|
|
@@ -16,11 +16,26 @@ use Psr\Http\Server\RequestHandlerInterface;
|
|
|
*
|
|
|
* Token sources accepted (in order):
|
|
|
* 1. `X-CSRF-Token` header (htmx, AJAX).
|
|
|
- * 2. `csrf_token` form field.
|
|
|
+ * 2. `csrf_token` form field (form-encoded body).
|
|
|
+ * 3. `csrf_token` JSON body field (`Content-Type: application/json`).
|
|
|
*
|
|
|
* Safe methods (GET / HEAD / OPTIONS) skip validation; they only ensure
|
|
|
* the session-side token exists so the next form render has one to
|
|
|
* embed.
|
|
|
+ *
|
|
|
+ * SEC_REVIEW F54 — defence in depth on top of the constant-time token
|
|
|
+ * compare:
|
|
|
+ * - `Origin` / `Referer` header check. State-changing requests with
|
|
|
+ * a present-but-cross-origin `Origin` (or, when `Origin` is absent
|
|
|
+ * but `Referer` is present, with a cross-origin `Referer`) are
|
|
|
+ * refused with 403 BEFORE the token check, so a cross-origin
|
|
|
+ * attacker who somehow stole the token cookie still can't fire a
|
|
|
+ * same-token cross-origin POST. Both headers absent → fall
|
|
|
+ * through to the token check (legitimate older clients).
|
|
|
+ * - JSON body extraction. The middleware now also pulls the token
|
|
|
+ * out of a JSON body when `Content-Type: application/json`, so a
|
|
|
+ * future fetch-with-JSON PUT/PATCH endpoint inherits CSRF
|
|
|
+ * protection without a per-route shim.
|
|
|
*/
|
|
|
final class CsrfMiddleware implements MiddlewareInterface
|
|
|
{
|
|
|
@@ -38,12 +53,16 @@ final class CsrfMiddleware implements MiddlewareInterface
|
|
|
$token = $this->ensureToken();
|
|
|
|
|
|
if (!in_array(strtoupper($request->getMethod()), self::SAFE_METHODS, true)) {
|
|
|
+ // 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)) {
|
|
|
+ return $this->forbidden('cross-origin request refused');
|
|
|
+ }
|
|
|
+
|
|
|
$supplied = $this->extractToken($request);
|
|
|
if ($supplied === null || !hash_equals($token, $supplied)) {
|
|
|
- $response = $this->responseFactory->createResponse(403);
|
|
|
- $response->getBody()->write('Forbidden: missing or invalid CSRF token');
|
|
|
-
|
|
|
- return $response->withHeader('Content-Type', 'text/plain');
|
|
|
+ return $this->forbidden('missing or invalid CSRF token');
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -69,7 +88,125 @@ final class CsrfMiddleware implements MiddlewareInterface
|
|
|
if (is_array($body) && isset($body['csrf_token']) && is_string($body['csrf_token'])) {
|
|
|
return $body['csrf_token'];
|
|
|
}
|
|
|
+ // SEC_REVIEW F54: JSON body. `Slim` doesn't auto-parse JSON
|
|
|
+ // unless the bodyparsing middleware was wired for it; even
|
|
|
+ // when it is, defensive re-parse covers PUT/PATCH endpoints
|
|
|
+ // that read raw streams.
|
|
|
+ $contentType = strtolower($request->getHeaderLine('Content-Type'));
|
|
|
+ if (str_contains($contentType, 'application/json')) {
|
|
|
+ $raw = (string) $request->getBody();
|
|
|
+ if ($raw !== '') {
|
|
|
+ $decoded = json_decode($raw, true);
|
|
|
+ if (is_array($decoded) && isset($decoded['csrf_token']) && is_string($decoded['csrf_token'])) {
|
|
|
+ return $decoded['csrf_token'];
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
|
|
|
return null;
|
|
|
}
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 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
|
|
|
+ * absent → returns true (legitimate older clients / direct curl).
|
|
|
+ * The token check still runs in that case; this is purely an
|
|
|
+ * additional layer.
|
|
|
+ *
|
|
|
+ * 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
|
|
|
+ {
|
|
|
+ $expected = self::originOf($request->getUri());
|
|
|
+ if ($expected === null) {
|
|
|
+ // 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.
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ $origin = trim($request->getHeaderLine('Origin'));
|
|
|
+ if ($origin !== '' && $origin !== 'null') {
|
|
|
+ return self::normalizeOrigin($origin) === $expected;
|
|
|
+ }
|
|
|
+
|
|
|
+ $referer = trim($request->getHeaderLine('Referer'));
|
|
|
+ if ($referer !== '') {
|
|
|
+ $refOrigin = self::originFromUrl($referer);
|
|
|
+ if ($refOrigin === null) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ return $refOrigin === $expected;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Neither header present — defer to the token check. Modern
|
|
|
+ // browsers always send `Origin` on POST/PUT/PATCH/DELETE, so
|
|
|
+ // this branch is the curl / programmatic / very-old-browser
|
|
|
+ // path; the token check is sufficient there.
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static function originOf(\Psr\Http\Message\UriInterface $uri): ?string
|
|
|
+ {
|
|
|
+ $scheme = strtolower($uri->getScheme());
|
|
|
+ $host = strtolower($uri->getHost());
|
|
|
+ if ($host === '') {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ $port = $uri->getPort();
|
|
|
+ $portPart = self::isDefaultPort($scheme, $port) ? '' : ':' . $port;
|
|
|
+
|
|
|
+ return $scheme . '://' . $host . $portPart;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static function originFromUrl(string $url): ?string
|
|
|
+ {
|
|
|
+ $parts = parse_url($url);
|
|
|
+ if ($parts === false || !isset($parts['scheme'], $parts['host'])) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ $scheme = strtolower((string) $parts['scheme']);
|
|
|
+ $host = strtolower((string) $parts['host']);
|
|
|
+ $port = isset($parts['port']) ? (int) $parts['port'] : null;
|
|
|
+ $portPart = self::isDefaultPort($scheme, $port) ? '' : ':' . $port;
|
|
|
+
|
|
|
+ return $scheme . '://' . $host . $portPart;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static function normalizeOrigin(string $origin): string
|
|
|
+ {
|
|
|
+ // `Origin: https://example.com:443` → strip default port; lower-case.
|
|
|
+ $parts = parse_url($origin);
|
|
|
+ if ($parts === false || !isset($parts['scheme'], $parts['host'])) {
|
|
|
+ return strtolower($origin);
|
|
|
+ }
|
|
|
+ $scheme = strtolower((string) $parts['scheme']);
|
|
|
+ $host = strtolower((string) $parts['host']);
|
|
|
+ $port = isset($parts['port']) ? (int) $parts['port'] : null;
|
|
|
+ $portPart = self::isDefaultPort($scheme, $port) ? '' : ':' . $port;
|
|
|
+
|
|
|
+ return $scheme . '://' . $host . $portPart;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static function isDefaultPort(string $scheme, ?int $port): bool
|
|
|
+ {
|
|
|
+ if ($port === null) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ return ($scheme === 'https' && $port === 443) || ($scheme === 'http' && $port === 80);
|
|
|
+ }
|
|
|
+
|
|
|
+ private function forbidden(string $reason): ResponseInterface
|
|
|
+ {
|
|
|
+ $response = $this->responseFactory->createResponse(403);
|
|
|
+ $response->getBody()->write('Forbidden: ' . $reason);
|
|
|
+
|
|
|
+ return $response->withHeader('Content-Type', 'text/plain');
|
|
|
+ }
|
|
|
}
|