فهرست منبع

fix: prefix session cookie with __Host- in production (SEC_REVIEW F57)

The browser-enforced `__Host-` cookie-name prefix REJECTS any cookie
that doesn't have `Secure`, `Path=/` exactly, and no `Domain`
attribute. The existing `SessionManager::startSession` already sets
those attributes for production, so the rename is a free
defence-in-depth tightening that prevents:

  - parent-domain pages from setting a cookie that masks the
    session ID,
  - subdomain pages from doing the same,
  - the cookie being delivered over plain HTTP after a TLS-
    downgrade attack — the browser refuses to send a `__Host-`
    cookie except over HTTPS to the exact origin that set it.

New `SessionManager::cookieName()` returns
`__Host-irdb_session` when `secureCookie` is true (production /
HTTPS) and the unprefixed `irdb_session` otherwise.
`startSession()` now calls `session_name($this->cookieName())` so
the `Set-Cookie` header carries the prefixed name in production.

Dev mode (`APP_ENV=development`, `secureCookie=false`, HTTP) keeps
the unprefixed name because browsers reject `__Host-` cookies
without `Secure`, which means without the dev fallback the cookie
would never stick on local development.

The existing `COOKIE_NAME` constant remains as the unprefixed brand
name (used in log messages and the OidcController doc-comment); the
new instance method is the source of truth for the actual cookie
name on the wire. Existing rolling sessions get implicitly
invalidated on deploy when the cookie name changes — users
re-authenticate, acceptable cost.

Regression tests in `ui/tests/Unit/Auth/SessionManagerTest.php`:
`testCookieNameUsesHostPrefixWhenSecure` and
`testCookieNameSkipsHostPrefixInDev`. The 28 pre-existing
SessionManager tests continue to pass — they all use the CLI
fallback path which doesn't set the cookie, so the rename is
transparent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
chiappa 3 روز پیش
والد
کامیت
67011c8cea
2فایلهای تغییر یافته به همراه59 افزوده شده و 1 حذف شده
  1. 30 1
      ui/src/Auth/SessionManager.php
  2. 29 0
      ui/tests/Unit/Auth/SessionManagerTest.php

+ 30 - 1
ui/src/Auth/SessionManager.php

@@ -29,6 +29,18 @@ namespace App\Auth;
  */
 class SessionManager
 {
+    /**
+     * Base name without prefix. The actual cookie name set on the
+     * response is `cookieName()`, which adds the `__Host-` prefix
+     * (SEC_REVIEW F57) when the cookie is `Secure` so the browser
+     * enforces:
+     *   - `Secure` (HTTPS-only),
+     *   - `Path=/` exactly,
+     *   - no `Domain` attribute (host-only, no subdomain cookie
+     *     shadowing).
+     * The constant remains for callers that want the unprefixed
+     * brand name (e.g. log messages, doc strings).
+     */
     public const COOKIE_NAME = 'irdb_session';
 
     private const KEY_USER = '_user';
@@ -82,11 +94,28 @@ class SessionManager
             'httponly' => true,
             'samesite' => 'Lax',
         ]);
-        session_name(self::COOKIE_NAME);
+        session_name($this->cookieName());
         session_start();
         $this->enforceLifetimes();
     }
 
+    /**
+     * SEC_REVIEW F57: prefix the session cookie with `__Host-` when
+     * we're issuing it `Secure` (production / HTTPS). The prefix is
+     * a browser-enforced contract that REJECTS the cookie unless:
+     *   - it has `Secure`,
+     *   - `Path=/` exactly,
+     *   - no `Domain` attribute (so a subdomain can't shadow the
+     *     parent-domain session).
+     * `__Host-` cookies are also rejected over HTTP, so dev mode
+     * (`secureCookie=false`, `APP_ENV=development`) keeps the
+     * unprefixed name.
+     */
+    public function cookieName(): string
+    {
+        return $this->secureCookie ? '__Host-' . self::COOKIE_NAME : self::COOKIE_NAME;
+    }
+
     public function regenerateId(): void
     {
         if (session_status() !== PHP_SESSION_ACTIVE) {

+ 29 - 0
ui/tests/Unit/Auth/SessionManagerTest.php

@@ -147,6 +147,35 @@ final class SessionManagerTest extends TestCase
         $sm->clear();
     }
 
+    public function testCookieNameUsesHostPrefixWhenSecure(): void
+    {
+        // SEC_REVIEW F57: in production the session cookie is named
+        // `__Host-irdb_session` so the browser enforces `Secure`,
+        // `Path=/`, and no `Domain` attribute (host-only).
+        $sm = new SessionManager(
+            secureCookie: true,
+            idleSeconds: 28800,
+            absoluteSeconds: 86400,
+            cliFallback: true,
+        );
+        self::assertSame('__Host-irdb_session', $sm->cookieName());
+    }
+
+    public function testCookieNameSkipsHostPrefixInDev(): void
+    {
+        // Over plain HTTP (`APP_ENV=development`, `secureCookie=false`)
+        // the `__Host-` prefix is rejected by the browser because the
+        // cookie isn't Secure. Dev keeps the unprefixed name so local
+        // sessions actually stick.
+        $sm = new SessionManager(
+            secureCookie: false,
+            idleSeconds: 28800,
+            absoluteSeconds: 86400,
+            cliFallback: true,
+        );
+        self::assertSame('irdb_session', $sm->cookieName());
+    }
+
     public function testRegenerateIdRotatesCsrfTokenInCliMode(): void
     {
         // SEC_REVIEW F40: a CSRF token minted before a privilege boundary