# 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=()" } # 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 }