Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -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
60 changes: 60 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# 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).

## [0.1.0] - 2026-10-10

### 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`.

[0.1.0]: https://github.com/PivotPHP/pivotphp-security/releases/tag/v0.1.0
39 changes: 39 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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.
258 changes: 258 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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).
Loading
Loading