|
@@ -44,8 +44,36 @@ final class CsrfMiddleware implements MiddlewareInterface
|
|
|
|
|
|
|
|
private const SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS'];
|
|
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
|
|
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
|
|
// SEC_REVIEW F54: same-origin Origin/Referer check before
|
|
|
// the token compare. Refuses a cross-origin POST even if
|
|
// the token compare. Refuses a cross-origin POST even if
|
|
|
// the token cookie were somehow exfiltrated.
|
|
// the token cookie were somehow exfiltrated.
|
|
|
- if (!self::isSameOrigin($request)) {
|
|
|
|
|
|
|
+ if (!$this->isSameOrigin($request)) {
|
|
|
return $this->forbidden('cross-origin request refused');
|
|
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
|
|
* SEC_REVIEW F54: same-origin gate. Returns true iff the request's
|
|
|
* `Origin` (preferred) or `Referer` (fallback) matches the
|
|
* `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).
|
|
* absent → returns true (legitimate older clients / direct curl).
|
|
|
* The token check still runs in that case; this is purely an
|
|
* The token check still runs in that case; this is purely an
|
|
|
* additional layer.
|
|
* 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,
|
|
* Modern browsers always send `Origin` on POST/PUT/PATCH/DELETE,
|
|
|
* so the absent-both branch is effectively the curl/test path.
|
|
* 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());
|
|
$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
|
|
// 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;
|
|
return true;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
$origin = trim($request->getHeaderLine('Origin'));
|
|
$origin = trim($request->getHeaderLine('Origin'));
|
|
|
if ($origin !== '' && $origin !== 'null') {
|
|
if ($origin !== '' && $origin !== 'null') {
|
|
|
- return self::normalizeOrigin($origin) === $expected;
|
|
|
|
|
|
|
+ return in_array(self::normalizeOrigin($origin), $accepted, true);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
$referer = trim($request->getHeaderLine('Referer'));
|
|
$referer = trim($request->getHeaderLine('Referer'));
|
|
@@ -141,7 +180,7 @@ final class CsrfMiddleware implements MiddlewareInterface
|
|
|
return false;
|
|
return false;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- return $refOrigin === $expected;
|
|
|
|
|
|
|
+ return in_array($refOrigin, $accepted, true);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// Neither header present — defer to the token check. Modern
|
|
// Neither header present — defer to the token check. Modern
|