Skip to content

Commit 9becc77

Browse files
FelixTJDietrichFelix T.J. Dietrich
andauthored
fix(docker): send HSTS from the router, not the edge (#1827)
Co-authored-by: Felix T.J. Dietrich <felix.dietrich@financial-health-initiative.org>
1 parent 6e5b298 commit 9becc77

5 files changed

Lines changed: 60 additions & 6 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"hephaestus": patch
3+
---
4+
5+
An instance now sends `Strict-Transport-Security` whatever proxy sits in front of it. The header was
6+
attached to the reverse proxy shipped with Hephaestus, so a deployment fronted by a different proxy —
7+
a PaaS, or an existing ingress — served without it, and the omission was easy to miss because every
8+
other security header comes from the responses themselves. Nothing changes for a deployment that uses
9+
the bundled proxy.

.github/workflows/ci-compose-validate.yml

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,12 +74,23 @@ jobs:
7474
jq -e '
7575
.services.webapp.labels["traefik.http.middlewares.webapp-body-limit.buffering.maxRequestBodyBytes"] == "26214400" and
7676
.services.webapp.labels["traefik.http.middlewares.webapp-body-limit.buffering.memRequestBodyBytes"] == "1048576" and
77-
.services.webapp.labels["traefik.http.routers.https-webapp.middlewares"] == "webapp-body-limit" and
77+
(.services.webapp.labels["traefik.http.routers.https-webapp.middlewares"] | split(",") | index("webapp-body-limit")) != null and
7878
.services["webhook-server"].labels["traefik.http.middlewares.webhook-body-limit.buffering.maxRequestBodyBytes"] == "26214400" and
7979
.services["webhook-server"].labels["traefik.http.middlewares.webhook-body-limit.buffering.memRequestBodyBytes"] == "1048576" and
80-
.services["webhook-server"].labels["traefik.http.routers.https-webhook-server.middlewares"] == "webhook-body-limit,gzip"
80+
(.services["webhook-server"].labels["traefik.http.routers.https-webhook-server.middlewares"] | split(",") | index("webhook-body-limit") != null and index("gzip") != null)
8181
' <<< "$rendered_json" >/dev/null || {
8282
echo "::error::webapp and webhook request-body limits are not attached to their routers"; exit 1; }
83+
84+
# HSTS is the one security header a response cannot set for itself, and the entrypoint
85+
# that used to carry it exists only where the proxy stack is the edge. Behind any other
86+
# proxy the header simply vanished, so each router that terminates a public request
87+
# carries it now, and this is what keeps that true.
88+
jq -e '
89+
[ .services | to_entries[] | .value.labels // {} | to_entries[]
90+
| select(.key | test("^traefik\\.http\\.routers\\.https-[a-z-]+\\.middlewares$"))
91+
| select((.value | split(",") | any(test("hsts"))) | not) | .key ] | length == 0
92+
' <<< "$rendered_json" >/dev/null || {
93+
echo "::error::an https router would serve without HSTS behind a proxy that is not ours"; exit 1; }
8394
for required in \
8495
'entrypoints.https.http.middlewares=security-headers@docker' \
8596
'providers.file.filename=/etc/traefik/dynamic.yml' \

