Skip to content

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:

  1. Enable the chart’s NetworkPolicy — networkPolicy.enabled=true, with networkPolicy.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.
  2. Or the equivalent: a service-mesh authorization policy, or a dedicated node pool the Service is not routable from.

Two gaps neither closes:

  • hostNetwork pods use the node’s address, not a pod IP, so no namespaceSelector or podSelector matches 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.

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-Policydefault-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'
X-Content-Type-Optionsnosniff
X-Frame-OptionsDENYNo embedding, anywhere
Referrer-Policystrict-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.

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.

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.

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.

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.

Nothing in Parley backs itself up. Set up pg_dump on day one.

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.

  • 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_log records space.claim, space.delete, space.add_owner, org.purge and 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 JSON security event line; see Observability. LOG_LEVEL=debug is unrelated: one diagnostic line per request with the socket peer, the X-Forwarded-For chain and the resolved client address, and nothing else; no cookie, token or header is ever logged. It proves proxy configuration, not who did what.