Files
preauth/SECURITY.md
T
lyra 538bd74100
PHP Test / test (pull_request) Successful in 52s
Tests / test (pull_request) Successful in 52s
docker to be more inline with other projects and best practices
2026-09-25 08:25:55 -04:00

72 lines
3.5 KiB
Markdown

# Security Policy
## Supported Versions
| Version | Supported |
|---------|-----------|
| unreleased (v1 development) | ✅ |
## Reporting a Vulnerability
Report vulnerabilities privately to **security@digitaladapt.com** (or open a private
security advisory on the repository). Please include reproduction steps and affected
versions. You will receive an acknowledgement within 48 hours and a status update at
least weekly until resolution.
**Do not open a public issue for a suspected vulnerability.** preauth is an
authentication gateway — it sits in front of every protected service, so a
weakness here is a weakness everywhere behind it.
## Security model summary
preauth implements the auth half of the `forward_auth` pattern: a reverse proxy
calls it per request to decide whether a request may reach the upstream service.
- **Two outcomes per request: allow or intercept.** `AcceptListener` /
`RejectListener` / `InterceptListener` decide, and the decision is made on
every request rather than cached — a cached auth session is an anti-pattern
(GUIDING-LIGHT §3.3d), which is also why this project gets **no service
worker**.
- **The login flow is never cached.** The login page, failed logins, redirects
and rate-limit responses are sent with
`Cache-Control: no-cache, no-store, must-revalidate, proxy-revalidate, max-age=0, s-maxage=0`.
An aggressive cache (notably older Safari) replaying a stale pre-auth response
presents to the user as being logged back out after a refresh.
- **Headers are set by the app, not left to the proxy.** `X-Content-Type-Options:
nosniff`, `X-Frame-Options: DENY`, a Content-Security-Policy, and
`Strict-Transport-Security: max-age=31536000`. Docs recommend mirroring the
caching headers at the edge as defence in depth, but the app does not depend
on it.
- **TOTP is required.** Secrets come from `TOTP_URI`; if it is unset the app
generates one and prints it for enrolment. Login state is carried in a signed
payload (`src/Data/Payload.php`) bound to a nonce and a scope, not in a
server-side session store.
- **Rate limiting is on by default**, with the block response configurable
(`TEAPOT=false` returns 429 rather than 418).
- **`REMOTE_USER` is trusted input, not a secret.** In `remote_user` modes the
gateway accepts an upstream-asserted identity, so the upstream must be the
only path to the app. Do not expose preauth directly to the internet for this
mode.
- **`.env` is never committed; secrets are env vars injected at runtime.** Real
secrets belong in `.env.local` or `bin/console secrets:set`, read via
`%env(...)%`. `.env.example` and `.env.test` are the committed env files.
## Scope
In scope: the application code in `src/`, the shipped `Caddyfile`, the
`Dockerfile`, and anything that affects the allow/intercept decision.
Out of scope: the `forward_auth` integration at the edge (a host-proxy
configuration concern, see `docs/examples/Caddyfile`) and the security of the
services preauth protects.
## Deployment note
preauth runs as a container and drops privileges via `USER` (Guiding Light
§6.4): the image runs as the non-root `app` user (uid/gid 1000) and owns the
state paths it needs. Only `/data` is written at runtime — the cache pools
behind sessions, backup codes and rate limiting — and `/config` is declared
because the base image points Caddy's XDG config dir there. If you pin a
different `user:` in your compose file, that user must be able to write to
both paths — otherwise login state and backup codes cannot be persisted.