docker/compose.app.yaml

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,15 @@ services:
6565
- "traefik.http.routers.https-webapp.entryPoints=https"
6666
# Keep buffering off the /api router because it would hold SSE responses until they close.
6767
# Define this middleware with its router so the app stack does not depend on the proxy stack.
68+
# HSTS rides on the router, not on the entrypoint: an entrypoint middleware exists only when
69+
# this deployment's edge is the proxy stack, and behind someone else's proxy the header
70+
# would silently go missing. The other security headers are set by the responses
71+
# themselves, so this is the one that has nowhere else to come from.
72+
- traefik.http.middlewares.app-hsts.headers.stsSeconds=2592000
73+
- traefik.http.middlewares.app-hsts.headers.stsIncludeSubdomains=true
6874
- traefik.http.middlewares.webapp-body-limit.buffering.maxRequestBodyBytes=26214400
6975
- traefik.http.middlewares.webapp-body-limit.buffering.memRequestBodyBytes=1048576
70-
- "traefik.http.routers.https-webapp.middlewares=webapp-body-limit"
76+
- "traefik.http.routers.https-webapp.middlewares=app-hsts,webapp-body-limit"
7177
- "traefik.http.routers.https-webapp.rule=(${APP_HOST_MATCH:-Host(`${APP_HOSTNAME}`)}) && PathPrefix(`/`)"
7278
- "traefik.http.routers.https-webapp.service=https-webapp"
7379
- "traefik.http.routers.https-webapp.tls.certresolver=letsencrypt"
@@ -85,7 +91,7 @@ services:
8591
- "traefik.http.middlewares.canonical-host.redirectregex.replacement=https://${APP_HOSTNAME}/$${1}"
8692
- "traefik.http.middlewares.canonical-host.redirectregex.permanent=true"
8793
- "traefik.http.routers.https-canonical.entryPoints=https"
88-
- "traefik.http.routers.https-canonical.middlewares=canonical-host"
94+
- "traefik.http.routers.https-canonical.middlewares=app-hsts,canonical-host"
8995
- "traefik.http.routers.https-canonical.rule=(${APP_HOST_MATCH:-Host(`${APP_HOSTNAME}`)}) && !Host(`${APP_HOSTNAME}`) && PathPrefix(`/`)"
9096
- "traefik.http.routers.https-canonical.service=https-webapp"
9197
# No certresolver here on purpose: ACME derives a router's domains from its rule and does not
@@ -312,7 +318,7 @@ services:
312318
- "traefik.http.routers.http-application-server.rule=(${APP_HOST_MATCH:-Host(`${APP_HOSTNAME}`)}) && PathPrefix(`/api`)"
313319
- "traefik.http.routers.http-application-server.service=http-application-server"
314320
- "traefik.http.routers.https-application-server.entryPoints=https"
315-
- "traefik.http.routers.https-application-server.middlewares=edge-api-rate-limit,https-application-server-stripprefix"
321+
- "traefik.http.routers.https-application-server.middlewares=app-hsts,edge-api-rate-limit,https-application-server-stripprefix"
316322
- "traefik.http.routers.https-application-server.rule=(${APP_HOST_MATCH:-Host(`${APP_HOSTNAME}`)}) && PathPrefix(`/api`)"
317323
- "traefik.http.routers.https-application-server.tls.certresolver=letsencrypt"
318324
- "traefik.http.routers.https-application-server.tls=true"

docker/compose.core.yaml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,9 +125,15 @@ services:
125125
# filter + security chain all bind to /webhooks/**). Stripping it would 404 every delivery.
126126
# Match the receiver's 25 MiB limit here, not on the shared entrypoint where buffering would
127127
# hold /api SSE responses. Keeping it with this router avoids a dependency on the proxy stack.
128+
# HSTS rides on the router, not on the entrypoint: an entrypoint middleware exists only when
129+
# this deployment's edge is the proxy stack, and behind someone else's proxy the header
130+
# would silently go missing. The other security headers are set by the responses
131+
# themselves, so this is the one that has nowhere else to come from.
132+
- traefik.http.middlewares.core-hsts.headers.stsSeconds=2592000
133+
- traefik.http.middlewares.core-hsts.headers.stsIncludeSubdomains=true
128134
- traefik.http.middlewares.webhook-body-limit.buffering.maxRequestBodyBytes=26214400
129135
- traefik.http.middlewares.webhook-body-limit.buffering.memRequestBodyBytes=1048576
130-
- "traefik.http.routers.https-webhook-server.middlewares=webhook-body-limit,gzip"
136+
- "traefik.http.routers.https-webhook-server.middlewares=core-hsts,webhook-body-limit,gzip"
131137
- "traefik.http.routers.https-webhook-server.rule=(${APP_HOST_MATCH:-Host(`${APP_HOSTNAME}`)}) && PathPrefix(`/webhooks`)"
132138
- "traefik.http.routers.https-webhook-server.tls.certresolver=letsencrypt"
133139
- "traefik.http.routers.https-webhook-server.tls=true"

scripts/compose-host-routing.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,3 +110,25 @@ await test("what an operator is told to copy is one host per Host()", () => {
110110
}
111111
}
112112
});
113+
114+
await test("every https router sets HSTS itself, not through the edge", () => {
115+
// The proxy stack puts security-headers on its https entrypoint, which covers a deployment whose
116+
// edge is that stack. Staging and every preview run behind someone else's proxy, where no
117+
// entrypoint middleware of ours exists — and the omission is invisible, because the webapp's
118+
// nginx sets the other security headers on its own responses. HSTS is the one no response can
119+
// set for itself, so each router that terminates a public request carries it.
120+
for (const stack of ["app", "core"] as const) {
121+
const file = readFileSync(new URL(`../docker/compose.${stack}.yaml`, import.meta.url), "utf8");
122+
const routers = [...file.matchAll(/traefik\.http\.routers\.(https-[a-z-]+)\.rule=/g)].map(
123+
([, name]) => name,
124+
);
125+
assert.ok(routers.length > 0, `${stack} declares no https router`);
126+
for (const router of routers) {
127+
const attached = new RegExp(`routers\\.${router}\\.middlewares=([^"\n]*)`).exec(file)?.[1];
128+
assert.ok(
129+
attached?.includes("hsts"),
130+
`${stack}/${router} would serve without HSTS behind a proxy that is not ours`,
131+
);
132+
}
133+
}
134+
});

0 commit comments

Comments
 (0)