Skip to content

fix: close 29 audited defects across email abuse, the embed SDK, quotas and the audit trail - #64

Open
houko wants to merge 2 commits into
mainfrom
fix/audit-round2
Open

houko wants to merge 2 commits into
mainfrom
fix/audit-round2

Conversation

@houko

@houko houko commented Sep 26, 2026 •

Copy link
Copy Markdown
Member

No ticket — a second repository-wide audit, aimed away from the ground the previous eight sweeps already covered.

What & why

Was: one request to the public link-survey email action could mail an attacker-chosen list of addresses, with attacker-written copy, from the operator's own relay; a single Valkey blip doubled every side effect of a submission; quotas committed over their limit under concurrency; a blocked reCAPTCHA script stopped the embed SDK showing any survey for the rest of the page session.

Now: each of those is closed, with a test that fails on main.

Six unswept subsystems (auth, MCP, email, embed SDK, first-run setup, audit logs) and six cross-cutting lenses instead of a subsystem split. 42 candidates → 30 confirmed by two independent refuters, 12 refuted and dropped; two of the 30 were the same defect, so 29 fixes. Nothing came back critical, which is the honest result after eight prior sweeps.

Where to look

Breaking changes

  • This PR contains breaking changes

None. The one externally-visible tightening is that the link-survey email action now rejects a non-email email field with a validation error; the shipped client already validated it with z.email(), so no real caller changes.

Migrations & env

  • none

How this was tested

Rerun: pnpm format:check && pnpm lint && pnpm typecheck && pnpm test

Coverage

Behaviour How Outcome
Comma-separated recipient rejected unit (red on main) packages/types/email.test.ts; list-parsing confirmed against the vendored nodemailer
Survey name comes from the row, not the caller unit (red on main) link/actions.test.ts
Retried enqueue reuses one job id unit (red on main) lib/pipelines.test.ts
Quota admission serialises unit (mutation) drop the FOR UPDATE in quotas/lib/utils.ts → red
Audit target ids resolve for team / 2FA / attribute key unit (red on main) audit-logs/lib/handler.test.ts
Remaining 24 findings unit (red on main) / manual .tsx and comment-only fixes verified by reading
Full run

typecheck 27/27 · lint 0 errors + all four repo gates · test 28/28 tasks, 9053 passed in @forma/web, 706 files.

Production diff is 776 lines; tests are 1393 and are excluded from the size check.

Open gaps

  • A failed Notion OAuth exchange now preserves the live credential and logs, but no page reads the ?error= it redirects with, so the user still sees a bare "not connected".
  • deleteInviteAction's audit snapshot gained role in its select, but its test mocks Prisma, so nothing pins it.
  • docs/api-v2-reference/openapi.yml is stale on the two zod drifts. It was already stale on main, no CI gate reads it, and it wants a central generate-api-specs run rather than a hand edit here.

One defect this branch found the hard way

@forma/database#test declared no dependency on @forma/database#build, while @forma/web#test does, so turbo run test could run them together. The build's script begins by deleting generated/prisma/, so a concurrent database test could import client.ts in the window before internal/prismaNamespace.ts was rewritten. Dormant until now — nothing under packages/database loaded the generated client until the two new zod tests, one of which reads OrganizationRole at runtime. It reproduced only on CI, never locally, including after a clean regenerate.

Risks

  • The quota lock is the only raw SQL. Table name checked against the schema (SurveyQuota, no @@map), empty-array guarded, ordered by id.
  • The Airtable refresh lock is deliberately not released — the cache facade has only an unconditional del, and releasing a lock you may no longer hold admits a third refresher. It expires instead; the comment says so.
  • getEnqueueJobId optional-chains job.response, because it runs outside the retry loop's try and that function must never throw.

Note

AI model used — claude-opus-5[1m] (Claude Code), reasoning effort unknown.

…as and the audit trail

A second audit sweep, aimed away from the ground the previous eight already burnt: six subsystems none
of them touched (authentication, MCP, email, the embed SDK, first-run setup, audit logs) and six
cross-cutting lenses rather than subsystems. 42 candidates, 30 confirmed by two independent refuters,
12 refuted and dropped. Nothing came back critical, which is the honest result after eight sweeps.

The ones worth reading:

- The unauthenticated link-survey email action took `email: z.string()`, and nodemailer parses a string
  `to` as an address list, so one request mailed every address in a comma-separated string from the
  operator's own relay. The same action let the caller supply the survey name the mail renders, so the
  attacker wrote the copy as well. Rate limiting counted requests, not recipients.
- `sendToPipeline` retried its enqueue three times with no job id, so one Valkey blip between the Lua
  script running and the reply arriving doubled every side effect of a submission: two follow-up emails,
  two notification emails, a duplicate row in every connected sheet, two POSTs to every webhook. The
  idempotency seam already existed and no caller used it.
- Quota admission counted, decided in JS, then inserted, under Read Committed with nothing serialising
  it, so concurrent responses both passed a full quota and it committed over its limit.
- Three embed-SDK lifecycle defects left `isSurveyRunning` out of step with reality: a blocked reCAPTCHA
  script wedged it true for the whole page session, a fired timeout was never pruned so an unrelated
  route change released it under a live survey, and a `setUserId` did not cancel a pending survey, which
  then rendered for and was attributed to the next identity.
- Audit events for teams, 2FA and contact attribute keys recorded `target.id: "unknown"`, and an invite
  role change recorded no change at all — so an escalation from member to owner left no trace.

The rest are bounded here: a Notion OAuth callback that stored the error body as credentials, an
Airtable refresh whose safety comment had been falsified six hours earlier, three tests that could not
fail, a Stripe path that could give a paying organization a second customer, and two zod/Prisma drifts.
`@forma/database#test` declared no dependency on `@forma/database#build`, while `@forma/web#test`
does — so `turbo run test` was free to run them at the same time. The build's script starts with
`clean-generated-prisma.mjs`, which deletes `generated/prisma/` before Prisma rewrites it, so a
concurrent database test could import `generated/prisma/client.ts` in the window where the entry
exists and `internal/prismaNamespace.ts` does not.

Latent until this branch: nothing under `packages/database` loaded the generated client, because the
only tests there were scripts and the adapter. The two new zod schema tests do — `zod/users.ts` reads
`OrganizationRole` at runtime — which is what turned a dormant graph gap into a red CI job that no
local run reproduces.
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