Repository navigation
A CRM contact's identity was decided by a column its subject can rewrite - #557
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
|
Both failures are That is the constraint trigger added by Red on the base branch too, with the identical two tests and the identical message:
This PR touches no migration, no Why it breaks. The step sets up its probe by demoting the family's sole member to // tests/e2e/authenticated.spec.ts:213
const { error: childRoleError } = await service.from('family_members')
.update({ role: 'child' }).eq('id', membership.id);
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 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 |
0379e52 to
8288f77
Compare
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
8288f77 to
6db885e
Compare
Three commits. A takeover of a stranger's
crm_contactsrow; a cleanup of the ILIKE class around it that also fixes a live regression onmain; and the E2E fixture fix that unblocksmain, which is red right now.1 — The identity key was user-writable
upsertOnboardingContactruns as the service role againstcrm_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 andfamily_id.Two independent faults, each sufficient alone:
email: profile?.email ?? auth.user.email.profiles.emailis a plaintextcolumn, andprofiles_update_selfconstrains which row you may update, not which columns — so its owner sets it to anything, including a stranger's exact address. No wildcard needed..ilike('email', email), so%matched every contact and.limit(1)picked one.Measured on a replayed database, as
authenticatedprofiles.emailto%profiles.email(preferred over the verified address)%.ilikematches a contact they never ownedupdateoverwrites the victim's rowfirst_name=AttackerControls, same session, proving
crm_contactsstayed shut to ordinary users throughout — so the only way in is the service-role path the app itself takes:Third consequence, same root cause:
fireAutomationEventtook the same column, andrunStepssendsto: recipient.emailthrough Resend from the product's ownFROM_EMAIL— so it also chose who receives branded mail on our behalf.Fix: the verified
auth.user.emailnow wins at all four call sites, and the three identity lookups escape.saveUserProfile's ownemail: profile.emailis 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 consolidatedescapeLikeand 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 threecrm_contactslookups,.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%becomes50\\\%, which LIKE reads as a literal backslash then a literal percent. Measured in Postgres 16:50% off groceries%50\% off groceries%(once)%50\\\% off groceries%(twice)So on
mainright 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 againstmainand pass here.3 — The E2E fixture, which is why
mainis red0299refuses, at commit, an end state where a family has members but no active parent or adult.tests/e2e/authenticated.spec.tsset up its wallet probe by demoting the family's sole manager tochild, so sinceaca5c7f2that step fails23514and the probe never runs — onmainas 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: nullis the same managed-profile row the'Probe child'above already uses.The order in
finallyis 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 DEFERREDand 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 that0299had refused nothing):UPDATE 1, then COMMIT raises 23514 — reproduces CI exactlychildVerification
authenticated.spec.tsfailures are gonetsc, 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