Skip to content

fix(gateway): drop role/principalId from POST /v1/contacts body (ATL-515)#30372

Merged
dvargasfuertes merged 1 commit into
mainfrom
apollo/atl-515-contact-role-leak
May 12, 2026
Merged

fix(gateway): drop role/principalId from POST /v1/contacts body (ATL-515)#30372
dvargasfuertes merged 1 commit into
mainfrom
apollo/atl-515-contact-role-leak

Conversation

@vellum-apollo-bot
Copy link
Copy Markdown
Contributor

@vellum-apollo-bot vellum-apollo-bot Bot commented May 12, 2026

Fixes ATL-515 — P2 / High privilege-escalation finding from Carson's Shard audit.

The vulnerability

The native POST /v1/contacts handler introduced in #30141 forwarded body.role and body.principalId straight into ContactStore.upsertContact(). The route's auth is generic "edge" — no guardian-specific gate. So any authenticated caller could:

  1. GET /v1/contacts → find the guardian contact id.
  2. POST /v1/contacts with { id: <guardian id>, displayName: "x", role: "guardian", principalId: "<attacker principal>" }.
  3. ContactStore.upsertContact match-by-id path overwrites the existing row's role + principalId.
  4. Next guardian-bootstrap lookup (WHERE role = 'guardian') returns the attacker's principalId — full privilege elevation for every guardian-only flow.

A new guardian could also be created from scratch by sending a fresh { role: "guardian", principalId: ..., channels: [...] }.

The fix

role and principalId are removed from the route handler and from ContactStore.upsertContact's param surface. The store-level removal is the structural guarantee — there is no longer a code path that lets a caller of upsertContact() set those fields, so a future regression in the route handler can't reintroduce the vector either.

  • On update: existing role/principalId are preserved (display name + channels still update).
  • On create: role defaults to "contact", principalId to null.
  • Dual-write to assistant DB mirrors the same invariant.

Guardian role is set exclusively through guardian-bootstrap, which uses raw SQL and runs on a privileged path. The only call site of ContactStore.upsertContact() was the route handler itself — no other internal caller is affected. Confirmed no caller in assistant/ or clients/ POSTs role/principalId to /v1/contacts.

Tests

Added strips role and principalId from request body (privilege escalation guard) to contacts-control-plane-proxy.test.ts — asserts that when a request body contains role: "guardian" + principalId: "attacker-principal-id", those fields are not present in the params the route passes to ContactStore.upsertContact. Other 19 tests in the file pass; adjacent contact-store-mark-channel-verified + ipc-contact-routes suites pass; bunx tsc --noEmit clean.


Open in Devin Review

The native handler introduced in #30141 accepted role and principalId
from the request body and passed them straight into
ContactStore.upsertContact. The route is protected only by generic edge
auth, so any authenticated caller could rebind the guardian by POSTing
the guardian's contact id plus role:"guardian" + their own principalId
— elevating to guardian for every guardian-only flow.

Fix: strip role and principalId from the route input AND from
ContactStore.upsertContact's params surface (the route is the only
caller; guardian binding is owned by guardian-bootstrap, which writes
guardian role via raw SQL with its own privileged path). On update,
existing role/principalId are preserved. On create, role defaults to
"contact" and principalId to null.

Test added: privilege-escalation regression asserting POST /v1/contacts
with role:"guardian" + principalId in body does not forward those
fields to the service layer.
@linear
Copy link
Copy Markdown

linear Bot commented May 12, 2026

ATL-515

Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 0 potential issues.

View 1 additional finding in Devin Review.

Open in Devin Review

@dvargasfuertes dvargasfuertes merged commit adc98bb into main May 12, 2026
14 checks passed
@dvargasfuertes dvargasfuertes deleted the apollo/atl-515-contact-role-leak branch May 12, 2026 20:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant