diff --git a/app/Controllers/LocaleController.php b/app/Controllers/LocaleController.php new file mode 100644 index 0000000..44a0137 --- /dev/null +++ b/app/Controllers/LocaleController.php @@ -0,0 +1,145 @@ + $resp['cookie_expires'], + 'path' => '/', + 'secure' => $resp['cookie_secure'], + 'httponly' => false, // read by JS-free client (we keep it readable for SSR) + 'samesite' => 'Lax', + ]; + setcookie(self::COOKIE_NAME, $resp['cookie_value'], $params); + } + + header('Location: ' . $resp['redirect'], true, $resp['status']); + exit; + } + + /** + * Pure response builder — testable without headers/exit. + * + * @return array{ + * status: int, + * redirect: string, + * set_cookie: bool, + * cookie_value: string, + * cookie_expires: int, + * cookie_secure: bool + * } + */ + public static function buildResponse( + ?string $locale, + ?string $explicitReturn, + ?string $referer, + bool $isHttps, + ): array { + $valid = is_string($locale) && Locale::isSupported($locale); + + $redirect = self::safeRedirect($explicitReturn, $referer); + + if (!$valid) { + return [ + 'status' => 302, + 'redirect' => $redirect, + 'set_cookie' => false, + 'cookie_value' => '', + 'cookie_expires' => 0, + 'cookie_secure' => $isHttps, + ]; + } + + return [ + 'status' => 302, + 'redirect' => $redirect, + 'set_cookie' => true, + 'cookie_value' => $locale, + 'cookie_expires' => time() + 60 * 60 * 24 * 365, // 1 year + 'cookie_secure' => $isHttps, + ]; + } + + /** + * Compute the current locale from $_GET, $_COOKIE and Accept-Language. + * Convenience for the front controller / View layer. + */ + public static function current(): string + { + return Locale::resolve( + isset($_GET['lang']) && is_string($_GET['lang']) ? $_GET['lang'] : null, + $_COOKIE[self::COOKIE_NAME] ?? null, + $_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? null, + ); + } + + /** + * Sanitize the return URL — same-origin path-only. Anything with a + * scheme, host, or `//` prefix is rejected and replaced with `/`. + */ + private static function safeRedirect(?string $explicit, ?string $referer): string + { + $candidate = $explicit ?: $referer; + if (!is_string($candidate) || $candidate === '') { + return '/'; + } + + // Reject absolute URLs and protocol-relative BEFORE backslash fixup, + // so a backslash in the input doesn't smuggle a `//` past us. + if (preg_match('#^(https?:)?//#i', $candidate)) { + return '/'; + } + + // Normalize backslashes (some browsers treat \ as /) + $candidate = str_replace('\\', '/', $candidate); + + // After normalization, re-check for `//` (could be a backslash trick). + if (preg_match('#^//#', $candidate)) { + return '/'; + } + + if ($candidate[0] !== '/') { + return '/'; + } + + return $candidate; + } +} diff --git a/public/index.php b/public/index.php index e343151..bbe6a5f 100755 --- a/public/index.php +++ b/public/index.php @@ -18,6 +18,7 @@ $router = new Router(); $router->addRoute('/', \App\Controllers\HomeController::class, 'index'); $router->addRoute('/impressum', \App\Controllers\ImpressumController::class, 'index'); $router->addRoute('/datenschutz', \App\Controllers\DatenschutzController::class, 'index'); +$router->addRoute('/locale', \App\Controllers\LocaleController::class, 'switch'); // Dispatch $uri = $_SERVER['REQUEST_URI'] ?? '/'; diff --git a/tests/Controllers/LocaleControllerTest.php b/tests/Controllers/LocaleControllerTest.php new file mode 100644 index 0000000..357eb80 --- /dev/null +++ b/tests/Controllers/LocaleControllerTest.php @@ -0,0 +1,161 @@ +assertSame(302, $resp['status']); + $this->assertSame('/foo', $resp['redirect']); + $this->assertTrue($resp['set_cookie']); + $this->assertSame('en', $resp['cookie_value']); + $this->assertGreaterThan(time(), $resp['cookie_expires']); + } + + public function testCookieExpiresInOneYear(): void + { + $before = time(); + $resp = LocaleController::buildResponse('uk', '/', null, false); + $after = time(); + + $expected = 60 * 60 * 24 * 365; + $this->assertGreaterThanOrEqual($before + $expected, $resp['cookie_expires']); + $this->assertLessThanOrEqual($after + $expected, $resp['cookie_expires']); + } + + public function testCookieSecureFlagMatchesHttps(): void + { + $http = LocaleController::buildResponse('en', '/', null, false); + $https = LocaleController::buildResponse('en', '/', null, true); + + $this->assertFalse($http['cookie_secure']); + $this->assertTrue($https['cookie_secure']); + } + + public function testSupportsAllFourLocales(): void + { + foreach (['de', 'en', 'uk', 'ru'] as $code) { + $resp = LocaleController::buildResponse($code, '/', null, false); + $this->assertTrue($resp['set_cookie'], "Locale {$code} should set cookie"); + $this->assertSame($code, $resp['cookie_value']); + } + } + + // ────────────────────────────────────────────── + // buildResponse() — invalid locale + // ────────────────────────────────────────────── + + public function testInvalidLocaleDoesNotSetCookie(): void + { + $resp = LocaleController::buildResponse('fr', '/', null, false); + $this->assertFalse($resp['set_cookie']); + $this->assertSame('', $resp['cookie_value']); + } + + public function testInvalidLocaleStillRedirects(): void + { + $resp = LocaleController::buildResponse('fr', '/safe-path', null, false); + $this->assertSame(302, $resp['status']); + $this->assertSame('/safe-path', $resp['redirect']); + } + + public function testNullLocaleDoesNotSetCookie(): void + { + $resp = LocaleController::buildResponse(null, '/', null, false); + $this->assertFalse($resp['set_cookie']); + } + + public function testEmptyStringLocaleDoesNotSetCookie(): void + { + $resp = LocaleController::buildResponse('', '/', null, false); + $this->assertFalse($resp['set_cookie']); + } + + // ────────────────────────────────────────────── + // safeRedirect() — return URL sanitization + // ────────────────────────────────────────────── + + #[DataProvider('provideOpenRedirectAttempts')] + public function testRejectsOpenRedirects(string $bad, string $expected): void + { + $resp = LocaleController::buildResponse('en', $bad, null, false); + $this->assertSame($expected, $resp['redirect']); + } + + public static function provideOpenRedirectAttempts(): array + { + return [ + 'absolute https' => ['https://evil.com/phish', '/'], + 'absolute http' => ['http://evil.com/phish', '/'], + 'protocol-relative' => ['//evil.com/phish', '/'], + 'scheme-relative upper' => ['//EVIL.COM/phish', '/'], + 'javascript scheme' => ['javascript:alert(1)', '/'], + 'data scheme' => ['data:text/html,