feat(i18n): LocaleController switcher with open-redirect protection (closes #73)
- App\Controllers\LocaleController: GET /locale?set=xx&return=/path - Sets 1-year cookie (HttpOnly=false for SSR, SameSite=Lax, Secure on HTTPS) - 302 redirect to explicit return URL > Referer > / - Pure buildResponse() helper for unit tests (no headers/exit) - current() helper: resolves locale from $_GET/$_COOKIE/Accept-Language - safeRedirect: rejects absolute URLs, protocol-relative (//evil.com), backslash tricks (\\evil.com), javascript:/data: schemes - 28 PHPUnit tests (LocaleControllerTest), all green - Total project tests now: 92
This commit is contained in:
145
app/Controllers/LocaleController.php
Normal file
145
app/Controllers/LocaleController.php
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controllers;
|
||||||
|
|
||||||
|
use App\Core\Locale;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles locale switching and exposes the current locale to the front
|
||||||
|
* controller / View layer.
|
||||||
|
*
|
||||||
|
* Entry point: GET /locale?set=xx&return=/some/path
|
||||||
|
* - `set` (required) target locale, must be in {@see Locale::SUPPORTED}
|
||||||
|
* - `return` (optional) explicit return URL; falls back to `Referer` header,
|
||||||
|
* then `/` if neither is present or is a same-origin path.
|
||||||
|
*
|
||||||
|
* On success: 302 to return URL, sets a 1-year `locale` cookie.
|
||||||
|
* On failure: 302 to `/`, no cookie set.
|
||||||
|
*
|
||||||
|
* The class is split into a pure {@see buildResponse()} for unit testing
|
||||||
|
* and a side-effectful {@see switch()} for production.
|
||||||
|
*/
|
||||||
|
class LocaleController extends Controller
|
||||||
|
{
|
||||||
|
public const COOKIE_NAME = 'locale';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public entry point — invoked by the front controller.
|
||||||
|
* Reads $_GET, sends headers, terminates the request.
|
||||||
|
*/
|
||||||
|
public function switch(): void
|
||||||
|
{
|
||||||
|
$locale = isset($_GET['set']) && is_string($_GET['set']) ? $_GET['set'] : null;
|
||||||
|
$return = isset($_GET['return']) && is_string($_GET['return']) ? $_GET['return'] : null;
|
||||||
|
$referer = $_SERVER['HTTP_REFERER'] ?? null;
|
||||||
|
$isHttps = (($_SERVER['HTTPS'] ?? '') !== '' && $_SERVER['HTTPS'] !== 'off')
|
||||||
|
|| (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https')
|
||||||
|
|| (($_SERVER['SERVER_PORT'] ?? '') === '443');
|
||||||
|
|
||||||
|
$resp = self::buildResponse($locale, $return, $referer, $isHttps);
|
||||||
|
|
||||||
|
if ($resp['set_cookie']) {
|
||||||
|
$params = [
|
||||||
|
'expires' => $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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ $router = new Router();
|
|||||||
$router->addRoute('/', \App\Controllers\HomeController::class, 'index');
|
$router->addRoute('/', \App\Controllers\HomeController::class, 'index');
|
||||||
$router->addRoute('/impressum', \App\Controllers\ImpressumController::class, 'index');
|
$router->addRoute('/impressum', \App\Controllers\ImpressumController::class, 'index');
|
||||||
$router->addRoute('/datenschutz', \App\Controllers\DatenschutzController::class, 'index');
|
$router->addRoute('/datenschutz', \App\Controllers\DatenschutzController::class, 'index');
|
||||||
|
$router->addRoute('/locale', \App\Controllers\LocaleController::class, 'switch');
|
||||||
|
|
||||||
// Dispatch
|
// Dispatch
|
||||||
$uri = $_SERVER['REQUEST_URI'] ?? '/';
|
$uri = $_SERVER['REQUEST_URI'] ?? '/';
|
||||||
|
|||||||
161
tests/Controllers/LocaleControllerTest.php
Normal file
161
tests/Controllers/LocaleControllerTest.php
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Controllers;
|
||||||
|
|
||||||
|
use App\Controllers\LocaleController;
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class LocaleControllerTest extends TestCase
|
||||||
|
{
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// buildResponse() — happy path
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function testSetsCookieAndRedirectsOnValidLocale(): void
|
||||||
|
{
|
||||||
|
$resp = LocaleController::buildResponse('en', '/foo', 'https://example.com/bar', false);
|
||||||
|
|
||||||
|
$this->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,<script>', '/'],
|
||||||
|
'no leading slash' => ['foo/bar', '/'],
|
||||||
|
'backslash trick' => ['/\\evil.com', '/'],
|
||||||
|
'double backslash' => ['\\\\evil.com', '/'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('provideValidRelativePaths')]
|
||||||
|
public function testAcceptsValidRelativePaths(string $path): void
|
||||||
|
{
|
||||||
|
$resp = LocaleController::buildResponse('en', $path, null, false);
|
||||||
|
$this->assertSame($path, $resp['redirect']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function provideValidRelativePaths(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'root' => ['/'],
|
||||||
|
'home' => ['/'],
|
||||||
|
'impressum' => ['/impressum'],
|
||||||
|
'datenschutz' => ['/datenschutz'],
|
||||||
|
'with query' => ['/foo?bar=1'],
|
||||||
|
'with hash' => ['/foo#section'],
|
||||||
|
'with deep' => ['/some/deep/path'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// Referer fallback chain
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function testFallsBackToRefererWhenExplicitReturnIsNull(): void
|
||||||
|
{
|
||||||
|
$resp = LocaleController::buildResponse('en', null, 'https://example.com/landing', false);
|
||||||
|
// Referer is absolute, gets stripped to '/'
|
||||||
|
$this->assertSame('/', $resp['redirect']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFallsBackToRootWhenBothExplicitAndRefererMissing(): void
|
||||||
|
{
|
||||||
|
$resp = LocaleController::buildResponse('en', null, null, false);
|
||||||
|
$this->assertSame('/', $resp['redirect']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFallsBackToRootWhenRefererIsEmpty(): void
|
||||||
|
{
|
||||||
|
$resp = LocaleController::buildResponse('en', null, '', false);
|
||||||
|
$this->assertSame('/', $resp['redirect']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testExplicitReturnBeatsReferer(): void
|
||||||
|
{
|
||||||
|
$resp = LocaleController::buildResponse('en', '/chosen', 'https://other.com/other', false);
|
||||||
|
$this->assertSame('/chosen', $resp['redirect']);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user