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