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:
Hermes
2026-06-04 08:57:33 +00:00
parent 63c8c759d2
commit ce21242308
3 changed files with 307 additions and 0 deletions

View 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']);
}
}