From 3962b0b43c08f64ae57ad74df4752dfbc791a6c5 Mon Sep 17 00:00:00 2001 From: Caio Fernandes Date: Sat, 10 Oct 2026 11:51:59 -0300 Subject: [PATCH 1/2] feat: initial security middlewares (CORS, trusted proxy, headers, CSRF, JWT, rate limit) PSR-15 middlewares that fail closed and validate their configuration at construction. CORS and trusted proxies are implemented here; headers, CSRF, JWT and rate limiting are adapters for bepsvpt/secure-headers, yiisoft/csrf, firebase/php-jwt and symfony/rate-limiter (optional, version-floored via conflict). Co-Authored-By: Claude Opus 5.5 --- .gitattributes | 7 + .github/workflows/ci.yml | 60 ++++ CHANGELOG.md | 27 ++ CLAUDE.md | 39 +++ README.md | 258 ++++++++++++++++++ composer.json | 84 ++++++ phpstan.neon | 9 + phpunit.xml | 21 ++ src/Cors/CorsConfig.php | 122 +++++++++ src/Cors/CorsMiddleware.php | 169 ++++++++++++ src/Csrf/CsrfConfig.php | 50 ++++ src/Csrf/CsrfMiddleware.php | 93 +++++++ .../InvalidConfigurationException.php | 17 ++ src/Exception/MissingDependencyException.php | 23 ++ src/Exception/SecurityException.php | 14 + src/Headers/SecurityHeadersConfig.php | 105 +++++++ src/Headers/SecurityHeadersMiddleware.php | 92 +++++++ src/Jwt/JwtAuthMiddleware.php | 137 ++++++++++ src/Jwt/JwtConfig.php | 99 +++++++ src/Proxy/IpRange.php | 73 +++++ src/Proxy/TrustedProxyConfig.php | 56 ++++ src/Proxy/TrustedProxyMiddleware.php | 69 +++++ src/RateLimit/RateLimitConfig.php | 32 +++ src/RateLimit/RateLimitMiddleware.php | 95 +++++++ src/Support/ErrorResponse.php | 26 ++ tests/Cors/CorsMiddlewareTest.php | 196 +++++++++++++ tests/Csrf/CsrfMiddlewareTest.php | 168 ++++++++++++ tests/Functional/PipelineTest.php | 142 ++++++++++ .../Headers/SecurityHeadersMiddlewareTest.php | 104 +++++++ tests/Jwt/JwtAuthMiddlewareTest.php | 189 +++++++++++++ tests/Proxy/TrustedProxyMiddlewareTest.php | 124 +++++++++ tests/RateLimit/RateLimitMiddlewareTest.php | 119 ++++++++ tests/Support/RecordingHandler.php | 33 +++ 33 files changed, 2852 insertions(+) create mode 100644 .gitattributes create mode 100644 .github/workflows/ci.yml create mode 100644 CHANGELOG.md create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 composer.json create mode 100644 phpstan.neon create mode 100644 phpunit.xml create mode 100644 src/Cors/CorsConfig.php create mode 100644 src/Cors/CorsMiddleware.php create mode 100644 src/Csrf/CsrfConfig.php create mode 100644 src/Csrf/CsrfMiddleware.php create mode 100644 src/Exception/InvalidConfigurationException.php create mode 100644 src/Exception/MissingDependencyException.php create mode 100644 src/Exception/SecurityException.php create mode 100644 src/Headers/SecurityHeadersConfig.php create mode 100644 src/Headers/SecurityHeadersMiddleware.php create mode 100644 src/Jwt/JwtAuthMiddleware.php create mode 100644 src/Jwt/JwtConfig.php create mode 100644 src/Proxy/IpRange.php create mode 100644 src/Proxy/TrustedProxyConfig.php create mode 100644 src/Proxy/TrustedProxyMiddleware.php create mode 100644 src/RateLimit/RateLimitConfig.php create mode 100644 src/RateLimit/RateLimitMiddleware.php create mode 100644 src/Support/ErrorResponse.php create mode 100644 tests/Cors/CorsMiddlewareTest.php create mode 100644 tests/Csrf/CsrfMiddlewareTest.php create mode 100644 tests/Functional/PipelineTest.php create mode 100644 tests/Headers/SecurityHeadersMiddlewareTest.php create mode 100644 tests/Jwt/JwtAuthMiddlewareTest.php create mode 100644 tests/Proxy/TrustedProxyMiddlewareTest.php create mode 100644 tests/RateLimit/RateLimitMiddlewareTest.php create mode 100644 tests/Support/RecordingHandler.php diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..00ec317 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +/.github export-ignore +/tests export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/phpstan.neon export-ignore +/phpunit.xml export-ignore +/CLAUDE.md export-ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e60768a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,60 @@ +name: CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +permissions: + contents: read + +jobs: + tests: + runs-on: ubuntu-latest + name: PHP ${{ matrix.php }} · ${{ matrix.deps }} + strategy: + fail-fast: false + matrix: + include: + - php: '8.1' + deps: 'lowest' + - php: '8.3' + deps: 'highest' + - php: '8.4' + deps: 'highest' + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: mbstring, openssl, json + coverage: none + + - name: Validate composer.json + run: composer validate --strict + + - name: Install dependencies + run: | + if [ "${{ matrix.deps }}" = "lowest" ]; then + composer update --prefer-lowest --prefer-stable --no-progress --no-interaction + else + composer update --prefer-stable --no-progress --no-interaction + fi + + - name: Audit dependencies + run: composer audit + + - name: Run PHPUnit + run: vendor/bin/phpunit + + - name: Run PHPStan (Level 9) + if: matrix.deps == 'highest' + run: vendor/bin/phpstan analyse --no-progress + + - name: Run PHPCS (PSR-12) + run: vendor/bin/phpcs --standard=PSR12 src tests diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f93b29d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,27 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- **`Cors\CorsMiddleware`** / `CorsConfig` — Fetch-standard CORS: preflight answered without reaching + the handler (`204`, or `403` for disallowed origin/method/header), exact origin matching, + `Vary: Origin`, credentials only with explicit origins. +- **`Proxy\TrustedProxyMiddleware`** / `TrustedProxyConfig` — resolves the client IP into the + `client_ip` attribute, honouring `X-Forwarded-For` only from trusted proxies (IPv4/IPv6 CIDR). +- **`Headers\SecurityHeadersMiddleware`** / `SecurityHeadersConfig` — adapter for + `bepsvpt/secure-headers` with JSON-API defaults (HSTS, `nosniff`, `DENY`, restrictive CSP, + `strict-origin-when-cross-origin`). +- **`Csrf\CsrfMiddleware`** / `CsrfConfig` — adapter for `yiisoft/csrf`; protects every + state-changing method, accepts the token from the body or header. +- **`Jwt\JwtAuthMiddleware`** / `JwtConfig` — adapter for `firebase/php-jwt`; single algorithm, + minimum secret length, `iss`/`aud` checks, public paths, claims in the `user` attribute. +- **`RateLimit\RateLimitMiddleware`** / `RateLimitConfig` — adapter for `symfony/rate-limiter`; + `429` with `Retry-After` and `X-RateLimit-*` headers, keyed by the resolved client IP. +- `Exception\InvalidConfigurationException` and `Exception\MissingDependencyException`, both + implementing `Exception\SecurityException`. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e0504e4 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,39 @@ +# CLAUDE.md - PivotPHP Security + +Este arquivo orienta o trabalho no pacote **pivotphp/security**. + +## Visão geral + +Middlewares PSR-15 de segurança para o PivotPHP e qualquer pipeline PSR-15. Dependências +obrigatórias: apenas interfaces PSR (`psr/http-message`, `psr/http-factory`, +`psr/http-server-*`). Cada adapter depende da sua biblioteca, declarada em `suggest` e com versão +mínima em `conflict`. + +| Componente | Implementação | +|---|---| +| `Cors` | própria (nenhuma biblioteca PSR-7 madura e ativa compatível com PSR-7 2.0) | +| `Proxy` | própria (só existe acoplado ao HttpFoundation) | +| `Headers` | adapter de `bepsvpt/secure-headers` | +| `Csrf` | adapter de `yiisoft/csrf` | +| `Jwt` | adapter de `firebase/php-jwt` | +| `RateLimit` | adapter de `symfony/rate-limiter` (`RateLimiterFactory`; a interface só existe a partir da 7.3) | + +## Comandos + +```bash +composer test # PHPUnit +composer phpstan # PHPStan nível 9 (src + tests) +composer cs:check # PSR-12 +composer quality:check # phpstan + cs:check + test +``` + +## Regras de design + +- **Um middleware por preocupação**; config em objeto imutável (`readonly`, PHP 8.1 — não usar + `readonly class`, que exige 8.2), validado no construtor com `InvalidConfigurationException`. +- **Fail closed**: falha de verificação devolve resposta (`401/403/429`) criada pela + `ResponseFactoryInterface` injetada; nunca chama o handler. Não lançar exceção para o cliente. +- **Não implementar criptografia**: JWT, tokens CSRF e contagem atômica ficam nas bibliotecas. +- **Sem estado estático entre requisições** (por isso nonces de CSP não são suportados; o + `JWT::$leeway` do firebase é restaurado em `finally`). +- Matriz de CI: PHP 8.1 com dependências mínimas; 8.3 e 8.4 com máximas. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2fcc058 --- /dev/null +++ b/README.md @@ -0,0 +1,258 @@ +# PivotPHP Security + +[![PHP Version](https://img.shields.io/badge/php-%5E8.1-blue)](https://www.php.net/) +[![License](https://img.shields.io/badge/license-MIT-green)](LICENSE) +[![PSR-15](https://img.shields.io/badge/PSR--15-middleware-brightgreen)](https://www.php-fig.org/psr/psr-15/) + +Security middlewares for [PivotPHP](https://github.com/PivotPHP) and **any PSR-15 pipeline**. + +- One middleware per concern: CORS, trusted proxies, security headers, CSRF, JWT, rate limiting. +- **Fail closed**: a failed check returns `401`/`403`/`429` and never reaches your handler. +- **Validated configuration**: unsafe settings (`*` origin with credentials, empty JWT secret, + `POST` declared "safe"...) throw `InvalidConfigurationException` when the config is built. +- **No hand-rolled crypto**: JWT, CSRF tokens, header building and rate limiting delegate to + established libraries; this package provides the PSR-15 adapters. + +The only required dependencies are the PSR interfaces. Each adapter needs its library, installed on +demand: + +| Middleware | Library | Install | +|---|---|---| +| `Cors\CorsMiddleware` | — | — | +| `Proxy\TrustedProxyMiddleware` | — | — | +| `Headers\SecurityHeadersMiddleware` | [bepsvpt/secure-headers](https://github.com/bepsvpt/secure-headers) | `composer require bepsvpt/secure-headers` | +| `Csrf\CsrfMiddleware` | [yiisoft/csrf](https://github.com/yiisoft/csrf) | `composer require yiisoft/csrf` | +| `Jwt\JwtAuthMiddleware` | [firebase/php-jwt](https://github.com/firebase/php-jwt) | `composer require firebase/php-jwt` | +| `RateLimit\RateLimitMiddleware` | [symfony/rate-limiter](https://symfony.com/doc/current/rate_limiter.html) | `composer require symfony/rate-limiter` | + +## Installation + +```bash +composer require pivotphp/security +``` + +Middlewares that build responses take a PSR-17 `ResponseFactoryInterface` (e.g. +`PivotPHP\Http\Factory\Psr17Factory` or `Nyholm\Psr7\Factory\Psr17Factory`). + +## Recommended order + +```text +TrustedProxy → SecurityHeaders → CORS → RateLimit → (body parsing) → CSRF → JWT → routes +``` + +- **TrustedProxy first**, so every later middleware sees the real client IP. +- **SecurityHeaders and CORS before anything that rejects**, so `401`/`403`/`429` responses still + carry security and CORS headers (the browser can read the error). +- **CORS before authentication**: preflight requests carry no credentials. +- **Body parsing before CSRF**, so form fields are visible. + +## CORS + +```php +use PivotPHP\Security\Cors\CorsConfig; +use PivotPHP\Security\Cors\CorsMiddleware; + +$cors = new CorsMiddleware($responseFactory, new CorsConfig( + allowedOrigins: ['https://app.example.com'], + allowCredentials: true, +)); +``` + +| Option | Default | Notes | +|---|---|---| +| `allowedOrigins` | `[]` | Exact `scheme://host[:port]`, lower case, no path. `['*']` allows any origin. | +| `allowedMethods` | `GET, POST, PUT, PATCH, DELETE` | | +| `allowedHeaders` | `Content-Type, Authorization` | Request headers accepted in preflight. | +| `exposedHeaders` | `[]` | Response headers readable by the browser. | +| `allowCredentials` | `false` | | +| `maxAge` | `600` | Preflight cache in seconds; `null` omits the header. | + +Behaviour: + +- Requests without `Origin` pass through untouched. +- Preflight (`OPTIONS` + `Access-Control-Request-Method`) is answered by the middleware: `204` with + `Access-Control-Allow-*` when origin, method and headers are allowed; `403` otherwise. The handler + is never called. +- Actual requests always reach the handler; `Access-Control-Allow-Origin` is added only for allowed + origins. `Vary: Origin` is added whenever the response depends on the origin. + +> ⚠️ **Security:** `*` with `allowCredentials: true` is rejected at configuration. The `null` +> origin (sandboxed iframes, `file://`) cannot be allowed. CORS does not protect the server — it +> only tells browsers what other origins may read; authenticate and authorize every request. + +## Trusted proxies + +```php +use PivotPHP\Security\Proxy\TrustedProxyConfig; +use PivotPHP\Security\Proxy\TrustedProxyMiddleware; + +$proxy = new TrustedProxyMiddleware(new TrustedProxyConfig( + trustedProxies: ['10.0.0.0/8', '2001:db8::/32'], +)); + +// later: $request->getAttribute('client_ip') +``` + +| Option | Default | Notes | +|---|---|---| +| `trustedProxies` | `[]` | IPv4/IPv6 addresses or CIDR ranges. | +| `header` | `X-Forwarded-For` | Comma-separated IP chain. | +| `attribute` | `client_ip` | Attribute receiving the resolved IP. | + +The forwarding header is honoured **only** when `REMOTE_ADDR` is a trusted proxy. The chain is read +right to left, skipping trusted proxies; the first untrusted address is the client. A malformed entry +stops the walk. The attribute is not set when `REMOTE_ADDR` is missing or invalid. + +> ⚠️ **Security:** never trust `X-Forwarded-For` from the internet. List only the proxies you run +> (load balancer, reverse proxy). + +## Security headers + +```php +use PivotPHP\Security\Headers\SecurityHeadersConfig; +use PivotPHP\Security\Headers\SecurityHeadersMiddleware; + +$headers = new SecurityHeadersMiddleware(); // API defaults +``` + +Default headers (JSON API, OWASP REST guidance): + +```text +Strict-Transport-Security: max-age=31536000; includeSubDomains +X-Content-Type-Options: nosniff +X-Frame-Options: DENY +Referrer-Policy: strict-origin-when-cross-origin +Content-Security-Policy: default-src 'none'; frame-ancestors 'none' +X-Permitted-Cross-Domain-Policies: none +``` + +| Option | Default | Notes | +|---|---|---| +| `hsts`, `hstsMaxAge`, `hstsIncludeSubDomains`, `hstsPreload` | `true`, `31536000`, `true`, `false` | Preload requires subdomains and max-age ≥ 1 year. | +| `frameOptions` | `deny` | `deny` or `sameorigin`. | +| `referrerPolicy` | `strict-origin-when-cross-origin` | Any standard value. | +| `contentSecurityPolicy` | `default-src 'none'; frame-ancestors 'none'` | `bepsvpt/secure-headers` CSP array; `null` disables. | +| `overrides` | `[]` | Raw `bepsvpt/secure-headers` config (Permissions-Policy, COOP/COEP/CORP, reporting...). | + +```php +new SecurityHeadersConfig(contentSecurityPolicy: [ + 'default-src' => ['self' => true], + 'img-src' => ['allow' => ['https://cdn.example.com']], +]); +``` + +Headers already set by the application are kept. The deprecated `X-XSS-Protection` is not sent. +CSP nonces are not supported (the library keeps them in static state shared across requests). + +> ⚠️ **Security:** an HTML application needs a CSP that matches its assets; the default is meant for +> APIs. Enable HSTS only on HTTPS-only hosts. + +## CSRF + +Needed when the API authenticates with **cookies** (session or JWT in a cookie). Pure bearer-token +APIs are not exposed to CSRF. + +```php +use PivotPHP\Security\Csrf\CsrfMiddleware; +use Yiisoft\Csrf\Hmac\HmacCsrfToken; +use Yiisoft\Csrf\MaskedCsrfToken; + +$token = new MaskedCsrfToken(new HmacCsrfToken($identityGenerator, $secret, 'sha256', 3600)); +$csrf = new CsrfMiddleware($responseFactory, $token); + +// render: $request->getAttribute('csrf_token')->getValue() +``` + +Token strategies come from `yiisoft/csrf`: `SynchronizerCsrfToken` (random token in a storage, e.g. +the session) or `HmacCsrfToken` (stateless, bound to an identity such as the session id). Wrap either +in `MaskedCsrfToken` when the token is rendered in HTML (BREACH mitigation). + +| Option (`CsrfConfig`) | Default | Notes | +|---|---|---| +| `parameterName` | `_csrf` | Form field. | +| `headerName` | `X-CSRF-Token` | Header (SPA/AJAX). | +| `safeMethods` | `GET, HEAD, OPTIONS` | `POST/PUT/PATCH/DELETE` cannot be declared safe. | +| `attribute` | `csrf_token` | Attribute holding the token object. | +| `failureStatus` | `403` | Any 4xx. | + +Every other method needs a valid token in the body field or header; otherwise the response is the +failure status and the handler is not called. + +> ⚠️ **Security:** keep `GET` free of side effects — safe methods are never checked. Combine with +> `SameSite` cookies. + +## JWT authentication + +```php +use PivotPHP\Security\Jwt\JwtAuthMiddleware; +use PivotPHP\Security\Jwt\JwtConfig; + +$auth = new JwtAuthMiddleware($responseFactory, new JwtConfig( + key: $_ENV['JWT_SECRET'], // ≥ 32 bytes for HS256 + algorithm: 'HS256', + publicPaths: ['/health', '/docs/*'], +)); + +// in the handler: $request->getAttribute('user') — the claims as an array +``` + +| Option | Default | Notes | +|---|---|---| +| `key` | — | Secret (HS*) or public key / PEM (RS*, PS256, ES*, EdDSA). | +| `algorithm` | `HS256` | Exactly one algorithm is accepted. | +| `publicPaths` | `[]` | `fnmatch` patterns that skip authentication. | +| `cookieName` | `null` | Cookie read when `Authorization` is absent. | +| `issuer` | `null` | Required `iss`. | +| `audience` | `[]` | Accepted `aud` values (any match). | +| `leeway` | `0` | Clock skew in seconds. | +| `attribute` | `user` | Attribute receiving the claims. | + +Signature, `exp`, `nbf` and `iat` are verified by `firebase/php-jwt` (non-numeric time claims are +rejected); `iss`/`aud` are verified here. Failures return `401` with `WWW-Authenticate: Bearer`. + +> ⚠️ **Security:** empty secrets, secrets shorter than the hash output (32/48/64 bytes) and unknown +> algorithms are rejected at configuration. Load keys from the environment or a secret store, never +> from source code. + +## Rate limiting + +```php +use PivotPHP\Security\RateLimit\RateLimitConfig; +use PivotPHP\Security\RateLimit\RateLimitMiddleware; +use Symfony\Component\RateLimiter\RateLimiterFactory; + +$limiter = new RateLimiterFactory( + ['id' => 'login', 'policy' => 'sliding_window', 'limit' => 5, 'interval' => '1 minute'], + $storage, // e.g. CacheStorage over Redis + $lockFactory, // symfony/lock — required for correct counts under concurrency +); + +$loginLimit = new RateLimitMiddleware($responseFactory, $limiter, new RateLimitConfig(keyPrefix: 'login:')); +``` + +| Option (`RateLimitConfig`) | Default | Notes | +|---|---|---| +| `keyPrefix` | `''` | One prefix per limit/route group. | +| `clientIpAttribute` | `client_ip` | Set by `TrustedProxyMiddleware`; falls back to `REMOTE_ADDR`. | +| `exposeHeaders` | `true` | `X-RateLimit-Limit`/`X-RateLimit-Remaining` on accepted responses. | + +A fourth constructor argument accepts a custom key resolver +(`fn (ServerRequestInterface $r): ?string`, e.g. an API key). Over the limit: `429` with +`Retry-After`, `X-RateLimit-Limit` and `X-RateLimit-Remaining: 0`; the handler is not called. +Use one middleware instance per limit (e.g. a stricter one on `/login`). + +> ⚠️ **Security:** with `InMemoryStorage` or without a `LockFactory`, limits are per process and +> concurrent requests can exceed them. Use a shared storage and a lock in production. + +## Testing + +```bash +composer test # PHPUnit +composer phpstan # PHPStan level 9 +composer cs:check # PSR-12 +composer quality:check # all of the above +``` + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..8c8e4ad --- /dev/null +++ b/composer.json @@ -0,0 +1,84 @@ +{ + "name": "pivotphp/security", + "description": "Security middlewares for PivotPHP and any PSR-15 pipeline: CORS, trusted proxies, security headers, CSRF, JWT and rate limiting", + "type": "library", + "keywords": [ + "php", + "security", + "psr-15", + "middleware", + "cors", + "csrf", + "jwt", + "rate-limit", + "security-headers", + "pivotphp" + ], + "license": "MIT", + "authors": [ + { + "name": "Caio Alberto Fernandes" + } + ], + "support": { + "issues": "https://github.com/PivotPHP/pivotphp-security/issues", + "source": "https://github.com/PivotPHP/pivotphp-security" + }, + "require": { + "php": "^8.1", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1|^2.0", + "psr/http-server-handler": "^1.0", + "psr/http-server-middleware": "^1.0" + }, + "require-dev": { + "bepsvpt/secure-headers": "^9.1", + "firebase/php-jwt": "^7.1.1", + "nyholm/psr7": "^1.8", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^10.5|^11.0", + "roave/security-advisories": "dev-latest", + "squizlabs/php_codesniffer": "^3.7", + "symfony/rate-limiter": "^6.4|^7.0|^8.0", + "yiisoft/csrf": "^2.2" + }, + "suggest": { + "bepsvpt/secure-headers": "Required by Headers\\SecurityHeadersMiddleware (^9.1)", + "firebase/php-jwt": "Required by Jwt\\JwtAuthMiddleware (^7.1.1)", + "symfony/rate-limiter": "Required by RateLimit\\RateLimitMiddleware (^6.4|^7.0|^8.0)", + "yiisoft/csrf": "Required by Csrf\\CsrfMiddleware (^2.2)" + }, + "conflict": { + "bepsvpt/secure-headers": "<9.1", + "firebase/php-jwt": "<7.1.1", + "symfony/rate-limiter": "<6.4", + "yiisoft/csrf": "<2.2" + }, + "autoload": { + "psr-4": { + "PivotPHP\\Security\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "PivotPHP\\Security\\Tests\\": "tests/" + } + }, + "scripts": { + "test": "phpunit", + "phpstan": "phpstan analyse --no-progress", + "cs:check": "phpcs --standard=PSR12 src tests", + "cs:fix": "phpcbf --standard=PSR12 src tests", + "quality:check": [ + "@phpstan", + "@cs:check", + "@test" + ] + }, + "minimum-stability": "stable", + "prefer-stable": true, + "config": { + "sort-packages": true + } +} diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..8f2ee06 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,9 @@ +includes: + - vendor/phpstan/phpstan-strict-rules/rules.neon + +parameters: + level: 9 + paths: + - src + - tests + treatPhpDocTypesAsCertain: false diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..ed49bf3 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,21 @@ + + + + + tests + + + + + src + + + diff --git a/src/Cors/CorsConfig.php b/src/Cors/CorsConfig.php new file mode 100644 index 0000000..c621108 --- /dev/null +++ b/src/Cors/CorsConfig.php @@ -0,0 +1,122 @@ + */ + public readonly array $allowedOrigins; + + /** @var list */ + public readonly array $allowedMethods; + + /** @var list lower-cased */ + public readonly array $allowedHeaders; + + /** @var list */ + public readonly array $exposedHeaders; + + /** + * @param list $allowedOrigins exact origins, or `['*']` for any + * @param list $allowedMethods + * @param list $allowedHeaders request headers allowed in preflight + * @param list $exposedHeaders response headers readable by the browser + * @param int|null $maxAge preflight cache in seconds (null omits the header) + */ + public function __construct( + array $allowedOrigins = [], + array $allowedMethods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'], + array $allowedHeaders = ['Content-Type', 'Authorization'], + array $exposedHeaders = [], + public readonly bool $allowCredentials = false, + public readonly ?int $maxAge = 600, + ) { + foreach ($allowedOrigins as $origin) { + self::assertOrigin($origin); + } + + if ($allowCredentials && in_array('*', $allowedOrigins, true)) { + throw new InvalidConfigurationException( + 'CORS: "*" cannot be used as an allowed origin when credentials are allowed; ' + . 'list the origins explicitly.' + ); + } + + if ($maxAge !== null && $maxAge < 0) { + throw new InvalidConfigurationException('CORS: maxAge must be zero or positive.'); + } + + $this->allowedOrigins = array_values(array_unique($allowedOrigins)); + $this->allowedMethods = array_values(array_unique(array_map( + static fn (string $method): string => self::assertToken(strtoupper($method), 'method'), + $allowedMethods + ))); + $this->allowedHeaders = array_values(array_unique(array_map( + static fn (string $header): string => self::assertToken(strtolower($header), 'header'), + $allowedHeaders + ))); + $this->exposedHeaders = array_values(array_unique(array_map( + static fn (string $header): string => self::assertToken($header, 'exposed header'), + $exposedHeaders + ))); + } + + public function allowsAnyOrigin(): bool + { + return in_array('*', $this->allowedOrigins, true); + } + + public function allowsOrigin(string $origin): bool + { + return $this->allowsAnyOrigin() || in_array($origin, $this->allowedOrigins, true); + } + + private static function assertOrigin(string $origin): void + { + if ($origin === '*') { + return; + } + + if ($origin === 'null') { + throw new InvalidConfigurationException( + 'CORS: the "null" origin is sent by sandboxed documents and file:// pages and must not be allowed.' + ); + } + + $parts = parse_url($origin); + $isValid = is_array($parts) + && isset($parts['scheme'], $parts['host']) + && in_array($parts['scheme'], ['http', 'https'], true) + && !isset($parts['path']) + && !isset($parts['query']) + && !isset($parts['fragment']) + && !isset($parts['user']) + && $origin === strtolower($origin); + + if (!$isValid) { + throw new InvalidConfigurationException(sprintf( + 'CORS: invalid origin "%s"; expected lower-case "scheme://host[:port]" without path or trailing slash.', + $origin + )); + } + } + + private static function assertToken(string $value, string $kind): string + { + if (preg_match('/^[!#$%&\'*+.^_`|~0-9A-Za-z-]+$/', $value) !== 1) { + throw new InvalidConfigurationException(sprintf('CORS: invalid %s "%s".', $kind, $value)); + } + + return $value; + } +} diff --git a/src/Cors/CorsMiddleware.php b/src/Cors/CorsMiddleware.php new file mode 100644 index 0000000..4733dea --- /dev/null +++ b/src/Cors/CorsMiddleware.php @@ -0,0 +1,169 @@ +getHeaderLine('Origin'); + + if ($origin === '') { + return $handler->handle($request); + } + + if ($this->isPreflight($request)) { + return $this->preflight($request, $origin); + } + + $response = $handler->handle($request); + + if (!$this->config->allowsOrigin($origin)) { + return $this->vary($response, ['Origin']); + } + + $response = $this->withAllowOrigin($response, $origin); + + if ($this->config->exposedHeaders !== []) { + $response = $response->withHeader( + 'Access-Control-Expose-Headers', + implode(', ', $this->config->exposedHeaders) + ); + } + + return $response; + } + + private function isPreflight(ServerRequestInterface $request): bool + { + return strtoupper($request->getMethod()) === 'OPTIONS' + && $request->hasHeader('Access-Control-Request-Method'); + } + + private function preflight(ServerRequestInterface $request, string $origin): ResponseInterface + { + $vary = ['Origin', 'Access-Control-Request-Method', 'Access-Control-Request-Headers']; + + if (!$this->config->allowsOrigin($origin)) { + return $this->vary($this->forbidden('CORS origin not allowed'), $vary); + } + + $method = strtoupper($request->getHeaderLine('Access-Control-Request-Method')); + if (!in_array($method, $this->config->allowedMethods, true)) { + return $this->vary($this->forbidden('CORS method not allowed'), $vary); + } + + $requestedHeaders = $this->requestedHeaders($request); + foreach ($requestedHeaders as $header) { + if (!in_array($header, $this->config->allowedHeaders, true)) { + return $this->vary($this->forbidden('CORS header not allowed'), $vary); + } + } + + $response = $this->withAllowOrigin($this->responseFactory->createResponse(204), $origin) + ->withHeader('Access-Control-Allow-Methods', implode(', ', $this->config->allowedMethods)); + + if ($this->config->allowedHeaders !== []) { + $response = $response->withHeader( + 'Access-Control-Allow-Headers', + implode(', ', $this->config->allowedHeaders) + ); + } + + if ($this->config->maxAge !== null) { + $response = $response->withHeader('Access-Control-Max-Age', (string) $this->config->maxAge); + } + + return $this->vary($response, $vary); + } + + /** + * @return list lower-cased header names + */ + private function requestedHeaders(ServerRequestInterface $request): array + { + $line = $request->getHeaderLine('Access-Control-Request-Headers'); + if (trim($line) === '') { + return []; + } + + return array_values(array_filter(array_map( + static fn (string $header): string => strtolower(trim($header)), + explode(',', $line) + ), static fn (string $header): bool => $header !== '')); + } + + private function withAllowOrigin(ResponseInterface $response, string $origin): ResponseInterface + { + if ($this->config->allowsAnyOrigin()) { + return $response->withHeader('Access-Control-Allow-Origin', '*'); + } + + $response = $this->vary($response->withHeader('Access-Control-Allow-Origin', $origin), ['Origin']); + + if ($this->config->allowCredentials) { + $response = $response->withHeader('Access-Control-Allow-Credentials', 'true'); + } + + return $response; + } + + private function forbidden(string $message): ResponseInterface + { + return ErrorResponse::create($this->responseFactory, 403, $message); + } + + /** + * Adds header names to `Vary` without duplicating existing entries. + * + * @param list $names + */ + private function vary(ResponseInterface $response, array $names): ResponseInterface + { + if ($this->config->allowsAnyOrigin()) { + return $response; + } + + $current = array_values(array_filter(array_map( + 'trim', + explode(',', $response->getHeaderLine('Vary')) + ), static fn (string $value): bool => $value !== '')); + + $lower = array_map('strtolower', $current); + foreach ($names as $name) { + if (!in_array(strtolower($name), $lower, true)) { + $current[] = $name; + $lower[] = strtolower($name); + } + } + + return $response->withHeader('Vary', implode(', ', $current)); + } +} diff --git a/src/Csrf/CsrfConfig.php b/src/Csrf/CsrfConfig.php new file mode 100644 index 0000000..2684a6b --- /dev/null +++ b/src/Csrf/CsrfConfig.php @@ -0,0 +1,50 @@ + */ + public readonly array $safeMethods; + + /** + * @param list $safeMethods methods that never require a token + * @param string $attribute request attribute receiving the `CsrfTokenInterface` + */ + public function __construct( + public readonly string $parameterName = '_csrf', + public readonly string $headerName = 'X-CSRF-Token', + array $safeMethods = ['GET', 'HEAD', 'OPTIONS'], + public readonly string $attribute = 'csrf_token', + public readonly int $failureStatus = 403, + ) { + if ($parameterName === '' || $headerName === '' || $attribute === '') { + throw new InvalidConfigurationException('CSRF: parameterName, headerName and attribute must not be empty.'); + } + + $safeMethods = array_values(array_unique(array_map('strtoupper', $safeMethods))); + $unsafe = array_intersect($safeMethods, ['POST', 'PUT', 'PATCH', 'DELETE']); + if ($unsafe !== []) { + throw new InvalidConfigurationException(sprintf( + 'CSRF: state-changing methods cannot be declared safe (%s).', + implode(', ', $unsafe) + )); + } + + if ($failureStatus < 400 || $failureStatus > 499) { + throw new InvalidConfigurationException('CSRF: failureStatus must be a 4xx status code.'); + } + + $this->safeMethods = $safeMethods; + } +} diff --git a/src/Csrf/CsrfMiddleware.php b/src/Csrf/CsrfMiddleware.php new file mode 100644 index 0000000..fa48079 --- /dev/null +++ b/src/Csrf/CsrfMiddleware.php @@ -0,0 +1,93 @@ +config = $config ?? new CsrfConfig(); + + $failure = new class ($responseFactory, $this->config->failureStatus) implements RequestHandlerInterface { + public function __construct( + private readonly ResponseFactoryInterface $factory, + private readonly int $status, + ) { + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return ErrorResponse::create($this->factory, $this->status, 'Invalid CSRF token'); + } + }; + + $this->inner = (new CsrfTokenMiddleware($responseFactory, $token, $failure)) + ->withParameterName($this->config->parameterName) + ->withHeaderName($this->config->headerName) + ->withSafeMethods($this->config->safeMethods); + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + $request = $request->withAttribute($this->config->attribute, $this->token); + + // yiisoft/csrf reads the parsed body as an array; PSR-7 also allows objects. + // Validate against an array view, but hand the original request to the handler. + $body = $request->getParsedBody(); + if (!is_object($body)) { + return $this->inner->process($request, $handler); + } + + $original = $request; + $next = new class ($handler, $original) implements RequestHandlerInterface { + public function __construct( + private readonly RequestHandlerInterface $handler, + private readonly ServerRequestInterface $original, + ) { + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->handler->handle($this->original); + } + }; + + return $this->inner->process($request->withParsedBody(get_object_vars($body)), $next); + } +} diff --git a/src/Exception/InvalidConfigurationException.php b/src/Exception/InvalidConfigurationException.php new file mode 100644 index 0000000..26d2aa5 --- /dev/null +++ b/src/Exception/InvalidConfigurationException.php @@ -0,0 +1,17 @@ + */ + public readonly array $contentSecurityPolicy; + + /** @var array */ + public readonly array $overrides; + + /** + * @param array|null $contentSecurityPolicy CSP in bepsvpt/secure-headers format + * (e.g. `['default-src' => ['self' => true]]`); `null` disables the header + * @param array $overrides raw bepsvpt/secure-headers configuration + */ + public function __construct( + public readonly bool $hsts = true, + public readonly int $hstsMaxAge = 31536000, + public readonly bool $hstsIncludeSubDomains = true, + public readonly bool $hstsPreload = false, + public readonly string $frameOptions = 'deny', + public readonly string $referrerPolicy = 'strict-origin-when-cross-origin', + ?array $contentSecurityPolicy = [ + 'default-src' => ['none' => true], + 'frame-ancestors' => ['none' => true], + ], + array $overrides = [], + ) { + if ($hstsMaxAge < 0) { + throw new InvalidConfigurationException('Security headers: hstsMaxAge must be zero or positive.'); + } + + if ($hstsPreload && (!$hstsIncludeSubDomains || $hstsMaxAge < 31536000)) { + throw new InvalidConfigurationException( + 'Security headers: HSTS preload requires includeSubDomains and a max-age of at least 31536000.' + ); + } + + if (!in_array(strtolower($frameOptions), ['deny', 'sameorigin'], true)) { + throw new InvalidConfigurationException('Security headers: frameOptions must be "deny" or "sameorigin".'); + } + + $policies = [ + 'no-referrer', 'no-referrer-when-downgrade', 'origin', 'origin-when-cross-origin', + 'same-origin', 'strict-origin', 'strict-origin-when-cross-origin', 'unsafe-url', + ]; + if (!in_array($referrerPolicy, $policies, true)) { + throw new InvalidConfigurationException(sprintf( + 'Security headers: invalid referrerPolicy "%s".', + $referrerPolicy + )); + } + + $this->contentSecurityPolicy = $contentSecurityPolicy === null + ? ['enable' => false] + : ['enable' => true] + $contentSecurityPolicy; + $this->overrides = $overrides; + } + + /** + * Configuration array consumed by `bepsvpt/secure-headers`. + * + * @return array + */ + public function toLibraryConfig(): array + { + return [ + 'x-content-type-options' => 'nosniff', + 'x-frame-options' => strtoupper($this->frameOptions), + 'x-xss-protection' => '', + 'x-download-options' => '', + 'x-permitted-cross-domain-policies' => 'none', + 'referrer-policy' => $this->referrerPolicy, + 'cross-origin-embedder-policy' => '', + 'cross-origin-opener-policy' => '', + 'cross-origin-resource-policy' => '', + 'permissions-policy' => ['enable' => false], + 'hsts' => [ + 'enable' => $this->hsts, + 'max-age' => $this->hstsMaxAge, + 'include-sub-domains' => $this->hstsIncludeSubDomains, + 'preload' => $this->hstsPreload, + ], + 'csp' => $this->contentSecurityPolicy, + ]; + } +} diff --git a/src/Headers/SecurityHeadersMiddleware.php b/src/Headers/SecurityHeadersMiddleware.php new file mode 100644 index 0000000..1540ef1 --- /dev/null +++ b/src/Headers/SecurityHeadersMiddleware.php @@ -0,0 +1,92 @@ + */ + private readonly array $headers; + + public function __construct(?SecurityHeadersConfig $config = null) + { + if (!class_exists(SecureHeaders::class)) { + throw MissingDependencyException::forPackage(self::class, 'bepsvpt/secure-headers'); + } + + $config ??= new SecurityHeadersConfig(); + + $libraryConfig = array_replace_recursive( + self::libraryDefaults(), + $config->toLibraryConfig(), + $config->overrides + ); + + $headers = []; + foreach ((new SecureHeaders($libraryConfig))->headers() as $name => $value) { + if (is_string($name) && is_string($value) && $value !== '') { + $headers[$name] = $value; + } + } + + $this->headers = $headers; + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + $response = $handler->handle($request); + + foreach ($this->headers as $name => $value) { + if (!$response->hasHeader($name)) { + $response = $response->withHeader($name, $value); + } + } + + return $response; + } + + /** + * Headers this middleware adds (useful for inspection and tests). + * + * @return array + */ + public function headers(): array + { + return $this->headers; + } + + /** + * The library reads several keys without defaults, so its shipped + * configuration is used as the base. + * + * @return array + */ + private static function libraryDefaults(): array + { + $file = dirname((string) (new ReflectionClass(SecureHeaders::class))->getFileName(), 2) + . '/config/secure-headers.php'; + + /** @var array $defaults */ + $defaults = require $file; + + return $defaults; + } +} diff --git a/src/Jwt/JwtAuthMiddleware.php b/src/Jwt/JwtAuthMiddleware.php new file mode 100644 index 0000000..952ced6 --- /dev/null +++ b/src/Jwt/JwtAuthMiddleware.php @@ -0,0 +1,137 @@ +`, then from the + * configured cookie. + * - Signature, algorithm, `exp`, `nbf` and `iat` are verified by + * `firebase/php-jwt`; `iss` and `aud` are verified here when configured. + * - On success the claims (as an array) are stored in the `user` attribute and + * the **same** request is passed to the handler. + * - Any failure returns `401` with `WWW-Authenticate: Bearer` and never + * reaches the handler. + */ +final class JwtAuthMiddleware implements MiddlewareInterface +{ + public function __construct( + private readonly ResponseFactoryInterface $responseFactory, + private readonly JwtConfig $config, + ) { + if (!class_exists(JWT::class)) { + throw MissingDependencyException::forPackage(self::class, 'firebase/php-jwt'); + } + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + if ($this->config->isPublicPath($request->getUri()->getPath())) { + return $handler->handle($request); + } + + $token = $this->extractToken($request); + if ($token === null) { + return $this->unauthorized('Bearer realm="api"', 'Authentication required'); + } + + $claims = $this->decode($token); + if ($claims === null || !$this->hasValidIssuer($claims) || !$this->hasValidAudience($claims)) { + return $this->unauthorized('Bearer realm="api", error="invalid_token"', 'Invalid token'); + } + + return $handler->handle($request->withAttribute($this->config->attribute, $claims)); + } + + private function extractToken(ServerRequestInterface $request): ?string + { + $authorization = $request->getHeaderLine('Authorization'); + if (preg_match('/^Bearer\s+(\S+)$/i', $authorization, $matches) === 1) { + return $matches[1]; + } + + if ($this->config->cookieName !== null) { + $cookie = $request->getCookieParams()[$this->config->cookieName] ?? null; + if (is_string($cookie) && $cookie !== '') { + return $cookie; + } + } + + return null; + } + + /** + * @return array|null + */ + private function decode(string $token): ?array + { + $previousLeeway = JWT::$leeway; + JWT::$leeway = $this->config->leeway; + + try { + $payload = JWT::decode($token, new Key($this->config->key, $this->config->algorithm)); + $claims = json_decode((string) json_encode($payload, JSON_THROW_ON_ERROR), true, 512, JSON_THROW_ON_ERROR); + } catch (Throwable) { + return null; + } finally { + JWT::$leeway = $previousLeeway; + } + + if (!is_array($claims)) { + return null; + } + + /** @var array $claims */ + return $claims; + } + + /** + * @param array $claims + */ + private function hasValidIssuer(array $claims): bool + { + return $this->config->issuer === null || ($claims['iss'] ?? null) === $this->config->issuer; + } + + /** + * @param array $claims + */ + private function hasValidAudience(array $claims): bool + { + if ($this->config->audience === []) { + return true; + } + + $aud = $claims['aud'] ?? null; + $values = is_array($aud) ? $aud : [$aud]; + + foreach ($values as $value) { + if (is_string($value) && in_array($value, $this->config->audience, true)) { + return true; + } + } + + return false; + } + + private function unauthorized(string $challenge, string $message): ResponseInterface + { + return ErrorResponse::create($this->responseFactory, 401, $message) + ->withHeader('WWW-Authenticate', $challenge); + } +} diff --git a/src/Jwt/JwtConfig.php b/src/Jwt/JwtConfig.php new file mode 100644 index 0000000..d7fc827 --- /dev/null +++ b/src/Jwt/JwtConfig.php @@ -0,0 +1,99 @@ + 32, 'HS384' => 48, 'HS512' => 64]; + + private const ALGORITHMS = [ + 'HS256', 'HS384', 'HS512', + 'RS256', 'RS384', 'RS512', + 'PS256', + 'ES256', 'ES384', 'ES256K', + 'EdDSA', + ]; + + /** @var list */ + public readonly array $publicPaths; + + /** @var list */ + public readonly array $audience; + + /** + * @param string|OpenSSLAsymmetricKey|OpenSSLCertificate $key verification key + * (secret for HS*, public key for the other algorithms) + * @param list $publicPaths paths that skip authentication (`fnmatch` patterns, e.g. `/health`, `/docs/*`) + * @param string|null $cookieName cookie read when the `Authorization` header is absent + * @param string|null $issuer required `iss` claim + * @param list $audience accepted `aud` values (any match) + * @param int $leeway clock skew tolerance in seconds for `exp`/`nbf`/`iat` + */ + public function __construct( + #[\SensitiveParameter] + public readonly string|OpenSSLAsymmetricKey|OpenSSLCertificate $key, + public readonly string $algorithm = 'HS256', + array $publicPaths = [], + public readonly ?string $cookieName = null, + public readonly ?string $issuer = null, + array $audience = [], + public readonly int $leeway = 0, + public readonly string $attribute = 'user', + ) { + if (!in_array($algorithm, self::ALGORITHMS, true)) { + throw new InvalidConfigurationException(sprintf('JWT: unsupported algorithm "%s".', $algorithm)); + } + + if (is_string($key)) { + if ($key === '') { + throw new InvalidConfigurationException('JWT: the key must not be empty.'); + } + + $minBytes = self::HMAC_MIN_BYTES[$algorithm] ?? 0; + if (strlen($key) < $minBytes) { + throw new InvalidConfigurationException(sprintf( + 'JWT: %s secrets must be at least %d bytes long.', + $algorithm, + $minBytes + )); + } + } elseif (isset(self::HMAC_MIN_BYTES[$algorithm])) { + throw new InvalidConfigurationException('JWT: HMAC algorithms require a string secret.'); + } + + if ($leeway < 0) { + throw new InvalidConfigurationException('JWT: leeway must be zero or positive.'); + } + + if ($attribute === '' || $cookieName === '' || $issuer === '') { + throw new InvalidConfigurationException('JWT: attribute, cookieName and issuer must not be empty strings.'); + } + + $this->publicPaths = array_values($publicPaths); + $this->audience = array_values($audience); + } + + public function isPublicPath(string $path): bool + { + foreach ($this->publicPaths as $pattern) { + if (fnmatch($pattern, $path)) { + return true; + } + } + + return false; + } +} diff --git a/src/Proxy/IpRange.php b/src/Proxy/IpRange.php new file mode 100644 index 0000000..75de568 --- /dev/null +++ b/src/Proxy/IpRange.php @@ -0,0 +1,73 @@ + $maxBits) { + return null; + } + + return [$network, (int) $parts[1]]; + } +} diff --git a/src/Proxy/TrustedProxyConfig.php b/src/Proxy/TrustedProxyConfig.php new file mode 100644 index 0000000..f5ff44d --- /dev/null +++ b/src/Proxy/TrustedProxyConfig.php @@ -0,0 +1,56 @@ + */ + public readonly array $trustedProxies; + + /** + * @param list $trustedProxies IPv4/IPv6 addresses or CIDR ranges (e.g. `10.0.0.0/8`) + * @param string $header forwarding header holding a comma-separated IP chain + * @param string $attribute request attribute that receives the client IP + */ + public function __construct( + array $trustedProxies = [], + public readonly string $header = 'X-Forwarded-For', + public readonly string $attribute = 'client_ip', + ) { + foreach ($trustedProxies as $proxy) { + if (!IpRange::isValid($proxy)) { + throw new InvalidConfigurationException(sprintf( + 'Trusted proxy "%s" is not a valid IP address or CIDR range.', + $proxy + )); + } + } + + if ($header === '' || $attribute === '') { + throw new InvalidConfigurationException('Trusted proxy header and attribute must not be empty.'); + } + + $this->trustedProxies = array_values($trustedProxies); + } + + public function isTrusted(string $ip): bool + { + foreach ($this->trustedProxies as $proxy) { + if (IpRange::contains($proxy, $ip)) { + return true; + } + } + + return false; + } +} diff --git a/src/Proxy/TrustedProxyMiddleware.php b/src/Proxy/TrustedProxyMiddleware.php new file mode 100644 index 0000000..81dd610 --- /dev/null +++ b/src/Proxy/TrustedProxyMiddleware.php @@ -0,0 +1,69 @@ +resolve($request); + + if ($clientIp !== null) { + $request = $request->withAttribute($this->config->attribute, $clientIp); + } + + return $handler->handle($request); + } + + private function resolve(ServerRequestInterface $request): ?string + { + $remote = $request->getServerParams()['REMOTE_ADDR'] ?? null; + + if (!is_string($remote) || filter_var($remote, FILTER_VALIDATE_IP) === false) { + return null; + } + + if (!$this->config->isTrusted($remote)) { + return $remote; + } + + $chain = array_map('trim', explode(',', $request->getHeaderLine($this->config->header))); + $client = $remote; + + foreach (array_reverse($chain) as $hop) { + if ($hop === '' || filter_var($hop, FILTER_VALIDATE_IP) === false) { + break; + } + + $client = $hop; + + if (!$this->config->isTrusted($hop)) { + break; + } + } + + return $client; + } +} diff --git a/src/RateLimit/RateLimitConfig.php b/src/RateLimit/RateLimitConfig.php new file mode 100644 index 0000000..501aaf5 --- /dev/null +++ b/src/RateLimit/RateLimitConfig.php @@ -0,0 +1,32 @@ +config = $config ?? new RateLimitConfig(); + $this->keyResolver = $keyResolver === null ? null : Closure::fromCallable($keyResolver); + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + $limit = $this->limiterFactory->create($this->config->keyPrefix . $this->clientKey($request))->consume(); + + if (!$limit->isAccepted()) { + $retryAfter = max(0, $limit->getRetryAfter()->getTimestamp() - time()); + + return ErrorResponse::create($this->responseFactory, 429, 'Too Many Requests') + ->withHeader('Retry-After', (string) $retryAfter) + ->withHeader('X-RateLimit-Limit', (string) $limit->getLimit()) + ->withHeader('X-RateLimit-Remaining', '0'); + } + + $response = $handler->handle($request); + + if (!$this->config->exposeHeaders) { + return $response; + } + + return $response + ->withHeader('X-RateLimit-Limit', (string) $limit->getLimit()) + ->withHeader('X-RateLimit-Remaining', (string) $limit->getRemainingTokens()); + } + + private function clientKey(ServerRequestInterface $request): string + { + if ($this->keyResolver !== null) { + $key = ($this->keyResolver)($request); + if ($key !== null && $key !== '') { + return $key; + } + } + + $ip = $request->getAttribute($this->config->clientIpAttribute); + if (is_string($ip) && $ip !== '') { + return $ip; + } + + $remote = $request->getServerParams()['REMOTE_ADDR'] ?? null; + + return is_string($remote) && $remote !== '' ? $remote : 'unknown'; + } +} diff --git a/src/Support/ErrorResponse.php b/src/Support/ErrorResponse.php new file mode 100644 index 0000000..f7dc524 --- /dev/null +++ b/src/Support/ErrorResponse.php @@ -0,0 +1,26 @@ +createResponse($status) + ->withHeader('Content-Type', 'application/json; charset=utf-8'); + + $response->getBody()->write((string) json_encode(['error' => $message], JSON_THROW_ON_ERROR)); + + return $response; + } +} diff --git a/tests/Cors/CorsMiddlewareTest.php b/tests/Cors/CorsMiddlewareTest.php new file mode 100644 index 0000000..5ee065a --- /dev/null +++ b/tests/Cors/CorsMiddlewareTest.php @@ -0,0 +1,196 @@ +handler = new RecordingHandler(); + } + + private function middleware(CorsConfig $config): CorsMiddleware + { + return new CorsMiddleware(new Psr17Factory(), $config); + } + + private function credentialsConfig(): CorsConfig + { + return new CorsConfig(allowedOrigins: ['https://app.example.com'], allowCredentials: true); + } + + public function testRequestWithoutOriginPassesThroughUntouched(): void + { + $response = $this->middleware($this->credentialsConfig()) + ->process(new ServerRequest('GET', '/x'), $this->handler); + + self::assertSame(1, $this->handler->calls); + self::assertFalse($response->hasHeader('Access-Control-Allow-Origin')); + self::assertFalse($response->hasHeader('Vary')); + } + + public function testAllowedOriginWithCredentialsEchoesOriginAndVaries(): void + { + $request = new ServerRequest('GET', '/x', ['Origin' => 'https://app.example.com']); + $response = $this->middleware($this->credentialsConfig())->process($request, $this->handler); + + self::assertSame('https://app.example.com', $response->getHeaderLine('Access-Control-Allow-Origin')); + self::assertSame('true', $response->getHeaderLine('Access-Control-Allow-Credentials')); + self::assertSame('Origin', $response->getHeaderLine('Vary')); + } + + /** + * SPEC-064: a disallowed origin must not receive `*` nor credentials. + */ + public function testDisallowedOriginGetsNoCorsHeaders(): void + { + $request = new ServerRequest('GET', '/x', ['Origin' => 'https://evil.example']); + $response = $this->middleware($this->credentialsConfig())->process($request, $this->handler); + + self::assertSame(1, $this->handler->calls); + self::assertFalse($response->hasHeader('Access-Control-Allow-Origin')); + self::assertFalse($response->hasHeader('Access-Control-Allow-Credentials')); + self::assertSame('Origin', $response->getHeaderLine('Vary')); + } + + public function testNullOriginIsNotAllowed(): void + { + $request = new ServerRequest('GET', '/x', ['Origin' => 'null']); + $response = $this->middleware($this->credentialsConfig())->process($request, $this->handler); + + self::assertFalse($response->hasHeader('Access-Control-Allow-Origin')); + } + + public function testWildcardWithCredentialsIsRejectedAtConfiguration(): void + { + $this->expectException(InvalidConfigurationException::class); + + new CorsConfig(allowedOrigins: ['*'], allowCredentials: true); + } + + public function testWildcardOriginSendsStarWithoutVary(): void + { + $request = new ServerRequest('GET', '/x', ['Origin' => 'https://any.example']); + $response = $this->middleware(new CorsConfig(allowedOrigins: ['*']))->process($request, $this->handler); + + self::assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin')); + self::assertFalse($response->hasHeader('Access-Control-Allow-Credentials')); + self::assertFalse($response->hasHeader('Vary')); + } + + /** + * SPEC-063: the preflight is answered by the middleware, with headers, and never reaches the handler. + */ + public function testAllowedPreflightIsAnsweredWithoutCallingTheHandler(): void + { + $request = new ServerRequest('OPTIONS', '/x', [ + 'Origin' => 'https://app.example.com', + 'Access-Control-Request-Method' => 'PUT', + 'Access-Control-Request-Headers' => 'content-type, Authorization', + ]); + $response = $this->middleware($this->credentialsConfig())->process($request, $this->handler); + + self::assertSame(0, $this->handler->calls); + self::assertSame(204, $response->getStatusCode()); + self::assertSame('https://app.example.com', $response->getHeaderLine('Access-Control-Allow-Origin')); + self::assertSame('GET, POST, PUT, PATCH, DELETE', $response->getHeaderLine('Access-Control-Allow-Methods')); + self::assertSame('content-type, authorization', $response->getHeaderLine('Access-Control-Allow-Headers')); + self::assertSame('600', $response->getHeaderLine('Access-Control-Max-Age')); + self::assertSame('true', $response->getHeaderLine('Access-Control-Allow-Credentials')); + self::assertSame( + 'Origin, Access-Control-Request-Method, Access-Control-Request-Headers', + $response->getHeaderLine('Vary') + ); + } + + /** + * @return iterable}> + */ + public static function rejectedPreflights(): iterable + { + yield 'origin' => [['Origin' => 'https://evil.example', 'Access-Control-Request-Method' => 'GET']]; + yield 'method' => [['Origin' => 'https://app.example.com', 'Access-Control-Request-Method' => 'TRACE']]; + yield 'header' => [[ + 'Origin' => 'https://app.example.com', + 'Access-Control-Request-Method' => 'GET', + 'Access-Control-Request-Headers' => 'X-Secret', + ]]; + } + + /** + * @param array $headers + */ + #[DataProvider('rejectedPreflights')] + public function testRejectedPreflightFailsClosed(array $headers): void + { + $response = $this->middleware($this->credentialsConfig()) + ->process(new ServerRequest('OPTIONS', '/x', $headers), $this->handler); + + self::assertSame(0, $this->handler->calls); + self::assertSame(403, $response->getStatusCode()); + self::assertFalse($response->hasHeader('Access-Control-Allow-Origin')); + } + + public function testPlainOptionsWithoutPreflightHeaderReachesHandler(): void + { + $request = new ServerRequest('OPTIONS', '/x', ['Origin' => 'https://app.example.com']); + $this->middleware($this->credentialsConfig())->process($request, $this->handler); + + self::assertSame(1, $this->handler->calls); + } + + public function testExposedHeadersAndExistingVaryArePreserved(): void + { + $handler = new class implements RequestHandlerInterface { + public function handle(ServerRequestInterface $request): ResponseInterface + { + return new Response(200, ['Vary' => 'Accept-Encoding, origin']); + } + }; + + $config = new CorsConfig(allowedOrigins: ['https://app.example.com'], exposedHeaders: ['X-Request-Id']); + $request = new ServerRequest('GET', '/x', ['Origin' => 'https://app.example.com']); + $response = $this->middleware($config)->process($request, $handler); + + self::assertSame('X-Request-Id', $response->getHeaderLine('Access-Control-Expose-Headers')); + self::assertSame('Accept-Encoding, origin', $response->getHeaderLine('Vary')); + } + + /** + * @return iterable + */ + public static function invalidOrigins(): iterable + { + yield 'null' => ['null']; + yield 'trailing slash' => ['https://app.example.com/']; + yield 'path' => ['https://app.example.com/api']; + yield 'no scheme' => ['app.example.com']; + yield 'upper case' => ['https://App.example.com']; + yield 'ftp' => ['ftp://app.example.com']; + } + + #[DataProvider('invalidOrigins')] + public function testInvalidOriginsAreRejectedAtConfiguration(string $origin): void + { + $this->expectException(InvalidConfigurationException::class); + + new CorsConfig(allowedOrigins: [$origin]); + } +} diff --git a/tests/Csrf/CsrfMiddlewareTest.php b/tests/Csrf/CsrfMiddlewareTest.php new file mode 100644 index 0000000..ad6c8df --- /dev/null +++ b/tests/Csrf/CsrfMiddlewareTest.php @@ -0,0 +1,168 @@ +token; + } + + public function set(string $token): void + { + $this->token = $token; + } + + public function remove(): void + { + $this->token = null; + } + }; + + $this->token = new SynchronizerCsrfToken(new RandomCsrfTokenGenerator(), $storage); + $this->handler = new RecordingHandler(); + } + + private function middleware(?CsrfConfig $config = null): CsrfMiddleware + { + return new CsrfMiddleware(new Psr17Factory(), $this->token, $config); + } + + public function testSafeMethodPassesAndExposesTheToken(): void + { + $response = $this->middleware()->process(new ServerRequest('GET', '/form'), $this->handler); + + self::assertSame(200, $response->getStatusCode()); + self::assertSame($this->token, $this->handler->attribute('csrf_token')); + } + + /** + * SPEC-067: a legitimate POST with a valid token (form field) is accepted. + */ + public function testValidTokenInBodyIsAccepted(): void + { + $request = (new ServerRequest('POST', '/form'))->withParsedBody(['_csrf' => $this->token->getValue()]); + + $response = $this->middleware()->process($request, $this->handler); + + self::assertSame(200, $response->getStatusCode()); + self::assertSame(1, $this->handler->calls); + } + + public function testValidTokenInHeaderIsAccepted(): void + { + $request = new ServerRequest('DELETE', '/item/1', ['X-CSRF-Token' => $this->token->getValue()]); + + self::assertSame(200, $this->middleware()->process($request, $this->handler)->getStatusCode()); + } + + /** + * @return iterable + */ + public static function unsafeMethods(): iterable + { + yield 'POST' => ['POST']; + yield 'PUT' => ['PUT']; + yield 'PATCH' => ['PATCH']; + yield 'DELETE' => ['DELETE']; + } + + /** + * SPEC-067: every state-changing method is protected, not only POST. + */ + #[DataProvider('unsafeMethods')] + public function testUnsafeMethodWithoutTokenFailsClosed(string $method): void + { + $response = $this->middleware()->process(new ServerRequest($method, '/x'), $this->handler); + + self::assertSame(403, $response->getStatusCode()); + self::assertSame(0, $this->handler->calls); + self::assertSame('{"error":"Invalid CSRF token"}', (string) $response->getBody()); + } + + public function testWrongTokenFailsClosed(): void + { + $this->token->getValue(); + $request = new ServerRequest('POST', '/x', ['X-CSRF-Token' => 'forged']); + + self::assertSame(403, $this->middleware()->process($request, $this->handler)->getStatusCode()); + } + + public function testObjectParsedBodyIsValidatedAndOriginalRequestReachesHandler(): void + { + $body = (object) ['_csrf' => $this->token->getValue(), 'name' => 'x']; + $request = (new ServerRequest('POST', '/form'))->withParsedBody($body); + + $response = $this->middleware()->process($request, $this->handler); + + self::assertSame(200, $response->getStatusCode()); + self::assertSame($body, $this->handler->request?->getParsedBody()); + } + + public function testCustomNamesAndStatus(): void + { + $config = new CsrfConfig(parameterName: 'token', headerName: 'X-XSRF', failureStatus: 419); + + $ok = (new ServerRequest('POST', '/x', ['X-XSRF' => $this->token->getValue()])); + self::assertSame(200, $this->middleware($config)->process($ok, $this->handler)->getStatusCode()); + + $bad = new ServerRequest('POST', '/x', ['X-CSRF-Token' => $this->token->getValue()]); + self::assertSame(419, $this->middleware($config)->process($bad, $this->handler)->getStatusCode()); + } + + public function testStatelessHmacAndMaskedTokens(): void + { + $identity = new class implements CsrfTokenIdentityGeneratorInterface { + public string $id = 'session-a'; + + public function generate(): string + { + return $this->id; + } + }; + $this->token = new MaskedCsrfToken(new HmacCsrfToken($identity, str_repeat('k', 32), 'sha256', 300)); + + $value = $this->token->getValue(); + self::assertNotSame($value, $this->token->getValue(), 'masked tokens differ on every render'); + + $request = new ServerRequest('POST', '/x', ['X-CSRF-Token' => $value]); + self::assertSame(200, $this->middleware()->process($request, $this->handler)->getStatusCode()); + + $identity->id = 'session-b'; + self::assertSame(403, $this->middleware()->process($request, new RecordingHandler())->getStatusCode()); + } + + public function testStateChangingMethodsCannotBeDeclaredSafe(): void + { + $this->expectException(InvalidConfigurationException::class); + + new CsrfConfig(safeMethods: ['GET', 'post']); + } +} diff --git a/tests/Functional/PipelineTest.php b/tests/Functional/PipelineTest.php new file mode 100644 index 0000000..a56ba80 --- /dev/null +++ b/tests/Functional/PipelineTest.php @@ -0,0 +1,142 @@ +handler = new RecordingHandler(); + + $middlewares = [ + new TrustedProxyMiddleware(new TrustedProxyConfig(['10.0.0.0/8'])), + new SecurityHeadersMiddleware(), + new CorsMiddleware($factory, new CorsConfig(['https://app.example.com'], allowCredentials: true)), + new RateLimitMiddleware($factory, new RateLimiterFactory( + ['id' => 'api', 'policy' => 'fixed_window', 'limit' => 3, 'interval' => '1 minute'], + new InMemoryStorage() + )), + new JwtAuthMiddleware($factory, new JwtConfig(self::SECRET, publicPaths: ['/health'])), + ]; + + $this->pipeline = array_reduce( + array_reverse($middlewares), + static fn (RequestHandlerInterface $next, MiddlewareInterface $middleware): RequestHandlerInterface => + new class ($middleware, $next) implements RequestHandlerInterface { + public function __construct( + private readonly MiddlewareInterface $middleware, + private readonly RequestHandlerInterface $next, + ) { + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->middleware->process($request, $this->next); + } + }, + $this->handler + ); + } + + /** + * @param array $headers + */ + private function send(string $method, string $path, array $headers = []): ResponseInterface + { + return $this->pipeline->handle( + new ServerRequest($method, $path, $headers, null, '1.1', ['REMOTE_ADDR' => '10.0.0.5']) + ); + } + + public function testPreflightNeedsNoCredentialsAndCarriesSecurityHeaders(): void + { + $response = $this->send('OPTIONS', '/me', [ + 'Origin' => 'https://app.example.com', + 'Access-Control-Request-Method' => 'GET', + 'Access-Control-Request-Headers' => 'authorization', + ]); + + self::assertSame(204, $response->getStatusCode()); + self::assertSame('https://app.example.com', $response->getHeaderLine('Access-Control-Allow-Origin')); + self::assertSame('nosniff', $response->getHeaderLine('X-Content-Type-Options')); + self::assertSame(0, $this->handler->calls); + } + + public function testAuthenticatedRequestReachesHandlerWithUserAndClientIp(): void + { + $token = JWT::encode(['sub' => 'u1', 'exp' => time() + 60], self::SECRET, 'HS256'); + + $response = $this->send('GET', '/me', [ + 'Authorization' => 'Bearer ' . $token, + 'Origin' => 'https://app.example.com', + 'X-Forwarded-For' => '198.51.100.4', + ]); + + self::assertSame(200, $response->getStatusCode()); + self::assertSame('198.51.100.4', $this->handler->attribute('client_ip')); + self::assertSame(['sub' => 'u1', 'exp' => $this->claim('exp')], $this->handler->attribute('user')); + self::assertSame('https://app.example.com', $response->getHeaderLine('Access-Control-Allow-Origin')); + self::assertSame('2', $response->getHeaderLine('X-RateLimit-Remaining')); + } + + public function testRejectionsStillCarrySecurityAndCorsHeaders(): void + { + $response = $this->send('GET', '/me', ['Origin' => 'https://app.example.com']); + + self::assertSame(401, $response->getStatusCode()); + self::assertSame('DENY', $response->getHeaderLine('X-Frame-Options')); + self::assertSame('https://app.example.com', $response->getHeaderLine('Access-Control-Allow-Origin')); + } + + public function testRateLimitAppliesPerResolvedClientIp(): void + { + for ($i = 0; $i < 3; $i++) { + $this->send('GET', '/health', ['X-Forwarded-For' => '198.51.100.4']); + } + + $blocked = $this->send('GET', '/health', ['X-Forwarded-For' => '198.51.100.4']); + $other = $this->send('GET', '/health', ['X-Forwarded-For' => '198.51.100.5']); + + self::assertSame(429, $blocked->getStatusCode()); + self::assertSame(200, $other->getStatusCode()); + } + + private function claim(string $name): mixed + { + $user = $this->handler->attribute('user'); + + return is_array($user) ? ($user[$name] ?? null) : null; + } +} diff --git a/tests/Headers/SecurityHeadersMiddlewareTest.php b/tests/Headers/SecurityHeadersMiddlewareTest.php new file mode 100644 index 0000000..0e88de6 --- /dev/null +++ b/tests/Headers/SecurityHeadersMiddlewareTest.php @@ -0,0 +1,104 @@ +process(new ServerRequest('GET', '/'), new RecordingHandler()); + + self::assertSame('max-age=31536000; includeSubDomains', $response->getHeaderLine('Strict-Transport-Security')); + self::assertSame('nosniff', $response->getHeaderLine('X-Content-Type-Options')); + self::assertSame('DENY', $response->getHeaderLine('X-Frame-Options')); + self::assertSame('strict-origin-when-cross-origin', $response->getHeaderLine('Referrer-Policy')); + self::assertSame( + "default-src 'none'; frame-ancestors 'none'", + $response->getHeaderLine('Content-Security-Policy') + ); + self::assertFalse($response->hasHeader('X-XSS-Protection')); + self::assertFalse($response->hasHeader('Permissions-Policy')); + } + + public function testHeadersSetByTheApplicationAreKept(): void + { + $handler = new class implements RequestHandlerInterface { + public function handle(ServerRequestInterface $request): ResponseInterface + { + return new Response(200, ['Content-Security-Policy' => "default-src 'self'"]); + } + }; + + $response = (new SecurityHeadersMiddleware())->process(new ServerRequest('GET', '/'), $handler); + + self::assertSame("default-src 'self'", $response->getHeaderLine('Content-Security-Policy')); + } + + public function testOptionsAreApplied(): void + { + $middleware = new SecurityHeadersMiddleware(new SecurityHeadersConfig( + hsts: false, + frameOptions: 'sameorigin', + referrerPolicy: 'no-referrer', + contentSecurityPolicy: null, + overrides: ['cross-origin-opener-policy' => 'same-origin'], + )); + + $headers = $middleware->headers(); + + self::assertArrayNotHasKey('Strict-Transport-Security', $headers); + self::assertArrayNotHasKey('Content-Security-Policy', $headers); + self::assertSame('SAMEORIGIN', $headers['X-Frame-Options']); + self::assertSame('no-referrer', $headers['Referrer-Policy']); + self::assertSame('same-origin', $headers['Cross-Origin-Opener-Policy']); + } + + public function testCustomCsp(): void + { + $headers = (new SecurityHeadersMiddleware(new SecurityHeadersConfig( + contentSecurityPolicy: [ + 'default-src' => ['self' => true], + 'img-src' => ['allow' => ['https://cdn.example']], + ], + )))->headers(); + + self::assertSame("default-src 'self'; img-src https://cdn.example", $headers['Content-Security-Policy']); + } + + public function testPreloadRequiresSubDomainsAndLongMaxAge(): void + { + $this->expectException(InvalidConfigurationException::class); + + new SecurityHeadersConfig(hstsIncludeSubDomains: false, hstsPreload: true); + } + + public function testInvalidFrameOptionsIsRejected(): void + { + $this->expectException(InvalidConfigurationException::class); + + new SecurityHeadersConfig(frameOptions: 'allow-from https://x'); + } + + public function testInvalidReferrerPolicyIsRejected(): void + { + $this->expectException(InvalidConfigurationException::class); + + new SecurityHeadersConfig(referrerPolicy: 'never'); + } +} diff --git a/tests/Jwt/JwtAuthMiddlewareTest.php b/tests/Jwt/JwtAuthMiddlewareTest.php new file mode 100644 index 0000000..3e99652 --- /dev/null +++ b/tests/Jwt/JwtAuthMiddlewareTest.php @@ -0,0 +1,189 @@ +handler = new RecordingHandler(); + } + + /** + * @param array $claims + */ + private static function token(array $claims = [], string $secret = self::SECRET, string $alg = 'HS256'): string + { + return JWT::encode($claims + ['sub' => 'u1', 'exp' => time() + 60], $secret, $alg); + } + + /** + * @param array $headers + */ + private function call(array $headers, ?JwtConfig $config = null, string $path = '/me'): ResponseInterface + { + $config ??= new JwtConfig(self::SECRET); + + return (new JwtAuthMiddleware(new Psr17Factory(), $config)) + ->process(new ServerRequest('GET', $path, $headers), $this->handler); + } + + /** + * SPEC-054: the claims reach the handler. + */ + public function testValidTokenExposesClaimsToHandler(): void + { + $response = $this->call(['Authorization' => 'Bearer ' . self::token(['role' => 'admin'])]); + + self::assertSame(200, $response->getStatusCode()); + $user = $this->handler->attribute('user'); + self::assertIsArray($user); + self::assertSame('u1', $user['sub']); + self::assertSame('admin', $user['role']); + } + + public function testMissingTokenFailsClosed(): void + { + $response = $this->call([]); + + self::assertSame(401, $response->getStatusCode()); + self::assertSame(0, $this->handler->calls); + self::assertSame('Bearer realm="api"', $response->getHeaderLine('WWW-Authenticate')); + } + + /** + * @return iterable + */ + public static function invalidTokens(): iterable + { + yield 'wrong secret' => [self::token([], str_repeat('x', 32))]; + yield 'expired' => [self::token(['exp' => time() - 10])]; + yield 'not yet valid' => [self::token(['nbf' => time() + 60])]; + yield 'string exp (SPEC-060)' => [ + JWT::urlsafeB64Encode('{"alg":"HS256","typ":"JWT"}') . '.' + . JWT::urlsafeB64Encode('{"sub":"u1","exp":"1"}') . '.' + . JWT::urlsafeB64Encode(hash_hmac('sha256', JWT::urlsafeB64Encode('{"alg":"HS256","typ":"JWT"}') . '.' + . JWT::urlsafeB64Encode('{"sub":"u1","exp":"1"}'), self::SECRET, true)), + ]; + yield 'alg none' => [ + JWT::urlsafeB64Encode('{"alg":"none","typ":"JWT"}') . '.' . JWT::urlsafeB64Encode('{"sub":"u1"}') . '.', + ]; + yield 'garbage' => ['not.a.jwt']; + } + + #[DataProvider('invalidTokens')] + public function testInvalidTokenFailsClosed(string $token): void + { + $response = $this->call(['Authorization' => 'Bearer ' . $token]); + + self::assertSame(401, $response->getStatusCode()); + self::assertSame(0, $this->handler->calls); + self::assertStringContainsString('invalid_token', $response->getHeaderLine('WWW-Authenticate')); + } + + public function testAlgorithmConfusionIsRejected(): void + { + $config = new JwtConfig(self::SECRET, 'HS256'); + $hs512 = JWT::encode(['sub' => 'u1'], str_repeat('s', 64), 'HS512'); + + self::assertSame(401, $this->call(['Authorization' => 'Bearer ' . $hs512], $config)->getStatusCode()); + } + + /** + * SPEC-055: public paths are honoured. + */ + public function testPublicPathsSkipAuthentication(): void + { + $config = new JwtConfig(self::SECRET, publicPaths: ['/health', '/docs/*']); + + self::assertSame(200, $this->call([], $config, '/health')->getStatusCode()); + self::assertSame(200, $this->call([], $config, '/docs/openapi.json')->getStatusCode()); + self::assertSame(401, $this->call([], $config, '/healthz')->getStatusCode()); + } + + public function testTokenFromCookie(): void + { + $config = new JwtConfig(self::SECRET, cookieName: 'session'); + $request = (new ServerRequest('GET', '/me'))->withCookieParams(['session' => self::token()]); + + $response = (new JwtAuthMiddleware(new Psr17Factory(), $config))->process($request, $this->handler); + + self::assertSame(200, $response->getStatusCode()); + } + + public function testIssuerAndAudienceAreEnforced(): void + { + $config = new JwtConfig(self::SECRET, issuer: 'https://auth.example', audience: ['api']); + + $ok = self::token(['iss' => 'https://auth.example', 'aud' => ['web', 'api']]); + self::assertSame(200, $this->call(['Authorization' => 'Bearer ' . $ok], $config)->getStatusCode()); + + $badIss = self::token(['iss' => 'https://evil.example', 'aud' => 'api']); + self::assertSame(401, $this->call(['Authorization' => 'Bearer ' . $badIss], $config)->getStatusCode()); + + $badAud = self::token(['iss' => 'https://auth.example', 'aud' => 'web']); + self::assertSame(401, $this->call(['Authorization' => 'Bearer ' . $badAud], $config)->getStatusCode()); + } + + public function testLeewayIsAppliedAndRestored(): void + { + $config = new JwtConfig(self::SECRET, leeway: 30); + $token = self::token(['exp' => time() - 10]); + + self::assertSame(200, $this->call(['Authorization' => 'Bearer ' . $token], $config)->getStatusCode()); + self::assertSame(0, JWT::$leeway); + } + + /** + * SPEC-060: empty or short secrets cannot be configured. + * + * @return iterable + */ + public static function weakKeys(): iterable + { + yield 'empty' => ['', 'HS256']; + yield 'short HS256' => ['short-secret', 'HS256']; + yield 'short HS512' => [str_repeat('k', 32), 'HS512']; + yield 'unknown algorithm' => [self::SECRET, 'none']; + } + + #[DataProvider('weakKeys')] + public function testWeakConfigurationIsRejected(string $key, string $algorithm): void + { + $this->expectException(InvalidConfigurationException::class); + + new JwtConfig($key, $algorithm); + } + + public function testRsaTokensAreVerifiedWithThePublicKey(): void + { + $private = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]); + self::assertNotFalse($private); + openssl_pkey_export($private, $privatePem); + $details = openssl_pkey_get_details($private); + self::assertIsArray($details); + self::assertIsString($privatePem); + + $config = new JwtConfig((string) $details['key'], 'RS256'); + $token = JWT::encode(['sub' => 'u1', 'exp' => time() + 60], $privatePem, 'RS256'); + + self::assertSame(200, $this->call(['Authorization' => 'Bearer ' . $token], $config)->getStatusCode()); + } +} diff --git a/tests/Proxy/TrustedProxyMiddlewareTest.php b/tests/Proxy/TrustedProxyMiddlewareTest.php new file mode 100644 index 0000000..1e74e97 --- /dev/null +++ b/tests/Proxy/TrustedProxyMiddlewareTest.php @@ -0,0 +1,124 @@ + $forwarded]; + $handler = new RecordingHandler(); + + (new TrustedProxyMiddleware($config))->process( + new ServerRequest('GET', '/', $headers, null, '1.1', ['REMOTE_ADDR' => $remote]), + $handler + ); + + return $handler->attribute('client_ip'); + } + + /** + * SPEC-065: a direct client cannot choose its IP through X-Forwarded-For. + */ + public function testForwardedHeaderIsIgnoredFromUntrustedPeer(): void + { + self::assertSame('203.0.113.7', $this->resolve('203.0.113.7', '8.8.8.8', new TrustedProxyConfig())); + self::assertSame( + '203.0.113.7', + $this->resolve('203.0.113.7', '8.8.8.8', new TrustedProxyConfig(['10.0.0.0/8'])) + ); + } + + public function testClientIsTakenFromTrustedProxy(): void + { + $config = new TrustedProxyConfig(['10.0.0.0/8']); + + self::assertSame('198.51.100.4', $this->resolve('10.0.0.5', '198.51.100.4', $config)); + } + + public function testChainIsWalkedRightToLeftSkippingTrustedHops(): void + { + $config = new TrustedProxyConfig(['10.0.0.0/8', '192.0.2.10']); + + // the left-most entry is attacker-controlled; the first untrusted hop from the right wins + self::assertSame('198.51.100.4', $this->resolve('10.0.0.5', '1.2.3.4, 198.51.100.4, 192.0.2.10', $config)); + } + + public function testMalformedEntryStopsAtLastTrustedHop(): void + { + $config = new TrustedProxyConfig(['10.0.0.0/8']); + + self::assertSame('10.0.0.5', $this->resolve('10.0.0.5', 'not-an-ip', $config)); + self::assertSame('10.1.1.1', $this->resolve('10.0.0.5', '198.51.100.4, garbage, 10.1.1.1', $config)); + } + + public function testMissingHeaderFromTrustedProxyFallsBackToRemoteAddr(): void + { + self::assertSame('10.0.0.5', $this->resolve('10.0.0.5', null, new TrustedProxyConfig(['10.0.0.0/8']))); + } + + public function testInvalidRemoteAddrLeavesAttributeUnset(): void + { + self::assertNull($this->resolve('garbage', '8.8.8.8', new TrustedProxyConfig(['10.0.0.0/8']))); + } + + public function testIpv6RangesAreSupported(): void + { + $config = new TrustedProxyConfig(['fd00::/8']); + + self::assertSame('2001:db8::1', $this->resolve('fd12::1', '2001:db8::1', $config)); + } + + /** + * @return iterable + */ + public static function ranges(): iterable + { + yield 'exact v4' => ['192.0.2.1', '192.0.2.1', true]; + yield 'exact v4 miss' => ['192.0.2.1', '192.0.2.2', false]; + yield '/8 hit' => ['10.0.0.0/8', '10.255.1.1', true]; + yield '/8 miss' => ['10.0.0.0/8', '11.0.0.1', false]; + yield '/12 partial byte hit' => ['172.16.0.0/12', '172.31.255.255', true]; + yield '/12 partial byte miss' => ['172.16.0.0/12', '172.32.0.1', false]; + yield '/0' => ['0.0.0.0/0', '8.8.8.8', true]; + yield 'v6 /64 hit' => ['2001:db8::/64', '2001:db8::abcd', true]; + yield 'v6 /64 miss' => ['2001:db8::/64', '2001:db8:0:1::1', false]; + yield 'family mismatch' => ['10.0.0.0/8', '::ffff:10.0.0.1', false]; + } + + #[DataProvider('ranges')] + public function testIpRangeMatching(string $range, string $ip, bool $expected): void + { + self::assertSame($expected, IpRange::contains($range, $ip)); + } + + /** + * @return iterable + */ + public static function invalidRanges(): iterable + { + yield 'text' => ['localhost']; + yield 'prefix too long' => ['10.0.0.0/33']; + yield 'negative prefix' => ['10.0.0.0/-1']; + yield 'empty' => ['']; + } + + #[DataProvider('invalidRanges')] + public function testInvalidTrustedProxyIsRejected(string $range): void + { + $this->expectException(InvalidConfigurationException::class); + + new TrustedProxyConfig([$range]); + } +} diff --git a/tests/RateLimit/RateLimitMiddlewareTest.php b/tests/RateLimit/RateLimitMiddlewareTest.php new file mode 100644 index 0000000..9298bfd --- /dev/null +++ b/tests/RateLimit/RateLimitMiddlewareTest.php @@ -0,0 +1,119 @@ + 'test', 'policy' => 'fixed_window', 'limit' => $limit, 'interval' => '1 minute'], + new InMemoryStorage() + ); + + return new RateLimitMiddleware(new Psr17Factory(), $factory, $config, $resolver); + } + + private static function request(string $remote, ?string $clientIp = null): ServerRequestInterface + { + $request = new ServerRequest('POST', '/login', [], null, '1.1', ['REMOTE_ADDR' => $remote]); + + return $clientIp === null ? $request : $request->withAttribute('client_ip', $clientIp); + } + + private static function send(RateLimitMiddleware $middleware, ServerRequestInterface $request): ResponseInterface + { + return $middleware->process($request, new RecordingHandler()); + } + + public function testRequestsWithinTheLimitExposeHeaders(): void + { + $middleware = $this->middleware(); + + $first = self::send($middleware, self::request('203.0.113.1')); + + self::assertSame(200, $first->getStatusCode()); + self::assertSame('2', $first->getHeaderLine('X-RateLimit-Limit')); + self::assertSame('1', $first->getHeaderLine('X-RateLimit-Remaining')); + } + + public function testOverTheLimitFailsClosedWith429(): void + { + $middleware = $this->middleware(); + $handler = new RecordingHandler(); + + $middleware->process(self::request('203.0.113.1'), $handler); + $middleware->process(self::request('203.0.113.1'), $handler); + $blocked = $middleware->process(self::request('203.0.113.1'), $handler); + + self::assertSame(2, $handler->calls); + self::assertSame(429, $blocked->getStatusCode()); + self::assertSame('0', $blocked->getHeaderLine('X-RateLimit-Remaining')); + self::assertGreaterThan(0, (int) $blocked->getHeaderLine('Retry-After')); + self::assertLessThanOrEqual(60, (int) $blocked->getHeaderLine('Retry-After')); + } + + public function testClientsAreLimitedIndependently(): void + { + $middleware = $this->middleware(1); + + self::assertSame(200, self::send($middleware, self::request('203.0.113.1'))->getStatusCode()); + self::assertSame(200, self::send($middleware, self::request('203.0.113.2'))->getStatusCode()); + self::assertSame(429, self::send($middleware, self::request('203.0.113.1'))->getStatusCode()); + } + + /** + * SPEC-065: the key comes from the trusted client_ip attribute, not from raw headers. + */ + public function testClientIpAttributeTakesPrecedenceOverRemoteAddr(): void + { + $middleware = $this->middleware(1); + + self::assertSame(200, self::send($middleware, self::request('10.0.0.5', '198.51.100.1'))->getStatusCode()); + self::assertSame(200, self::send($middleware, self::request('10.0.0.5', '198.51.100.2'))->getStatusCode()); + self::assertSame(429, self::send($middleware, self::request('10.0.0.9', '198.51.100.1'))->getStatusCode()); + } + + public function testForwardedHeaderAloneDoesNotChangeTheKey(): void + { + $middleware = $this->middleware(1); + + self::send($middleware, self::request('203.0.113.1')->withHeader('X-Forwarded-For', '1.1.1.1')); + $second = self::send($middleware, self::request('203.0.113.1')->withHeader('X-Forwarded-For', '2.2.2.2')); + + self::assertSame(429, $second->getStatusCode()); + } + + public function testCustomResolverAndHiddenHeaders(): void + { + $middleware = $this->middleware( + 1, + new RateLimitConfig(exposeHeaders: false), + static fn (ServerRequestInterface $request): string => $request->getHeaderLine('X-Api-Key') + ); + + $ok = self::send($middleware, self::request('203.0.113.1')->withHeader('X-Api-Key', 'a')); + self::assertFalse($ok->hasHeader('X-RateLimit-Limit')); + $otherKey = self::send($middleware, self::request('203.0.113.1')->withHeader('X-Api-Key', 'b')); + $sameKey = self::send($middleware, self::request('203.0.113.9')->withHeader('X-Api-Key', 'a')); + + self::assertSame(200, $otherKey->getStatusCode()); + self::assertSame(429, $sameKey->getStatusCode()); + } +} diff --git a/tests/Support/RecordingHandler.php b/tests/Support/RecordingHandler.php new file mode 100644 index 0000000..8f333ba --- /dev/null +++ b/tests/Support/RecordingHandler.php @@ -0,0 +1,33 @@ +request = $request; + $this->calls++; + + return new Response(200, ['Content-Type' => 'text/plain'], 'handler'); + } + + public function attribute(string $name): mixed + { + return $this->request?->getAttribute($name); + } +} From 7f0c389b77493eba31e4f371c9fe80d2ad8decb3 Mon Sep 17 00:00:00 2001 From: Caio Fernandes Date: Sat, 10 Oct 2026 11:53:02 -0300 Subject: [PATCH 2/2] chore(release): 0.1.0 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f93b29d..b5054a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.1.0] - 2026-10-10 ### Added @@ -25,3 +25,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `429` with `Retry-After` and `X-RateLimit-*` headers, keyed by the resolved client IP. - `Exception\InvalidConfigurationException` and `Exception\MissingDependencyException`, both implementing `Exception\SecurityException`. + +[0.1.0]: https://github.com/PivotPHP/pivotphp-security/releases/tag/v0.1.0