Skip to content

Commit 403eab7

Browse files
feat(security): harden edge and runtime envelopes (#1661)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent cf8a11e commit 403eab7

33 files changed

Lines changed: 585 additions & 122 deletions
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"hephaestus": minor
3+
---
4+
5+
Hardens the reference deployment with HTTPS security headers, a TLS floor, a request-size ceiling, shared rate limits for costly operations, authenticated internal messaging, and container resource limits.
6+
7+
**Operators:** Set the new required `NATS_USERNAME` and `NATS_PASSWORD` variables. Optional `*_CPUS` and `*_PIDS_LIMIT` variables tune container ceilings. Remote databases require TLS unless `HEPHAESTUS_DATABASE_ALLOW_INSECURE_REMOTE=true` explicitly accepts plaintext transport. Each server role's database pool now defaults to 20 connections instead of 30; the optional `HIKARI_MAXIMUM_POOL_SIZE` variable tunes it.

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ jobs:
4848
-e 's|^HEPHAESTUS_SECURITY_ENCRYPTION_KEY=$|HEPHAESTUS_SECURITY_ENCRYPTION_KEY=0123456789abcdef0123456789abcdef|' \
4949
-e 's|^HEPHAESTUS_AUTH_STATE_COOKIE_KEY=$|HEPHAESTUS_AUTH_STATE_COOKIE_KEY=Y2ktbm90LWEtcmVhbC1zdGF0ZS1jb29raWUta2V5|' \
5050
-e 's|^WEBHOOK_SECRET=$|WEBHOOK_SECRET=ci000000000000000000000000000000000|' \
51+
-e 's|^NATS_USERNAME=$|NATS_USERNAME=ci|' \
52+
-e 's|^NATS_PASSWORD=$|NATS_PASSWORD=ci-not-a-real-password|' \
5153
.env
5254
while IFS= read -r image; do
5355
name="HEPHAESTUS_IMAGE_${image^^}"
@@ -88,6 +90,28 @@ jobs:
8890
# publish the reference's dashboard port and keep the maintainers' ACME
8991
# email. Assert the merged result rather than trust the runner's version.
9092
rendered=$(docker compose config)
93+
for required in \
94+
'entrypoints.https.http.middlewares=security-headers@docker' \
95+
'request-body-limit@docker' \
96+
'providers.file.filename=/etc/traefik/dynamic.yml' \
97+
'contentsecuritypolicy' \
98+
'default-src' \
99+
'stsseconds' \
100+
'2592000' \
101+
'maxrequestbodybytes' \
102+
'26214400' \
103+
'minversion' \
104+
'versiontls12'; do
105+
grep -Fiq "$required" <<< "$rendered" || {
106+
echo "::error::runtime edge envelope is missing '$required'"; exit 1; }
107+
done
108+
edge_csp=$(sed -n 's/.*contentSecurityPolicy=\(.*\)"/\1/p' ../compose.proxy.yaml)
109+
selfhost_csp=$(sed -n 's/.*contentSecurityPolicy=\(.*\)"/\1/p' compose.single-host.yaml)
110+
nginx_csp=$(sed -n 's/add_header Content-Security-Policy "\(.*\)" always;/\1/p' ../../webapp/docker/security-headers.conf)
111+
[ "$edge_csp" = "$nginx_csp" ] || {
112+
echo "::error::Traefik and nginx Content-Security-Policy values differ"; exit 1; }
113+
[ "$edge_csp" = "$selfhost_csp" ] || {
114+
echo "::error::reference and single-host Content-Security-Policy values differ"; exit 1; }
91115
grep -q "admin@tum.de" <<< "$rendered" && {
92116
echo "::error::the maintainers' ACME email survived the override — Compose is too old to honour !override"; exit 1; } || true
93117
published=$(docker compose config --format json \

.github/workflows/deploy-locked-compose.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ jobs:
7373
SECRET_HEPHAESTUS_INTEGRATION_SLACK_SIGNING_SECRET: ${{ secrets.HEPHAESTUS_INTEGRATION_SLACK_SIGNING_SECRET }}
7474
SECRET_HEPHAESTUS_SECURITY_ENCRYPTION_KEY: ${{ secrets.HEPHAESTUS_SECURITY_ENCRYPTION_KEY }}
7575
SECRET_HEPHAESTUS_WORKER_REGISTRATION_TOKEN: ${{ secrets.HEPHAESTUS_WORKER_REGISTRATION_TOKEN }}
76+
SECRET_NATS_USERNAME: ${{ secrets.NATS_USERNAME }}
77+
SECRET_NATS_PASSWORD: ${{ secrets.NATS_PASSWORD }}
7678
SECRET_OUTLINE_OAUTH_CLIENT_SECRET: ${{ secrets.OUTLINE_OAUTH_CLIENT_SECRET }}
7779
SECRET_POSTHOG_PERSONAL_API_KEY: ${{ secrets.POSTHOG_PERSONAL_API_KEY }}
7880
SECRET_POSTHOG_PROJECT_API_KEY: ${{ secrets.POSTHOG_PROJECT_API_KEY }}

.github/workflows/verify-changesets.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,11 @@ jobs:
6969
node scripts/verify-changesets.ts /tmp/changeset-status.json "${changesets_changed[@]}"
7070
fi
7171
if [ "${#migration_changed[@]}" -gt 0 ]; then
72+
# No --since here: a fragment may pair with a pending changeset merged by an earlier
73+
# PR, and --since filters those out of the status output. Without --since the
74+
# changesets CLI resolves its configured baseBranch by the exact ref name `main`,
75+
# which a detached PR merge-ref checkout does not have — give it one.
76+
git rev-parse --verify --quiet main >/dev/null || git branch main origin/main
7277
pnpm changeset status --output /tmp/pending-changeset-status.json
7378
node scripts/verify-changesets.ts /tmp/pending-changeset-status.json --migration "${migration_changed[@]}"
7479
fi
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
#### 🔴 The runtime envelope is hardened: NATS requires credentials, remote databases require TLS
2+
3+
**Affected**: every reference and self-hosted deployment. Deployments using a remote (non-Compose)
4+
PostgreSQL host are additionally affected by the TLS requirement.
5+
6+
**Before**: the bundled NATS broker accepted unauthenticated connections (mitigated only by its
7+
loopback bind), a remote `DATABASE_URL` with `sslmode=disable` connected silently, and each server
8+
role opened up to 30 database connections.
9+
10+
**After**: the broker, publisher, and consumer all require the same credentials and the stack
11+
refuses to start without them; in production a remote PostgreSQL host without `sslmode=require`
12+
(or stronger) aborts startup; each server role's connection pool defaults to 20.
13+
14+
**Migration**: before deploying, set `NATS_USERNAME` and `NATS_PASSWORD` in your `.env` to freshly
15+
generated random values (do not reuse an application key); deployments driven by the deploy
16+
workflow need the same pair as environment secrets. If your database host is remote, add
17+
`sslmode=require`, `verify-ca`, or `verify-full` to `DATABASE_URL` — only set
18+
`HEPHAESTUS_DATABASE_ALLOW_INSECURE_REMOTE=true` after explicitly accepting plaintext transport.
19+
Optional tuning: `HIKARI_MAXIMUM_POOL_SIZE` restores a larger pool, and the new
20+
`APPLICATION_SERVER_CPUS`/`APPLICATION_SERVER_PIDS_LIMIT` (plus the worker and webhook variants)
21+
adjust the container ceilings.

docker/.env.example

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,8 +97,19 @@ HEPHAESTUS_SECURITY_CREDENTIAL_ENCRYPTION_KEY=
9797
# -----------------------------------------------------------------------------
9898

9999
NATS_ENABLED=true
100+
# Required. Generate independent random values; do not reuse an application key.
101+
NATS_USERNAME=
102+
NATS_PASSWORD=
100103
NATS_DURABLE_CONSUMER_NAME=hephaestus-consumer
101104

105+
# Container resource ceilings
106+
#APPLICATION_SERVER_CPUS=4.0
107+
#APPLICATION_SERVER_PIDS_LIMIT=512
108+
#APPLICATION_WORKER_CPUS=4.0
109+
#APPLICATION_WORKER_PIDS_LIMIT=512
110+
#WEBHOOK_SERVER_CPUS=2.0
111+
#WEBHOOK_SERVER_PIDS_LIMIT=256
112+
102113
# How long a durable consumer may go with nothing CONNECTED to it before JetStream deletes it — not
103114
# how long it goes without traffic. Set 0s on a deployment that may be offline longer than this and
104115
# must resume exactly where it left off; between 0s and 1h is rejected at startup.

docker/compose.app.yaml

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,10 @@ services:
6666
- "traefik.http.routers.http-webapp.rule=Host(`${APP_HOSTNAME}`) && PathPrefix(`/`)"
6767
- "traefik.http.routers.http-webapp.service=http-webapp"
6868
- "traefik.http.routers.https-webapp.entryPoints=https"
69+
# Request-body cap (defined in compose.proxy.yaml). Router-scoped rather than entrypoint-wide
70+
# because the buffering middleware also buffers responses, which would stall the /api SSE
71+
# streams; the static SPA and the webhook receiver have no streaming responses.
72+
- "traefik.http.routers.https-webapp.middlewares=request-body-limit@docker"
6973
- "traefik.http.routers.https-webapp.rule=Host(`${APP_HOSTNAME}`) && PathPrefix(`/`)"
7074
- "traefik.http.routers.https-webapp.service=https-webapp"
7175
- "traefik.http.routers.https-webapp.tls.certresolver=letsencrypt"
@@ -112,6 +116,8 @@ services:
112116
# Bounds heap sizing to this container; without it the JVM targets host RAM and co-located
113117
# services oversubscribe and swap. Hosts the NATS sync consumers, so it takes the largest limit.
114118
mem_limit: ${APPLICATION_SERVER_MEM_LIMIT:-5g}
119+
cpus: ${APPLICATION_SERVER_CPUS:-4.0}
120+
pids_limit: ${APPLICATION_SERVER_PIDS_LIMIT:-512}
115121
ports:
116122
- "8080"
117123
environment:
@@ -136,6 +142,8 @@ services:
136142
DATABASE_USERNAME: root
137143
DATABASE_PASSWORD: root
138144
NATS_SERVER: nats://nats-server:4222
145+
NATS_USERNAME: ${NATS_USERNAME:?Set NATS_USERNAME}
146+
NATS_PASSWORD: ${NATS_PASSWORD:?Set NATS_PASSWORD}
139147
# Auth (Hephaestus-native; ADR 0017). State-cookie key MUST be base64 32-byte AES.
140148
HEPHAESTUS_AUTH_ISSUER: https://${APP_HOSTNAME}
141149
HEPHAESTUS_AUTH_STATE_COOKIE_KEY: ${HEPHAESTUS_AUTH_STATE_COOKIE_KEY}
@@ -256,7 +264,7 @@ services:
256264
THC_PATH: /actuator/health/liveness
257265
depends_on:
258266
postgres:
259-
condition: service_started
267+
condition: service_healthy
260268
volume-init:
261269
condition: service_completed_successfully
262270
restart: unless-stopped
@@ -276,19 +284,25 @@ services:
276284
# the sandbox network IP and route requests to an unreachable internal network.
277285
- "traefik.docker.network=shared-network"
278286
- "traefik.http.middlewares.https-application-server-stripprefix.stripprefix.prefixes=/api"
287+
- "traefik.http.middlewares.edge-api-rate-limit.ratelimit.average=100"
288+
- "traefik.http.middlewares.edge-api-rate-limit.ratelimit.period=1s"
289+
- "traefik.http.middlewares.edge-api-rate-limit.ratelimit.burst=200"
279290
- "traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https"
280291
- "traefik.http.routers.http-application-server.entryPoints=http"
281292
- "traefik.http.routers.http-application-server.middlewares=redirect-to-https"
282293
- "traefik.http.routers.http-application-server.rule=Host(`${APP_HOSTNAME}`) && PathPrefix(`/api`)"
283294
- "traefik.http.routers.http-application-server.service=http-application-server"
284295
- "traefik.http.routers.https-application-server.entryPoints=https"
285-
- "traefik.http.routers.https-application-server.middlewares=https-application-server-stripprefix"
296+
- "traefik.http.routers.https-application-server.middlewares=edge-api-rate-limit,https-application-server-stripprefix"
286297
- "traefik.http.routers.https-application-server.rule=Host(`${APP_HOSTNAME}`) && PathPrefix(`/api`)"
287298
- "traefik.http.routers.https-application-server.tls.certresolver=letsencrypt"
288299
- "traefik.http.routers.https-application-server.tls=true"
289300
- "traefik.http.routers.https-application-server.service=https-application-server"
290301
- "traefik.http.services.http-application-server.loadbalancer.server.port=8080"
291302
- "traefik.http.services.https-application-server.loadbalancer.server.port=8080"
303+
# The transport is defined by the proxy's file provider (compose.proxy.yaml): serversTransports
304+
# cannot be defined via docker labels, and referencing an undefined one disables this router.
305+
- "traefik.http.services.https-application-server.loadbalancer.serverstransport=application@file"
292306
- "traefik.http.routers.http-application-server.priority=5"
293307
- "traefik.http.routers.https-application-server.priority=10"
294308
# Replica affinity for workspace traffic (mentor lives here) — see docs/contributor/unified-pi-runtime.mdx.
@@ -325,6 +339,8 @@ services:
325339
image: "${HEPHAESTUS_IMAGE_APPLICATION_SERVER:?verified release lock required}"
326340
# Bounds heap sizing to this container (see application-server).
327341
mem_limit: ${APPLICATION_WORKER_MEM_LIMIT:-3g}
342+
cpus: ${APPLICATION_WORKER_CPUS:-4.0}
343+
pids_limit: ${APPLICATION_WORKER_PIDS_LIMIT:-512}
328344
environment:
329345
# See application-server: the buildpack's 10M direct-memory default is too small.
330346
JAVA_TOOL_OPTIONS: "-XX:MaxDirectMemorySize=${APP_MAX_DIRECT_MEMORY:-128M}"
@@ -395,7 +411,9 @@ services:
395411
THC_PATH: /actuator/health/liveness
396412
depends_on:
397413
application-server:
398-
condition: service_started
414+
condition: service_healthy
415+
postgres:
416+
condition: service_healthy
399417
volume-init:
400418
condition: service_completed_successfully
401419
restart: unless-stopped

docker/compose.core.yaml

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ services:
1212
# Must fit the Spring classpath-scan startup spike (well above the small steady footprint),
1313
# or the JVM OOMs during boot.
1414
mem_limit: ${WEBHOOK_SERVER_MEM_LIMIT:-2g}
15+
cpus: ${WEBHOOK_SERVER_CPUS:-2.0}
16+
pids_limit: ${WEBHOOK_SERVER_PIDS_LIMIT:-256}
1517
expose:
1618
- "8080"
1719
environment:
@@ -24,6 +26,8 @@ services:
2426
# NATS — reuses the existing `natsConnection` bean to publish inbound events.
2527
HEPHAESTUS_SYNC_NATS_ENABLED: "true"
2628
HEPHAESTUS_SYNC_NATS_SERVER: "nats://nats-server:4222"
29+
NATS_USERNAME: ${NATS_USERNAME:?Set NATS_USERNAME}
30+
NATS_PASSWORD: ${NATS_PASSWORD:?Set NATS_PASSWORD}
2731
# Stream bounds belong on THIS container. The beans that read hephaestus.webhook.stream.* are
2832
# gated on the webhook role, so the same variables on application-server (which runs with the
2933
# role off) would configure nothing at all. `check:env` fails the build if they drift back.
@@ -95,6 +99,8 @@ services:
9599
depends_on:
96100
nats-server:
97101
condition: service_healthy
102+
postgres:
103+
condition: service_healthy
98104
restart: unless-stopped
99105
# Spring drains HTTP (timeout-per-shutdown-phase 20s) THEN WebhookGracefulShutdown drains
100106
# NATS (up to 15s). Docker must allow ≥35s before SIGKILL, or the JVM is cut mid-drain.
@@ -111,7 +117,10 @@ services:
111117
- "traefik.http.routers.https-webhook-server.entryPoints=https"
112118
# No stripprefix: the receiver owns the full "/webhooks/{kind}" path (controller + payload-size
113119
# filter + security chain all bind to /webhooks/**). Stripping it would 404 every delivery.
114-
- "traefik.http.routers.https-webhook-server.middlewares=gzip"
120+
# request-body-limit (defined in compose.proxy.yaml) caps webhook deliveries at the same
121+
# 25 MiB the receiver admits; router-scoped because entrypoint-wide buffering would stall
122+
# the /api SSE streams (see compose.proxy.yaml).
123+
- "traefik.http.routers.https-webhook-server.middlewares=request-body-limit@docker,gzip"
115124
- "traefik.http.routers.https-webhook-server.rule=Host(`${APP_HOSTNAME}`) && PathPrefix(`/webhooks`)"
116125
- "traefik.http.routers.https-webhook-server.tls.certresolver=letsencrypt"
117126
- "traefik.http.routers.https-webhook-server.tls=true"
@@ -139,10 +148,12 @@ services:
139148
image: ${HEPHAESTUS_IMAGE_NATS:?verified release lock required}
140149
restart: unless-stopped
141150
ports:
142-
# Loopback-only by default. Set NATS_BIND_HOST=0.0.0.0 (or a specific interface) to let other
143-
# hosts reach the bus — only on a trusted or firewalled network, as NATS runs unauthenticated.
151+
# Publish on loopback by default.
144152
- "${NATS_BIND_HOST:-127.0.0.1}:4222:4222"
145153
command: ["--config", "/etc/nats/nats-server.conf"]
154+
environment:
155+
NATS_USERNAME: ${NATS_USERNAME:?Set NATS_USERNAME}
156+
NATS_PASSWORD: ${NATS_PASSWORD:?Set NATS_PASSWORD}
146157
volumes:
147158
- nats-data:/data
148159
configs:
@@ -176,6 +187,11 @@ configs:
176187
content: |
177188
listen: "0.0.0.0:4222"
178189
190+
authorization {
191+
user: $$NATS_USERNAME
192+
password: $$NATS_PASSWORD
193+
}
194+
179195
http_port: 8222
180196
181197
# A message the receiver accepts but the broker refuses is verified, admitted and then lost.

docker/compose.proxy.yaml

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,20 @@ services:
99
ports:
1010
- "80:80"
1111
- "443:443"
12-
- "8080:8080"
1312
volumes:
1413
- /var/run/docker.sock:/var/run/docker.sock:ro
1514
- ./letsencrypt:/letsencrypt
1615
command:
1716
- "--ping=true"
1817
- "--ping.entrypoint=http"
19-
- "--api.dashboard=true"
20-
- "--api.insecure=false"
18+
- "--metrics.prometheus=true"
2119
- "--entrypoints.http.address=:80"
2220
- "--entrypoints.https.address=:443"
21+
# Entrypoint-wide response headers only. The request-body-limit buffering middleware must NOT
22+
# sit on the entrypoint: Traefik's buffering middleware buffers whole responses, which would
23+
# hold back every SSE stream (mentor chat, sync live-push) until it closes. It is attached
24+
# per-router to the non-streaming surfaces (webapp, webhook-server) instead.
25+
- "--entrypoints.https.http.middlewares=security-headers@docker"
2326
- "--entrypoints.http.http.encodequerysemicolons=true"
2427
- "--entryPoints.http.http2.maxConcurrentStreams=50"
2528
- "--entrypoints.https.http.encodequerysemicolons=true"
@@ -37,11 +40,24 @@ services:
3740
- "--certificatesresolvers.letsencrypt.acme.email=admin@tum.de"
3841
- "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=http"
3942
- "--providers.docker=true"
43+
# TLS options and serversTransports are NOT definable through Docker labels (the docker
44+
# provider silently drops them, and a dangling serversTransport reference disables the whole
45+
# router), so they live in this file-provider fragment.
46+
- "--providers.file.filename=/etc/traefik/dynamic.yml"
47+
configs:
48+
- source: traefik-dynamic
49+
target: /etc/traefik/dynamic.yml
4050
labels:
4151
- traefik.enable=true
42-
- traefik.http.routers.traefik.entrypoints=http
43-
- traefik.http.routers.traefik.service=api@internal
44-
- traefik.http.services.traefik.loadbalancer.server.port=8080
52+
- traefik.http.middlewares.security-headers.headers.stsSeconds=2592000
53+
- traefik.http.middlewares.security-headers.headers.stsIncludeSubdomains=true
54+
- traefik.http.middlewares.security-headers.headers.contentTypeNosniff=true
55+
- traefik.http.middlewares.security-headers.headers.frameDeny=true
56+
- traefik.http.middlewares.security-headers.headers.referrerPolicy=strict-origin-when-cross-origin
57+
- traefik.http.middlewares.security-headers.headers.permissionsPolicy=geolocation=(), camera=(), microphone=(), payment=()
58+
- "traefik.http.middlewares.security-headers.headers.contentSecurityPolicy=default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; connect-src 'self' https: wss:; worker-src 'self' blob:; manifest-src 'self'; upgrade-insecure-requests"
59+
- traefik.http.middlewares.request-body-limit.buffering.maxRequestBodyBytes=26214400
60+
- traefik.http.middlewares.request-body-limit.buffering.memRequestBodyBytes=1048576
4561
- traefik.http.middlewares.errorpage.errors.status=501,502,503,504
4662
- traefik.http.middlewares.errorpage.errors.service=maintenance
4763
- traefik.http.middlewares.errorpage.errors.query=/index.html
@@ -103,6 +119,22 @@ networks:
103119
driver: bridge
104120

105121
configs:
122+
traefik-dynamic:
123+
content: |
124+
# File-provider fragment for dynamic configuration the docker provider cannot express as
125+
# labels. Traefik silently ignores tls.options and serversTransports labels, so putting
126+
# these there would leave TLS 1.0/1.1 accepted and (for a referenced transport) disable
127+
# the router that names it.
128+
tls:
129+
options:
130+
default:
131+
minVersion: VersionTLS12
132+
http:
133+
serversTransports:
134+
application:
135+
forwardingTimeouts:
136+
dialTimeout: 10s
137+
responseHeaderTimeout: 30s
106138
nginx-default-config:
107139
content: |
108140
server {

docker/preview/compose.app.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,8 @@ services:
251251
# The integration consumer reads staging's JetStream, so a preview sees the events a shared
252252
# GitHub App delivers there rather than an empty broker.
253253
NATS_SERVER: ${NATS_SERVER:-nats://nats-server:4222}
254+
NATS_USERNAME: ${NATS_USERNAME}
255+
NATS_PASSWORD: ${NATS_PASSWORD}
254256
NATS_ENABLED: "true"
255257
# Per-deploy durable name, so previews get their own JetStream consumers instead of competing
256258
# for one and stealing each other's messages.

0 commit comments

Comments
 (0)