Skip to content

A CRM contact's identity was decided by a column its subject can rewrite - #557

Merged
NewWorldVenture merged 4 commits into
mainfrom
claude/youthful-turing-ppwrkl
Sep 15, 2026
Merged

NewWorldVenture merged 4 commits into
mainfrom
claude/youthful-turing-ppwrkl

Conversation

@NewWorldVenture

@NewWorldVenture NewWorldVenture commented Sep 14, 2026 •

Copy link
Copy Markdown
Owner

Three commits. A takeover of a stranger's crm_contacts row; a cleanup of the ILIKE class around it that also fixes a live regression on main; and the E2E fixture fix that unblocks main, which is red right now.


1 — The identity key was user-writable

upsertOnboardingContact runs as the service role against crm_contacts, whose RLS is admin-only, and the row its lookup matches is the row it then overwrites with the caller's name, email, phone and family_id.

Two independent faults, each sufficient alone:

  1. The key. Both callers resolved email: profile?.email ?? auth.user.email. profiles.email is a plain text column, and profiles_update_self constrains which row you may update, not which columns — so its owner sets it to anything, including a stranger's exact address. No wildcard needed.
  2. The pattern. That value went unescaped into .ilike('email', email), so % matched every contact and .limit(1) picked one.

Measured on a replayed database, as authenticated

Step Result
attacker rewrites their own profiles.email to % 1 row
server reads profiles.email (preferred over the verified address) %
.ilike matches a contact they never owned yes
service-role update overwrites the victim's row 1 row — first_name=Attacker

Controls, same session, proving crm_contacts stayed shut to ordinary users throughout — so the only way in is the service-role path the app itself takes:

Control Result
rows an ordinary user can read 0
rows changed by a direct write 0 (value verified untouched afterwards)

My first version of that second control tested for an insufficient_privilege exception and reported "blocked: f". That was wrong — an UPDATE matching no RLS-visible row changes 0 rows and raises nothing. Re-measured on ROW_COUNT.

Third consequence, same root cause: fireAutomationEvent took the same column, and runSteps sends to: recipient.email through Resend from the product's own FROM_EMAIL — so it also chose who receives branded mail on our behalf.

Fix: the verified auth.user.email now wins at all four call sites, and the three identity lookups escape. saveUserProfile's own email: profile.email is untouched — that's the user writing their own row. Escaping alone would not have been enough; the exact-address takeover needs no wildcard, so the preference change is the load-bearing half.


2 — Escaping exactly once, including what no guard could see

Rebased onto a50433ce, which consolidated escapeLike and added an enforcement test. I had written my own helper first; it's deleted and its call sites repointed rather than shipping a fifth copy.

That guard matches only template literals, so two shapes were invisible to it.

Bare-value sites — .ilike('email', email) has no backticks. Four were still raw: the three crm_contacts lookups, .ilike('category', b.category) in the digital twin, and raw search terms in meals and finances.

Values escaped twice — four sites already escaped by hand upstream, then had escapeLike() added at the call site:

const term = title.trim().replace(/[%_]/g, (m) => `\\${m}`);  // once
... .ilike('title', `%${escapeLike(term)}%`)                   // twice

50% becomes 50\\\%, which LIKE reads as a literal backslash then a literal percent. Measured in Postgres 16:

pattern matches 50% off groceries
%50\% off groceries% (once) t
%50\\\% off groceries% (twice) f

So on main right now, findReminder, the task search, and the grocery and meal searches cannot find any row whose name contains % or _.

