1
0

Caddyfile 3.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384
  1. # FrankenPHP Caddyfile for the api container.
  2. # Serves Slim from public/ on :8081.
  3. {
  4. frankenphp
  5. order php_server before file_server
  6. auto_https off
  7. admin off
  8. # SEC_REVIEW F25: trust ONLY loopback as an XFF rewriter by default.
  9. # The previous `trusted_proxies static private_ranges` honoured
  10. # X-Forwarded-For from any RFC1918 peer. Combined with the wide
  11. # /internal/* CIDR gate, a neighbouring container on the same docker
  12. # bridge could forge `REMOTE_ADDR=127.0.0.1` via XFF and pass the
  13. # network check. The bundled docker-compose stack has no real proxy
  14. # in front of the api, so loopback-only is the correct default.
  15. #
  16. # Production deployments behind a real reverse proxy override the
  17. # `TRUSTED_PROXIES` env var to that proxy's CIDR — for example:
  18. # TRUSTED_PROXIES="10.0.0.5/32"
  19. # The default keeps `remote_ip` matchers below evaluating against the
  20. # real TCP peer regardless of any XFF header.
  21. servers {
  22. trusted_proxies static {$TRUSTED_PROXIES:127.0.0.1/32 ::1/128}
  23. }
  24. }
  25. :8081 {
  26. root * /app/public
  27. encode zstd gzip
  28. # ── Security headers (M14) ──────────────────────────────────────────
  29. # Applied to every response. The api serves JSON + the OpenAPI YAML +
  30. # the /api/docs viewer; everything else is locked down.
  31. header {
  32. # Identify ourselves as little as possible.
  33. -Server
  34. -X-Powered-By
  35. X-Content-Type-Options "nosniff"
  36. # The api doesn't render its own pages except /api/docs which is a
  37. # single embedded viewer; SAMEORIGIN is the conservative default
  38. # that still allows future same-origin embedding.
  39. X-Frame-Options "SAMEORIGIN"
  40. Referrer-Policy "strict-origin-when-cross-origin"
  41. Permissions-Policy "geolocation=(), microphone=(), camera=()"
  42. }
  43. # HSTS: prod-only. Setting it in dev would lock you out of plain-HTTP
  44. # localhost on the same hostname (sticky for 1 year). Gate strictly.
  45. @prod expression `{env.APP_ENV} == "production"`
  46. header @prod Strict-Transport-Security "max-age=31536000; includeSubDomains"
  47. # CSP: docs viewer needs RapiDoc from jsDelivr + inline styles + the
  48. # try-it-now feature posting to /api/v1/*. Everything else is JSON.
  49. @docs path /api/docs /api/v1/openapi.yaml
  50. 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'"
  51. @not_docs not path /api/docs /api/v1/openapi.yaml
  52. header @not_docs Content-Security-Policy "default-src 'none'; frame-ancestors 'none'; base-uri 'none'"
  53. # Internal jobs API: only callable from loopback by default
  54. # (SEC_REVIEW F25). The bundled sidecar scheduler joins the api's
  55. # network namespace via `network_mode: "service:api"`, so its calls
  56. # arrive on 127.0.0.1. Host-cron Option A in the SPEC also targets
  57. # localhost. Production topologies that genuinely need wider
  58. # reachability set `INTERNAL_CIDR_ALLOWLIST` for the PHP middleware
  59. # AND mirror the same CIDR list in this matcher (operators must
  60. # patch the Caddyfile or override via a custom config).
  61. # The PHP layer also enforces this (InternalNetworkMiddleware) —
  62. # Caddy is the first line of defence.
  63. @internal {
  64. path /internal/*
  65. remote_ip 127.0.0.1/32 ::1/128
  66. }
  67. handle @internal {
  68. php_server
  69. }
  70. @external_internal_blocked {
  71. path /internal/*
  72. not remote_ip 127.0.0.1/32 ::1/128
  73. }
  74. respond @external_internal_blocked 404
  75. php_server
  76. }