Explorar o código

fix(ui): accept PUBLIC_URL as same-origin in CsrfMiddleware

Behind a TLS-terminating reverse proxy the embedded Caddy reports
HTTPS=off / SERVER_PORT=8080 to PHP, so the request URI Slim sees
is http://host:8080 while the browser sends Origin: https://host.
The same-origin gate then refused every state-changing request
with 403 "cross-origin request refused", breaking local-admin
login and every form. CsrfMiddleware now also accepts the
operator-declared PUBLIC_URL as a trusted origin without honouring
arbitrary X-Forwarded-* headers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ClaudePriv@chiappa.zhdk.ch hai 16 horas
pai
achega
92c53d7965

+ 15 - 1
ui/src/App/Container.php

@@ -94,6 +94,7 @@ final class Container
                 ?? sys_get_temp_dir() . '/irdb_login_throttle.json'),
             'settings.geoip_provider' => strtolower((string) ($settings['geoip_provider'] ?? 'dbip')),
             'settings.ui_locale' => trim((string) ($settings['ui_locale'] ?? '')),
+            'settings.public_url' => trim((string) ($settings['public_url'] ?? '')),
 
             LoggerInterface::class => factory(static function (ContainerInterface $c): LoggerInterface {
                 $logger = new Logger('ui');
@@ -207,7 +208,20 @@ final class Container
 
             // Middlewares — autowire works directly.
             SessionMiddleware::class => autowire(),
-            CsrfMiddleware::class => autowire(),
+            CsrfMiddleware::class => factory(static function (ContainerInterface $c): CsrfMiddleware {
+                /** @var ResponseFactoryInterface $rf */
+                $rf = $c->get(ResponseFactoryInterface::class);
+                /** @var string $publicUrl */
+                $publicUrl = $c->get('settings.public_url');
+                // PUBLIC_URL is the operator-declared browser-facing
+                // origin. When the UI sits behind a TLS-terminating
+                // reverse proxy, the embedded Caddy reports HTTPS=off
+                // / SERVER_PORT=8080 to PHP, so the request URI's
+                // origin won't match the browser's `Origin: https://…`.
+                // Trusting PUBLIC_URL bridges that gap without
+                // honouring arbitrary X-Forwarded-* headers.
+                return new CsrfMiddleware($rf, $publicUrl !== '' ? [$publicUrl] : []);
+            }),
             CspMiddleware::class => autowire(),
             AuthRequiredMiddleware::class => factory(static function (ContainerInterface $c): AuthRequiredMiddleware {
                 /** @var SessionManager $sessions */

+ 51 - 12
ui/src/Http/CsrfMiddleware.php

@@ -44,8 +44,36 @@ final class CsrfMiddleware implements MiddlewareInterface
 
     private const SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS'];
 
-    public function __construct(private readonly ResponseFactoryInterface $responseFactory)
-    {
+    /**
+     * Origins (scheme://host[:port]) that count as "same origin" in
+     * addition to the request URI's own origin. Populated from
+     * `PUBLIC_URL`: when the UI sits behind a TLS-terminating reverse
+     * proxy, the embedded Caddy reports `HTTPS=off` / `SERVER_PORT=8080`
+     * to PHP, so the request URI is `http://host:8080` while the browser
+     * sends `Origin: https://host`. PUBLIC_URL is the operator's
+     * declaration of the canonical browser-facing origin and is the
+     * trustworthy way to bridge that gap.
+     *
+     * @var list<string>
+     */
+    private readonly array $trustedOrigins;
+
+    /**
+     * @param list<string> $trustedOrigins Extra accepted origins
+     *   (typically a parsed PUBLIC_URL). Empty disables this fallback.
+     */
+    public function __construct(
+        private readonly ResponseFactoryInterface $responseFactory,
+        array $trustedOrigins = [],
+    ) {
+        $normalized = [];
+        foreach ($trustedOrigins as $origin) {
+            $parsed = self::originFromUrl((string) $origin);
+            if ($parsed !== null) {
+                $normalized[] = $parsed;
+            }
+        }
+        $this->trustedOrigins = array_values(array_unique($normalized));
     }
 
     public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
@@ -56,7 +84,7 @@ final class CsrfMiddleware implements MiddlewareInterface
             // SEC_REVIEW F54: same-origin Origin/Referer check before
             // the token compare. Refuses a cross-origin POST even if
             // the token cookie were somehow exfiltrated.
-            if (!self::isSameOrigin($request)) {
+            if (!$this->isSameOrigin($request)) {
                 return $this->forbidden('cross-origin request refused');
             }
 
@@ -109,29 +137,40 @@ final class CsrfMiddleware implements MiddlewareInterface
     /**
      * SEC_REVIEW F54: same-origin gate. Returns true iff the request's
      * `Origin` (preferred) or `Referer` (fallback) matches the
-     * scheme+host+port the request itself was made to. Both headers
+     * scheme+host+port the request itself was made to OR any
+     * operator-configured trusted origin (PUBLIC_URL). Both headers
      * absent → returns true (legitimate older clients / direct curl).
      * The token check still runs in that case; this is purely an
      * additional layer.
      *
+     * The trusted-origin fallback handles TLS-terminating reverse
+     * proxies: PHP sees `http://host:8080` while the browser sends
+     * `Origin: https://host`. PUBLIC_URL declares the canonical
+     * browser-facing origin, so accepting it as same-origin is what
+     * the operator already promised this deployment serves.
+     *
      * Modern browsers always send `Origin` on POST/PUT/PATCH/DELETE,
      * so the absent-both branch is effectively the curl/test path.
      */
-    private static function isSameOrigin(ServerRequestInterface $request): bool
+    private function isSameOrigin(ServerRequestInterface $request): bool
     {
         $expected = self::originOf($request->getUri());
-        if ($expected === null) {
+        $accepted = $this->trustedOrigins;
+        if ($expected !== null) {
+            $accepted[] = $expected;
+        }
+        if ($accepted === []) {
             // Request URI has no host (CLI / test path / malformed
-            // entrypoint). Real browser traffic always carries a Host;
-            // fail open here so test setups don't have to thread a
-            // fake host through every fixture, and rely on the token
-            // check as the sole gate in this case.
+            // entrypoint) AND no trusted origin configured. Fail open
+            // so test setups don't have to thread a fake host through
+            // every fixture, and rely on the token check as the sole
+            // gate in this case.
             return true;
         }
 
         $origin = trim($request->getHeaderLine('Origin'));
         if ($origin !== '' && $origin !== 'null') {
-            return self::normalizeOrigin($origin) === $expected;
+            return in_array(self::normalizeOrigin($origin), $accepted, true);
         }
 
         $referer = trim($request->getHeaderLine('Referer'));
@@ -141,7 +180,7 @@ final class CsrfMiddleware implements MiddlewareInterface
                 return false;
             }
 
-            return $refOrigin === $expected;
+            return in_array($refOrigin, $accepted, true);
         }
 
         // Neither header present — defer to the token check. Modern

+ 50 - 0
ui/tests/Unit/Http/CsrfMiddlewareTest.php

@@ -190,6 +190,56 @@ final class CsrfMiddlewareTest extends TestCase
         self::assertSame(200, $response->getStatusCode());
     }
 
+    public function testTrustedPublicUrlOriginIsAcceptedBehindProxy(): void
+    {
+        // TLS-terminating reverse proxy in front of the UI: the
+        // browser sends `Origin: https://reputation.example.com` but
+        // FrankenPHP listens on plain :8080 so PHP's request URI is
+        // `http://reputation.example.com:8080`. The operator's
+        // PUBLIC_URL (passed to the constructor) is the canonical
+        // browser-facing origin and bridges the mismatch.
+        $mw = new CsrfMiddleware(new ResponseFactory(), ['https://reputation.example.com']);
+        $_SESSION[CsrfMiddleware::SESSION_KEY] = 'fixed-token';
+        $request = (new ServerRequestFactory())
+            ->createServerRequest('POST', 'http://reputation.example.com:8080/login/local')
+            ->withHeader('Origin', 'https://reputation.example.com')
+            ->withParsedBody(['csrf_token' => 'fixed-token']);
+
+        $response = $mw->process($request, $this->handler(static fn () => true));
+
+        self::assertSame(200, $response->getStatusCode());
+    }
+
+    public function testTrustedOriginRefererIsAcceptedBehindProxy(): void
+    {
+        $mw = new CsrfMiddleware(new ResponseFactory(), ['https://reputation.example.com']);
+        $_SESSION[CsrfMiddleware::SESSION_KEY] = 'fixed-token';
+        $request = (new ServerRequestFactory())
+            ->createServerRequest('POST', 'http://reputation.example.com:8080/login/local')
+            ->withHeader('Referer', 'https://reputation.example.com/login')
+            ->withParsedBody(['csrf_token' => 'fixed-token']);
+
+        $response = $mw->process($request, $this->handler(static fn () => true));
+
+        self::assertSame(200, $response->getStatusCode());
+    }
+
+    public function testTrustedOriginDoesNotWidenToOtherHosts(): void
+    {
+        // Configuring PUBLIC_URL must not turn the same-origin gate
+        // into "any host" — only the configured origin is added.
+        $mw = new CsrfMiddleware(new ResponseFactory(), ['https://reputation.example.com']);
+        $_SESSION[CsrfMiddleware::SESSION_KEY] = 'fixed-token';
+        $request = (new ServerRequestFactory())
+            ->createServerRequest('POST', 'http://reputation.example.com:8080/login/local')
+            ->withHeader('Origin', 'https://evil.example.com')
+            ->withParsedBody(['csrf_token' => 'fixed-token']);
+
+        $response = $mw->process($request, $this->handler(static fn () => true));
+
+        self::assertSame(403, $response->getStatusCode());
+    }
+
     public function testJsonBodyWithWrongTokenIs403(): void
     {
         $mw = new CsrfMiddleware(new ResponseFactory());