Fix: redundant upstream escapes removed (escaping stays at the call site, that commit's own convention), raw sites escaped, and the guard gains two rules — one for bare-value patterns, one forbidding a hand-rolled [%_] escape outside the helper. Both fail against main and pass here.

Two of those double-escapes were mine, introduced minutes earlier when a scripted inline→helper conversion overlapped a scripted raw-site fix and each added an escape. Typecheck caught one; a scan for const x = escapeLike(...) feeding escapeLike(x) caught the other. The new guard makes the class impossible to reintroduce quietly.


3 — The E2E fixture, which is why main is red

0299 refuses, at commit, an end state where a family has members but no active parent or adult. tests/e2e/authenticated.spec.ts set up its wallet probe by demoting the family's sole manager to child, so since aca5c7f2 that step fails 23514 and the probe never runs — on main as well as on every branch built from it.

The migration is right and the fixture was wrong: the probe is about the signed-in member being a child, not about the family losing its last manager. It now gives the household a second manager for the duration and restores the original shape afterwards. user_id: null is the same managed-profile row the 'Probe child' above already uses.

The order in finally is load-bearing and commented as such: restore the signed-in member first, then drop the stand-in.

Proved against a replayed database with the trigger present, driving each step as its own transaction — the constraint is DEFERRABLE INITIALLY DEFERRED and only fires at COMMIT, so a PL/pgSQL exception block around the UPDATE cannot see it (which is how my first probe managed to report that 0299 had refused nothing):

Sequence Result
demote the sole manager UPDATE 1, then COMMIT raises 23514 — reproduces CI exactly
insert stand-in, then demote commits; role reads child
restore, then drop the stand-in both commit; family back to 2 members
drop the stand-in before restoring refused 23514

Verification

Condition Result
New tests 9, of which 4 + 2 fail against the original code
Full suite 13,722 / 13,722
E2E in CI ✅ green — the two authenticated.spec.ts failures are gone
Escaping vs Postgres 16 checked directly, incl. a real underscore still matching

tsc, eslint and the Supabase query audit clean. No migration, so this reaches production with the deploy.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KRUgA6hD6QgzmtpSP6TUmP

@vercel

vercel Bot commented Sep 14, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
bubaly Ignored Ignored Sep 15, 2026 12:32am UTC

Request Review

Copy link
Copy Markdown
Owner Author

E2E (public · a11y · authenticated · mobile device matrix) is failing here, and it is not this PR's.

Both failures are tests/e2e/authenticated.spec.ts:83:7 › … › wallet money writes require a manager even when legacy policies existed, on chromium and iphone, with:

code: '23514'
message: 'A family must keep at least one active parent or adult, otherwise nobody
          can manage it. Add or promote another member first.'

That is the constraint trigger added by 0299_family_keeps_a_manager.sql in aca5c7f2.

Red on the base branch too, with the identical two tests and the identical message:

run head E2E result
34902370855 aca5c7f2 (main) 2 failed · 429 passed
34903487803 344d49e6 (this PR) 2 failed · 430 passed

This PR touches no migration, no tests/e2e/, and nothing in family membership or wallet code — only ILIKE escaping and the CRM contact identity key.

Why it breaks. The step sets up its probe by demoting the family's sole member to child so it can assert a child cannot mint wallet credits:

// tests/e2e/authenticated.spec.ts:213
const { error: childRoleError } = await service.from('family_members')
  .update({ role: 'child' }).eq('id', membership.id);

0299 is right to refuse that committed end state — it is exactly the lockout the migration exists to prevent. The fixture, not the migration, is what is wrong: the probe is about the member client being a child, not about the family having no manager.

Proposed patch — give the family a second active manager before the demotion, so the invariant holds and the probe still tests what it means to test:

// Keep a manager, or 0299's deferred constraint refuses the demotion at commit.
const { error: coParentError } = await service.from('family_members').insert({
  family_id: familyId, user_id: null, role: 'parent',
  display_name: 'Probe co-parent', is_active: true,
});
if (coParentError) throw coParentError;

const { error: childRoleError } = await service.from('family_members')
  .update({ role: 'child' }).eq('id', membership.id);

I have not pushed this. It belongs with 0299, whose author has the context on whether a user-less member row is acceptable in that fixture or whether a second seeded account is wanted instead — and pushing it here would widen this PR into that change while it is being actively worked on.

No re-run spent: the failure is deterministic and already confirmed on the base branch, so a re-run would tell us nothing a second time.


Generated by Claude Code

@NewWorldVenture
NewWorldVenture force-pushed the claude/youthful-turing-ppwrkl branch 2 times, most recently from 0379e52 to 8288f77 Compare September 14, 2026 23:08
NewWorldVenture pushed a commit that referenced this pull request Sep 14, 2026
main's 0299 refuses, at commit, an end state where a family has members but no
active parent or adult. tests/e2e/authenticated.spec.ts sets up its wallet probe
by demoting the family's SOLE manager to 'child', so since that migration landed
the step fails 23514 and the probe never runs — on main, and on every branch
built from it, including this one once it merged 0299.

This is not this PR's failure, and a fix already exists: PR #557 gives the
household a second manager for the duration of the probe and restores the
original shape afterwards, with the order in `finally` load-bearing (restore the
signed-in member first, then drop the stand-in — dropping it first leaves the
family managerless again and 0299 refuses that delete).

Ported here rather than waited on. It no-ops the moment main carries it, and
waiting would leave this PR's E2E job red for a reason that has nothing to do
with its diff. Cherry-picked verbatim from 8288f77 so the two copies cannot
drift; the reasoning is that commit's, not mine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TRY21ZKsFrfB3qtoP972A4
Swept all 27 .ilike/.like call sites, generalising the wildcard class just
fixed in inbound email routing. Most are family-scoped searches where a
wildcard only broadens your own search. Three were not: identity lookups
against crm_contacts, run through the SERVICE ROLE past admin-only RLS, where
the row the lookup matches is the row that then gets overwritten.

Two independent faults, each sufficient alone. First, the identity key was
user-writable: both upsertOnboardingContact callers resolved
`profile?.email ?? auth.user.email`, and profiles.email is a plain text column
whose owner may set it to anything — profiles_update_self constrains which row
you may update, not which columns. Second, that value went unescaped into
.ilike(), so '%' matched every contact and .limit(1) picked one.

Measured on a database replayed from the migrations, as authenticated, with
controls proving crm_contacts stayed shut to ordinary users throughout (0 rows
readable, 0 rows changed by a direct write, value verified untouched): the
attacker set their own profiles.email to '%', the server read it in preference
to the verified address, .ilike matched a contact they never owned, and the
service-role update overwrote it with their name, email and family.

Same root cause, third consequence: fireAutomationEvent took the same column,
and runSteps sends `to: recipient.email` through Resend from the product's own
FROM_EMAIL, so it also chose who receives branded mail on our behalf.

lib/db/like.ts holds one escapeLike, now used by all three identity lookups.
It escapes the backslash as well, and first — LIKE's default escape character
is the backslash, so a value containing one swallows the next character
('ab' ilike 'a\b' is true, measured). The four pre-existing local copies
escape only % and _ and still have that hole; left alone rather than widening
this change, and worth consolidating next.

The verified auth.user.email now wins at all four call sites. saveUserProfile's
own `email: profile.email` is untouched: that is the user writing their own
row. Escaping alone would not have been enough — the exact-address takeover
needs no wildcard, so the preference change is the load-bearing half.

Nine new tests, four of which fail against the original code. Escaping checked
against Postgres 16 directly, including that a real underscore still matches so
legitimate addresses keep resolving.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KRUgA6hD6QgzmtpSP6TUmP
… see

Rebased onto a50433c, which consolidated escapeLike into
lib/supabase/escape-like.ts and enforces one definition by test. My own copy is
deleted and its call sites repointed rather than shipping a fifth.

That commit's guard matches only template literals, so two shapes were
invisible to it.

Bare-value call sites: `.ilike('email', email)` has no backticks. Four were
still raw — the three crm_contacts identity lookups, `.ilike('category',
b.category)` in the digital twin, and raw search terms in meals and finances.

Values escaped twice: four sites already escaped by hand upstream then had
escapeLike() added at the call site. `50%` becomes `50\\\%`, which LIKE reads
as a literal backslash then a literal percent. Measured in Postgres 16:
ilike '%50\% off groceries%' is true, ilike '%50\\\% off groceries%' is false.
So findReminder, the task search and the grocery and meal searches stopped
finding any row whose name contains % or _.

The redundant upstream escapes are removed — escaping stays at the call site,
that commit's own convention — the raw sites now escape, and the guard gains
two rules: one for bare-value patterns, one forbidding a hand-rolled [%_]
escape outside the helper. Both fail against main and pass here.

Two of those double-escapes were mine, introduced minutes earlier when a
scripted inline-to-helper conversion overlapped a scripted raw-site fix and
each added an escape. Typecheck caught one; a scan for `const x =
escapeLike(...)` feeding `escapeLike(x)` caught the other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KRUgA6hD6QgzmtpSP6TUmP
0299 refuses, at commit, an end state where a family has members but no active
parent or adult. tests/e2e/authenticated.spec.ts set up its wallet probe by
demoting the family's SOLE manager to 'child', so since aca5c7f that step
fails 23514 and the probe never runs. Both the chromium and iphone projects
fail, on main as well as on every branch built from it.

The migration is right and the fixture was wrong: the probe is about the
signed-in MEMBER being a child, not about the family losing its last manager.
So the household gets a second manager for the duration, and gets it back to
its original shape afterwards. `user_id: null` is the same managed-profile row
the 'Probe child' above already uses.

The order in `finally` is load-bearing and is commented as such: restore the
signed-in member first, then drop the stand-in. Dropping it first leaves the
family managerless again and 0299 refuses that delete.

Proved against a database replayed from these migrations, with the trigger
present, driving each step as its own transaction because the constraint is
DEFERRABLE INITIALLY DEFERRED and only fires at COMMIT — a PL/pgSQL exception
block around the UPDATE cannot see it, which is how my first probe managed to
report that 0299 had not refused anything:

  demote the sole manager ............ UPDATE 1, then COMMIT raises 23514
  insert stand-in, then demote ....... commits; role reads 'child'
  restore, then drop the stand-in .... both commit; family back to 2 members
  drop the stand-in before restoring . refused 23514

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KRUgA6hD6QgzmtpSP6TUmP
…g out

tests/api-ai-runs.test.ts > GET /api/ai/runs/[id] > 401s anonymous callers has
been failing intermittently across this session at ~5.01s with "Test timed out
in 5000ms", having asserted nothing. Observed 2 failures in 6 full-suite runs
on this branch; it passes every time in isolation.

The cause is not the assertion. Every test in the file does its own
`await import(...)` of a route, which is cheap after the first — the module
cache serves it. The FIRST one pays the entire cold compile of the route and
its transitive imports, inside a 5s per-test timeout, while the rest of the
suite competes for the same workers. So the failure tracked scheduling, and
the test that happened to be first wore it.

The six route modules are now compiled once in beforeAll, which has no
per-test timeout budget, so the cost is paid outside the assertions. Raising
the timeout instead would only widen the window in which the same asymmetry
hides.

Verified: three consecutive full-suite runs, 13,727/13,727 each; the file
passes in isolation at 17/17; tsc and eslint clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KRUgA6hD6QgzmtpSP6TUmP
@NewWorldVenture
NewWorldVenture force-pushed the claude/youthful-turing-ppwrkl branch from 8288f77 to 6db885e Compare September 15, 2026 00:32
@NewWorldVenture
NewWorldVenture marked this pull request as ready for review September 15, 2026 11:44
@NewWorldVenture
NewWorldVenture merged commit cbc457a into main Sep 15, 2026
8 checks passed
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.

2 participants