Skip to content

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.

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
  1. 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.
  2. The proxy to the process. Parley trusts X-Forwarded-For only when TRUST_PROXY_HEADERS says to. Getting this wrong breaks the throttle in one direction or the other — see Deployment.
  3. 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.
  4. The process to Postgres. Full trust. The database credential is effectively root over all Parley data.

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.

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.

CapabilityStatusWhat to do instead
Passcodes stored readableOut of scopeA 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 spaceOut of scopeThe 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 defaultOut of scopeIt 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 unsignedOut of scopeIt 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 limiterNot builtRoom-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-onlyBuilt inorg_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.

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.