| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495 |
- # FrankenPHP Caddyfile for the api container.
- # Serves Slim from public/ on :8081.
- {
- frankenphp
- order php_server before file_server
- auto_https off
- admin off
- # SEC_REVIEW F25: trust ONLY loopback as an XFF rewriter by default.
- # The previous `trusted_proxies static private_ranges` honoured
- # X-Forwarded-For from any RFC1918 peer. Combined with the wide
- # /internal/* CIDR gate, a neighbouring container on the same docker
- # bridge could forge `REMOTE_ADDR=127.0.0.1` via XFF and pass the
- # network check. The bundled docker-compose stack has no real proxy
- # in front of the api, so loopback-only is the correct default.
- #
- # Production deployments behind a real reverse proxy override the
- # `TRUSTED_PROXIES` env var to that proxy's CIDR — for example:
- # TRUSTED_PROXIES="10.0.0.5/32"
- # The default keeps `remote_ip` matchers below evaluating against the
- # real TCP peer regardless of any XFF header.
- servers {
- trusted_proxies static {$TRUSTED_PROXIES:127.0.0.1/32 ::1/128}
- }
- }
- :8081 {
- root * /app/public
- encode zstd gzip
- # ── Security headers (M14) ──────────────────────────────────────────
- # Applied to every response. The api serves JSON + the OpenAPI YAML +
- # the /api/docs viewer; everything else is locked down.
- header {
- # Identify ourselves as little as possible.
- -Server
- -X-Powered-By
- X-Content-Type-Options "nosniff"
- # The api doesn't render its own pages except /api/docs which is a
- # single embedded viewer; SAMEORIGIN is the conservative default
- # that still allows future same-origin embedding.
- X-Frame-Options "SAMEORIGIN"
- Referrer-Policy "strict-origin-when-cross-origin"
- Permissions-Policy "geolocation=(), microphone=(), camera=()"
- # SEC_REVIEW F59: modern cross-origin isolation headers.
- # - COOP `same-origin` isolates the docs viewer / any
- # future api-rendered page from cross-origin popups.
- # - CORP `same-origin` blocks external pages from loading
- # the api JSON via cross-origin <script>/<img>/<link>
- # tags (defence in depth; CORS already gates fetch).
- # - X-Permitted-Cross-Domain-Policies `none` blocks legacy
- # Adobe Flash / Acrobat cross-domain.xml lookups.
- Cross-Origin-Opener-Policy "same-origin"
- Cross-Origin-Resource-Policy "same-origin"
- X-Permitted-Cross-Domain-Policies "none"
- }
- # HSTS: prod-only. Setting it in dev would lock you out of plain-HTTP
- # localhost on the same hostname (sticky for 1 year). Gate strictly.
- @prod expression `{env.APP_ENV} == "production"`
- header @prod Strict-Transport-Security "max-age=31536000; includeSubDomains"
- # CSP: docs viewer needs RapiDoc from jsDelivr + inline styles + the
- # try-it-now feature posting to /api/v1/*. Everything else is JSON.
- @docs path /api/docs /api/v1/openapi.yaml
- header @docs Content-Security-Policy "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data: https://cdn.jsdelivr.net; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
- @not_docs not path /api/docs /api/v1/openapi.yaml
- header @not_docs Content-Security-Policy "default-src 'none'; frame-ancestors 'none'; base-uri 'none'"
- # Internal jobs API: only callable from loopback by default
- # (SEC_REVIEW F25). The bundled sidecar scheduler joins the api's
- # network namespace via `network_mode: "service:api"`, so its calls
- # arrive on 127.0.0.1. Host-cron Option A in the SPEC also targets
- # localhost. Production topologies that genuinely need wider
- # reachability set `INTERNAL_CIDR_ALLOWLIST` for the PHP middleware
- # AND mirror the same CIDR list in this matcher (operators must
- # patch the Caddyfile or override via a custom config).
- # The PHP layer also enforces this (InternalNetworkMiddleware) —
- # Caddy is the first line of defence.
- @internal {
- path /internal/*
- remote_ip 127.0.0.1/32 ::1/128
- }
- handle @internal {
- php_server
- }
- @external_internal_blocked {
- path /internal/*
- not remote_ip 127.0.0.1/32 ::1/128
- }
- respond @external_internal_blocked 404
- php_server
- }
|