Hardening checklist
Verified against v0.10.0 · internal/api/security.go, internal/api/client_address.go, internal/api/authz.go, internal/api/me.go, internal/poker/routes.go, cmd/parley/main.go, deploy/k8s/deployment.yaml, deploy/charts/parley/templates/networkpolicy.yaml, .github/workflows/release.yml, internal/api/custody/store.go, internal/api/secevent.go, internal/db/migrations/0023_org_custody.sql
In order. The first three are the ones that actually bite.
1. Terminate TLS, and set BASE_URL to the https address
Section titled “1. Terminate TLS, and set BASE_URL to the https address”Parley never terminates TLS. Put Caddy or nginx in front of it.
Then set BASE_URL to the exact address people use. This is not cosmetic —
BASE_URL having an https scheme is what turns on the Secure flag on the
session cookie. Get it wrong and the cookie goes out without Secure, the
WebSocket origin check rejects the real origin, and the OIDC redirect points
somewhere nobody will arrive.
Confirm it from the boot log rather than from your intent:
"msg":"boot settings","base_url":"https://parley.example.com","cookie_secure":true,"allowed_ws_origin":"https://parley.example.com"On a bare binary, set BIND_ADDR to
127.0.0.1 if the process should stay on loopback. Kubernetes pods leave it
unset; NetworkPolicy is the control.
2. Set TRUST_PROXY_HEADERS to match your topology
Section titled “2. Set TRUST_PROXY_HEADERS to match your topology”true if and only if a proxy you control sets X-Forwarded-For itself. When it
is true, TRUSTED_PROXY_CIDRS is required and must contain only the immediate
and intermediate proxy hops Parley should trust. It must not contain a
client-reachable network. 0.0.0.0/0 and ::/0 are refused at startup.
Wrong in one direction the room-code throttle never fires; wrong in the other the whole internet shares one throttle bucket and eight bad guesses lock everyone out. Deployment has the detail.
An appending proxy is not a problem: Parley walks the chain right-to-left to the first untrusted hop and never reads the leftmost value.
On Kubernetes: the workable CIDR is the pod CIDR, and it is client-reachable
Section titled “On Kubernetes: the workable CIDR is the pod CIDR, and it is client-reachable”This is the one that bites in a cluster. The immediate peer Parley sees is the
ingress controller’s pod IP, and pod IPs are reassigned on every reschedule
— so the only TRUSTED_PROXY_CIDRS that keeps working is the pod CIDR
(10.42.0.0/16, 10.244.0.0/16, whatever your CNI hands out). Every workload in
the cluster can reach that network. Any pod can talk to Parley’s Service
directly, present its own X-Forwarded-For, and choose its client address,
taking over another client’s bucket in the passcode throttle or the open-mode
identity-creation quota.
“Never trust a client-reachable network” is true and useless here, because on Kubernetes there is no other choice. What you do instead is stop other pods reaching the Service:
- Enable the chart’s NetworkPolicy —
networkPolicy.enabled=true, withnetworkPolicy.ingressController.*pointing at your controller. It default-denies ingress to Parley’s pods and admits only that controller. Off by default because a CNI that ignores NetworkPolicies would make it look like protection it is not. See Kubernetes. - Or the equivalent: a service-mesh authorization policy, or a dedicated node pool the Service is not routable from.
Two gaps neither closes:
hostNetworkpods use the node’s address, not a pod IP, so nonamespaceSelectororpodSelectormatches them.- Node IPs that fall inside a trusted CIDR are trusted regardless of the policy — anything sourced from a node arrives from the node address. Check your node and pod ranges do not overlap.
Narrow the proxy’s own forwarded-header trust
Section titled “Narrow the proxy’s own forwarded-header trust”TRUSTED_PROXY_CIDRS closes the hole at Parley’s boundary only. A proxy that
accepts X-Forwarded-For from anyone and passes it on moves the same forgery one
hop upstream, where it arrives with the proxy’s blessing. Set Traefik’s
entryPoints.*.forwardedHeaders.trustedIPs, or ingress-nginx’s
proxy-real-ip-cidr, to just the load balancer or CDN in front of it. Both hops
have to be narrowed; either one alone leaves the bypass open.
3. Add HSTS at the proxy
Section titled “3. Add HSTS at the proxy”Parley does not send Strict-Transport-Security and will not. Add it where you
terminate TLS.
While you are there, these are the headers Parley does send on every response:
| Content-Security-Policy | default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline' | |
|---|---|---|
| X-Content-Type-Options | nosniff | |
| X-Frame-Options | DENY | No embedding, anywhere |
| Referrer-Policy | strict-origin-when-cross-origin |
And these it does not, all of which belong at the proxy if you want them:
Strict-Transport-Security, Permissions-Policy, Cross-Origin-Opener-Policy,
Cross-Origin-Embedder-Policy, Cross-Origin-Resource-Policy. Copy-paste
blocks for both Caddy and nginx, with a curl -sI line to confirm they landed,
are on Reverse proxy.
One of the five needs a choice, not just a copy. Cross-Origin-Embedder-Policy: require-corp is the value usually recommended alongside COOP for full
cross-origin isolation, but it blocks loading any cross-origin subresource
that does not send back Cross-Origin-Resource-Policy, and the plugin UI
iframe (/plugin-ui/<name>/<version>, sandboxed with
sandbox="allow-scripts" — see plugin
sandbox) is exactly that kind of
embed. The proxy snippets here use Cross-Origin-Embedder-Policy: credentialless instead: it gets you the same cross-origin-isolated globals
(SharedArrayBuffer, high-resolution timers) without requiring every embed to
opt in via CORP, and it does not break the plugin frame. If you need
require-corp for a reason specific to your deployment, verify the plugin UI
still renders before you ship it — do not carry it over as a default.
Two notes on the CSP, so you can assess it rather than pattern-match it. There is
no explicit frame-ancestors or object-src, but default-src 'self' is the
fallback for object-src, script-src, connect-src and font-src, so those
are covered; X-Frame-Options: DENY covers framing. The one genuine loosening is
style-src 'unsafe-inline', which the frontend needs today.
4. Decide what a passcode is worth to you
Section titled “4. Decide what a passcode is worth to you”A passcode gates a space; it is not identity, it is stored readable, and any member can rotate it or remove it — the owner role does not narrow that. See the threat model.
Open mode is trusted-network-only. If the instance is publicly reachable, use a space passcode or an external SSO/authentication proxy and add ingress abuse controls. Depending on your data, also consider:
- Keep the instance on an internal network only.
- Turn on OIDC so participants are at least attributable.
- Put an SSO proxy — oauth2-proxy, Authelia, Cloudflare Access — in front of the whole instance, which is the only way today to require identity before the passcode.
5. Lock down the database
Section titled “5. Lock down the database”The DATABASE_URL credential is effectively root over all Parley data. Give it
its own role, its own database, and no superuser rights or database ownership.
Parley needs no extensions beyond pgcrypto, which migration 0001 creates;
on PostgreSQL 13+ that only requires CREATE on the database (GRANT CREATE ON DATABASE parley TO parley;), not ownership or superuser.
Encrypt the connection. Parley refuses to start on a DATABASE_URL whose
sslmode is absent, disable, allow or prefer unless
DATABASE_ALLOW_PLAINTEXT=true, because the driver’s default silently falls
back to plaintext and every passcode and story note then crosses the network in
the clear. For CUI, use ?sslmode=verify-full&sslrootcert=/path/to/ca.pem on
DATABASE_URL: verify-full is the only mode that both encrypts and checks
the server’s certificate against the CA in sslrootcert (require encrypts
but skips the check, so a redirected connection can be impersonated, and
verify-ca checks the CA but not the hostname); Parley warns at boot if you
use require without pointing sslrootcert anywhere. PGSSLMODE and
PGSSLROOTCERT are accepted as a fallback when DATABASE_URL sets neither.
The boot line echoes the resolved mode as db_sslmode — read it rather than
assume, and if DATABASE_URL ever sets sslmode twice Parley refuses to
start rather than guess which value the driver would have honored.
Set a statement_timeout on the role — Parley sets none, so an unbounded
query or a client that stalls mid-transaction otherwise holds a connection
indefinitely:
ALTER ROLE parley SET statement_timeout = '30s';Also cap the role’s own connections, so one runaway Parley process (or an attacker who gets code execution in it) cannot exhaust every connection slot Postgres has:
ALTER ROLE parley CONNECTION LIMIT 50;Size the limit above Parley’s own pool ceiling — see Scaling and
limits — plus headroom for pg_dump during a
backup.
If you bake secrets into a PARLEY_CONFIG_FILE
rather than passing them as environment variables, chmod 600 it — it is a
plaintext DATABASE_URL and passcode-adjacent secrets sitting on disk.
6. Run the container as it ships
Section titled “6. Run the container as it ships”The image is distroless/static-debian12:nonroot: no shell, no package manager,
non-root by default. The Kubernetes manifest additionally sets
runAsNonRoot, allowPrivilegeEscalation: false, capabilities: drop: [ALL],
seccompProfile: RuntimeDefault and readOnlyRootFilesystem: true.
If you write your own manifest, carry all five across.
7. Rate limit at the edge
Section titled “7. Rate limit at the edge”Parley throttles room-code guesses and open-mode identity creation. It also caps spaces per identity, sessions per space, and stories per session. These controls bound persistent growth but do not rate-limit general API calls or WebSocket connections. Add request, connection, and bandwidth limits at the ingress if the instance is reachable from anywhere untrusted.
8. Back it up before it matters
Section titled “8. Back it up before it matters”Nothing in Parley backs itself up.
Set up pg_dump on day one.
9. Pin what you deploy
Section titled “9. Pin what you deploy”For releases produced by the hardened workflow, pin the published digest rather
than latest, use the digest-qualified release manifest, and verify its
attestation — Supply chain has the command. The
workflow builds without registry write access, verifies the staged registry
digest before assigning final tags, and attaches artifacts only after a
successful hardened release. Releases v0.1.0 through v0.2.1 were not backfilled.
What you cannot harden
Section titled “What you cannot harden”- There is no instance-wide operator account. Space ownership is per space (a space can have several owners) and stops at that boundary: only an owner may promote, demote, or remove a member, but any member may still change or remove the passcode, and story mutations and session controls are facilitator-only. An org admin has custody of every space in their org — list, rename, archive, delete, narrow visibility, grant ownership, claim an abandoned room — without reading votes, standups or rosters. That is management without access; see Organizations. Cross-instance administration is still SQL. A member writes only their own rows: their poker vote, their standup entry, and their standup readiness signal.
- The org audit log table is write-only.
org_audit_logrecordsspace.claim,space.delete,space.add_owner,org.purgeand plugin-admin actions. There is no API route or screen that reads it — tracked as #399 — so reviewing that table means querying Postgres. Do not describe it to a team as something they can open in the product. The same writes, plus sign-in, membership, passcode and guest-link events, each emit one stdout JSONsecurity eventline; see Observability.LOG_LEVEL=debugis unrelated: one diagnostic line per request with the socket peer, theX-Forwarded-Forchain and the resolved client address, and nothing else; no cookie, token or header is ever logged. It proves proxy configuration, not who did what.