Files
landingpage-haus-schleusingen/app/Controllers/LocaleController.php
Hermes ce21242308 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
2026-06-04 08:57:33 +00:00

146 lines
4.6 KiB
PHP

<?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;
}
}