1
0

Caddyfile 5.6 KB

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