Caddyfile 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102
  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. # SEC_REVIEW F59: modern cross-origin isolation headers.
  43. # - COOP `same-origin` isolates the docs viewer / any
  44. # future api-rendered page from cross-origin popups.
  45. # - CORP `same-origin` blocks external pages from loading
  46. # the api JSON via cross-origin <script>/<img>/<link>
  47. # tags (defence in depth; CORS already gates fetch).
  48. # - X-Permitted-Cross-Domain-Policies `none` blocks legacy
  49. # Adobe Flash / Acrobat cross-domain.xml lookups.
  50. Cross-Origin-Opener-Policy "same-origin"
  51. Cross-Origin-Resource-Policy "same-origin"
  52. X-Permitted-Cross-Domain-Policies "none"
  53. }
  54. # HSTS: prod-only. Setting it in dev would lock you out of plain-HTTP
  55. # localhost on the same hostname (sticky for 1 year). Gate strictly.
  56. # Value is operator-tuneable via the `HSTS_HEADER` env var so a
  57. # deployment that wants to apply for the browser preload list can
  58. # opt in by setting:
  59. # HSTS_HEADER="max-age=31536000; includeSubDomains; preload"
  60. # Default keeps the conservative no-preload value — preload-listing
  61. # is a one-way commitment (browser preload removals take months) so
  62. # we don't enable it by default (SEC_REVIEW F60).
  63. @prod expression `{env.APP_ENV} == "production"`
  64. header @prod Strict-Transport-Security "{$HSTS_HEADER:max-age=31536000; includeSubDomains}"
  65. # CSP: docs viewer needs RapiDoc from jsDelivr + inline styles + the
  66. # try-it-now feature posting to /api/v1/*. Everything else is JSON.
  67. @docs path /api/docs /api/v1/openapi.yaml
  68. 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'"
  69. @not_docs not path /api/docs /api/v1/openapi.yaml
  70. header @not_docs Content-Security-Policy "default-src 'none'; frame-ancestors 'none'; base-uri 'none'"
  71. # Internal jobs API: only callable from loopback by default
  72. # (SEC_REVIEW F25). The bundled sidecar scheduler joins the api's
  73. # network namespace via `network_mode: "service:api"`, so its calls
  74. # arrive on 127.0.0.1. Host-cron Option A in the SPEC also targets
  75. # localhost. Production topologies that genuinely need wider
  76. # reachability set `INTERNAL_CIDR_ALLOWLIST` for the PHP middleware
  77. # AND mirror the same CIDR list in this matcher (operators must
  78. # patch the Caddyfile or override via a custom config).
  79. # The PHP layer also enforces this (InternalNetworkMiddleware) —
  80. # Caddy is the first line of defence.
  81. @internal {
  82. path /internal/*
  83. remote_ip 127.0.0.1/32 ::1/128
  84. }
  85. handle @internal {
  86. php_server
  87. }
  88. @external_internal_blocked {
  89. path /internal/*
  90. not remote_ip 127.0.0.1/32 ::1/128
  91. }
  92. respond @external_internal_blocked 404
  93. php_server
  94. }