Threat model
Verified against v0.10.0 · internal/api/authz.go, internal/api/passcode.go, internal/api/security.go, internal/api/me.go, internal/hub/hub.go, internal/poker/routes.go, internal/api/custody/store.go, internal/api/secevent.go, internal/db/migrations/0023_org_custody.sql
Parley is a self-hosted tool a team joins by following a link. This page states what it protects, from whom, and what it deliberately does not.
Assets
Section titled “Assets”| Asset | Why it matters |
|---|---|
| Unrevealed votes | The entire point of planning poker. Leaking one before the reveal invalidates the round |
| Session content | Story titles, notes, and standup entries — whatever a team types about its work |
| Passcodes | The credential that gates a space |
| Session tokens | Bearer credentials in a cookie; possession is identity |
| Member identity | A display name, and for federated instances an issuer and subject |
Trust boundaries
Section titled “Trust boundaries”- The internet to the reverse proxy. Parley never terminates TLS and has no opinion about it. Everything below assumes the proxy is doing its job.
- The proxy to the process. Parley trusts
X-Forwarded-Foronly whenTRUST_PROXY_HEADERSsays to. Getting this wrong breaks the throttle in one direction or the other — see Deployment. - A member to the space. Space membership grants read and participation access. Session control and poker story mutations cross a second boundary: only the facilitator may perform them. Managing who is in the space crosses a third: only an owner may promote, demote, or remove a member.
- The process to Postgres. Full trust. The database credential is effectively root over all Parley data.
Who is in scope
Section titled “Who is in scope”A stranger with the URL but not the code. Handled. A protected space refuses the join, the roster and session list are never returned to a non-member, and session endpoints return 404 rather than 403 so that the existence of a session is not disclosed. Wrong codes are throttled at eight per minute per address per space.
A participant trying to see votes early. Handled. Redaction happens in the serializer that builds every payload, not in a handler, so an unrevealed vote is structurally absent from the API response, the WebSocket frame and the CSV export alike. When auto-reveal is on it only ever fires when a vote lands (or when the facilitator turns the flag on with a full table), so disconnecting cannot trigger a reveal.
A malicious website in another tab. Handled by three layers rather than a
token: SameSite=Lax on both cookies, an Origin and Sec-Fetch-Site check
that rejects cross-site non-GET requests with 403, and a requirement that any
non-GET request carrying a body declares application/json — which blocks the
classic cross-site form post.
Two edges of that check are worth naming rather than glossing. A request with
no Origin header is not rejected by the origin comparison, and
Sec-Fetch-Site is only refused on the literal value cross-site, so a
sibling subdomain — which browsers label same-site — is not blocked by
that header. SameSite=Lax and the JSON content-type requirement are what carry
those cases. If you host untrusted content on a sibling subdomain of your Parley
instance, do not rely on this layer alone.
The check exempts GET deliberately: a cross-site page can send one but cannot
read the answer, so the exemption only holds while a GET changes nothing.
That is a rule the API keeps — no GET under /api writes to the database.
Anything that records state, down to stamping when a member last opened a
space, is a POST, so it passes through the check above. That includes the
bookkeeping a request does on its own behalf: resolving the session cookie
renews the SESSION_IDLE_TTL idle window on a write, but a GET resolves it read-only,
because otherwise one <img src> per visit would let a third-party page hold a
victim’s session open forever. Registering an action whose verb is GET is
refused at startup rather than left to review.
Someone forging a cookie. Session tokens are 32 bytes of cryptographic randomness, stored as a SHA-256 hash. The user id is public — it is broadcast to every participant — so the cookie deliberately does not contain it.
A spreadsheet-formula payload in exported data. Handled: every CSV cell
beginning =, +, -, @, tab or carriage return is quote-prefixed.
Who is out of scope
Section titled “Who is out of scope”A member of the space. Any member can read the space, vote, write their own standup entry, set their own standup readiness, rotate the passcode, or open the space entirely — none of which the owner role narrows. Managing membership is the one thing it does narrow: promoting, demoting, and removing a member are owner-only. Story creation, editing, reordering, selection, and deletion are facilitator-only. Membership is still the space trust boundary, so treat handing out a passcode as handing out participation and room-code control.
An operator, or anyone with database access. Both can read everything, including every space’s passcode.
Denial of service. Parley rate-limits room-code guessing and open-mode
identity creation, and caps spaces per identity, sessions per space, and stories
per session. One session token may also hold only WS_MAX_PER_TOKEN live
WebSockets per replica — 8 by default — so a single authenticated client cannot
spend unbounded goroutines, send buffers and revalidation queries; past the cap
the upgrade is refused with a 429 before a socket is created. There is still no
general request rate limiter. A public deployment needs ingress abuse controls;
open mode itself is trusted-network-only.
Anyone who has your session cookie. There is no binding to an address or a
device and no UI to list or administratively revoke sessions. Deleting a token
from the shared store is authoritative: HTTP requests stop resolving it and
WebSockets start a shared-store check every 30 seconds, allow that check a
30-second timeout, and then close with policy code 1008 on failure. Allow up to
60 seconds for an external deletion to be enforced. Logout does not wait for
that check: it deletes the token, disconnects its own sockets synchronously, and
publishes the revocation to the other replicas, which close theirs on receipt.
The 30-second check remains the fallback for a notification lost in transit.
Risks accepted on purpose
Section titled “Risks accepted on purpose”| Capability | Status | What to do instead |
|---|---|---|
| Passcodes stored readable | Out of scope | A code exists to be read off the page and passed on, like a Meet code. Hashing it means no member can ever look it up again. A database dump therefore discloses every code — stated plainly, and the reason a code is a door and not an identity. |
| Any member can rotate or open a space | Out of scope | The passcode route checks membership, not role, on purpose: any member can already read the current code off the space page and hand it on, so gating rotation behind ownership would restrict tidying up a leak without restricting the leak. Membership roles do exist — see Authorization — but they gate membership management, not the code. |
| Anonymous participation by default | Out of scope | It is the product for a trusted network: a link and a name, no account. Public deployments need a passcode or external SSO/auth proxy plus ingress abuse controls. |
| The sign-in flow cookie is unsigned | Out of scope | It is base64 JSON, not a signature. It only ever carries values that are re-validated on use — the redirect target is re-sanitised after the round trip, and state and nonce are compared in constant time against the identity provider's response. |
| No general ingress rate limiter | Not built | Room-code guesses, open-mode identity creation, resource counts, and WebSockets per session token (WS_MAX_PER_TOKEN, 8 per replica) are bounded. Rate-limit requests and connections at your reverse proxy or ingress too. Budget for the client's own polling first: an open space page re-reads the space every 30 seconds — roughly 2 requests per minute per tab — so a limit tighter than that breaks the page rather than an attacker. |
| Org audit log is write-only | Built in | org_audit_log records custody and plugin-admin actions in Postgres (space.claim, space.delete, space.add_owner and org.purge) with no read API or screen yet — #399 — so review means querying Postgres; see Organizations. The same writes, plus sign-in, membership, passcode and guest-link events, each emit one stdout JSON security event line; see Observability. Ordinary row timestamps and LOG_LEVEL=debug client-address lines are not that trail: debug names no user, is not retained, and is not queryable. |
Reporting a vulnerability
Section titled “Reporting a vulnerability”Use GitHub private vulnerability reporting (enabled on this repository) or email security@letsparley.io. Do not open a public Issue or Discussion. The response targets are acknowledgement within five business days, initial triage within ten business days, and coordinated disclosure within 90 calendar days.
Two pieces of history worth knowing. Every release before v0.2.2 could be crashed remotely by a client disconnecting during a broadcast, which panicked the server on a goroutine nothing recovers and took down every room on the instance. It was fixed in v0.2.2. Separately, v0.1.0 contains a room-code throttle that could be bypassed when Parley was reachable directly, fixed in v0.2.0.
No vulnerable tag or image was retagged, so anyone who already pulled a digest still gets what they pulled. v0.2.2 is the floor; run the current release, v0.10.0.