Skip to content

Commit 63e9c7f

Browse files
committed
fix(edge): accept never demotes, the migration splits into safe-now functions and apply-last lockdown
Round-2 security review fixes: N1 (Important) — accept_invite could DEMOTE: invites only ever carry role admin|member, so an owner accepting a stray member-invite went owner->member, potentially zeroing the account's owners (an admin could engineer this to bypass the owner-removal rule). Replaced the blind role-overwriting upsert with a read-then-branch: after the atomic claim, read the signer's existing membership (.ilike, case-insensitive); no row inserts at invite.role; existing role >= invite role leaves the row untouched (invite still consumed); invite role higher updates only the role column. Reverts the invite to 'pending' on any write failure, same as before. N2 (Important) — deploy-order hard dependency: leave/remove_member call delete_owner_guarded, which lived in the migration gated "APPLY ONLY AFTER the edge function is live" — so those two actions would 500 for the entire rollout window. Split the migration: - 20260801_membership_functions.sql: get_invite_by_token + delete_owner_guarded + all revokes/grants + the new unique index (N3). Additive only, safe to apply immediately, BEFORE the edge function deploys. - 20260802_account_membership_lockdown.sql: only the 8 drop-policy statements, keeping the "APPLY ONLY AFTER" gate. Deleted the old combined 20260801_account_membership_lockdown.sql. N3 (Minor) — added uq_account_owners_account_lower_wallet, a unique index on (account_id, lower(wallet_address)), to the functions migration. Closes the latent trap where the one known checksummed production row could gain a lowercase sibling (e.g. via accept_invite's insert path) and make .ilike()/.maybeSingle() lookups start matching 2 rows and 500 forever. Verified zero duplicate-case pairs exist in prod today; existing data is NOT normalized (no FK cascade from users to make that safe here). N4 (Minor) — the ERC-1271 verifyMessage catch-all was returning 503 for malformed signatures too, not just RPC outages. Added isWellFormedSignature (0x + even-length hex, >=132 chars) checked before any verifier call; malformed signatures now fail fast as 401 BAD_SIGNATURE. 503 VERIFY_UNAVAILABLE is now reserved for errors thrown by the ERC-1271 RPC call path after shape validation already passed. pnpm test:web stays green (271/271).
1 parent bac90ee commit 63e9c7f

4 files changed

Lines changed: 168 additions & 84 deletions

File tree

apps/expo/supabase/functions/org-membership/index.ts

Lines changed: 72 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@
22
* Supabase Edge Function: org-membership
33
*
44
* The ONLY write path for org membership once
5-
* 20260801_account_membership_lockdown.sql is applied: creating/revoking
6-
* invites, accepting/declining them, leaving an org, removing a member,
7-
* updating account fields, and creating a new account. Also serves two
8-
* privileged reads (list_invites, has_pending_invite) that the lockdown
9-
* migration closes off from anon-key access.
5+
* supabase/migrations/20260802_account_membership_lockdown.sql is applied
6+
* (which itself requires 20260801_membership_functions.sql to already be
7+
* live — see that migration's header): creating/revoking invites,
8+
* accepting/declining them, leaving an org, removing a member, updating
9+
* account fields, and creating a new account. Also serves two privileged
10+
* reads (list_invites, has_pending_invite) that the lockdown migration
11+
* closes off from anon-key access.
1012
*
1113
* Auth: every request carries a signed message
1214
* ("roebel-org-v1:<action>:<wallet>:<timestampSec>:<payloadHash>") signed
@@ -59,6 +61,14 @@ const MAX_MESSAGE_AGE_SECONDS = 300;
5961
const WALLET_RE = /^0x[0-9a-fA-F]{40}$/;
6062
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
6163
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
64+
// Loose shape check, not a real signature parse: 0x + even-length hex,
65+
// at least 65 bytes (132 chars total — a plain ECDSA sig; ERC-6492-wrapped
66+
// smart-account sigs are longer). Used to reject garbage BEFORE calling any
67+
// verifier, so a malformed signature reads as 401 BAD_SIGNATURE, never a
68+
// 503 that implies the RPC itself is the problem.
69+
const SIGNATURE_SHAPE_RE = /^0x[0-9a-fA-F]+$/;
70+
71+
const ROLE_RANK: Record<string, number> = { owner: 3, admin: 2, member: 1 };
6272

6373
const INVITE_ROLES = ['admin', 'member'] as const;
6474
const ACCOUNT_TYPES = ['personal', 'organisation'] as const;
@@ -164,6 +174,14 @@ function isHttpsUrl(v: string): boolean {
164174
return v.startsWith('https://');
165175
}
166176

177+
function isWellFormedSignature(sig: string): boolean {
178+
if (!SIGNATURE_SHAPE_RE.test(sig)) return false;
179+
const hexLen = sig.length - 2; // strip '0x'
180+
if (hexLen % 2 !== 0) return false;
181+
if (sig.length < 132) return false;
182+
return true;
183+
}
184+
167185
// Mirrors apps/web/src/lib/slug.ts generateSlug / apps/expo/lib/supabase-accounts.ts
168186
// generateSlug byte-for-byte.
169187
function baseSlugify(input: string): string {
@@ -339,22 +357,53 @@ async function handleAcceptInvite(
339357
return fail('INVITE_GONE', 409, 'invite was already resolved by a concurrent request');
340358
}
341359

342-
// Upsert WITHOUT ignoreDuplicates: an existing member accepting a
343-
// higher-role invite must have their role updated, not silently no-op'd.
344-
const { error: ownerErr } = await admin.from('account_owners').upsert(
345-
{
360+
// Never demote. Invites only ever carry role admin|member — a blind
361+
// upsert of invite.role would let an owner who mistakenly (or an admin
362+
// who deliberately) re-accepts a stray member-invite get knocked down to
363+
// 'member', potentially zeroing the account's owner count and letting an
364+
// admin engineer a way around the owner-removal rule. Read the signer's
365+
// existing membership first (case-insensitive — a checksummed row may
366+
// still exist at rest) and only ever raise the role, never lower it.
367+
const { data: existingRow, error: existingErr } = await admin
368+
.from('account_owners')
369+
.select('role')
370+
.eq('account_id', invite.account_id)
371+
.ilike('wallet_address', signer)
372+
.maybeSingle();
373+
if (existingErr) {
374+
await admin.from('invite_tokens').update({ status: 'pending' }).eq('id', inviteId).eq('status', 'accepted');
375+
return fail('INTERNAL', 500, existingErr.message);
376+
}
377+
378+
if (!existingRow) {
379+
const { error: insertErr } = await admin.from('account_owners').insert({
346380
account_id: invite.account_id,
347381
wallet_address: signer,
348382
role: invite.role,
349383
invited_by: invite.invited_by,
350-
},
351-
{ onConflict: 'account_id,wallet_address' },
352-
);
353-
if (ownerErr) {
354-
// Best-effort revert: don't leave the invite stuck 'accepted' with no
355-
// membership to show for it.
356-
await admin.from('invite_tokens').update({ status: 'pending' }).eq('id', inviteId).eq('status', 'accepted');
357-
return fail('INTERNAL', 500, ownerErr.message);
384+
});
385+
if (insertErr) {
386+
// Best-effort revert: don't leave the invite stuck 'accepted' with no
387+
// membership to show for it.
388+
await admin.from('invite_tokens').update({ status: 'pending' }).eq('id', inviteId).eq('status', 'accepted');
389+
return fail('INTERNAL', 500, insertErr.message);
390+
}
391+
} else {
392+
const existingRank = ROLE_RANK[(existingRow as { role: string }).role] ?? 0;
393+
const inviteRank = ROLE_RANK[invite.role] ?? 0;
394+
if (inviteRank > existingRank) {
395+
const { error: updateErr } = await admin
396+
.from('account_owners')
397+
.update({ role: invite.role })
398+
.eq('account_id', invite.account_id)
399+
.ilike('wallet_address', signer);
400+
if (updateErr) {
401+
await admin.from('invite_tokens').update({ status: 'pending' }).eq('id', inviteId).eq('status', 'accepted');
402+
return fail('INTERNAL', 500, updateErr.message);
403+
}
404+
}
405+
// else: existing role already outranks or equals the invite role —
406+
// leave it untouched. The invite is still consumed (stays 'accepted').
358407
}
359408

360409
if (invite.invited_wallet) {
@@ -759,6 +808,12 @@ serve(async (req: Request) => {
759808
if (typeof signature !== 'string' || signature.length === 0) {
760809
return fail('BAD_REQUEST', 400, 'signature required');
761810
}
811+
// Shape-check BEFORE calling any verifier: a malformed signature is a bad
812+
// request, not a verifier outage — reject it as 401 here so it never
813+
// reaches the try/catch below and gets misread as 503 VERIFY_UNAVAILABLE.
814+
if (!isWellFormedSignature(signature)) {
815+
return fail('BAD_SIGNATURE', 401, 'signature malformed');
816+
}
762817

763818
const ts = Number(timestampSec);
764819
const ageSec = Math.abs(Date.now() / 1000 - ts);

supabase/migrations/20260801_account_membership_lockdown.sql

Lines changed: 0 additions & 67 deletions
This file was deleted.
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
-- 20260801_membership_functions.sql
2+
-- Additive only — SAFE to apply immediately, BEFORE the edge function deploys.
3+
--
4+
-- Split off the previously-combined 20260801_account_membership_lockdown.sql
5+
-- (deploy-order fix, security review round 2): the org-membership edge
6+
-- function's leave/remove_member handlers call delete_owner_guarded, so
7+
-- that function (and get_invite_by_token, and the account_owners
8+
-- case-insensitive uniqueness guard below) must exist BEFORE the edge
9+
-- function goes live — not gated behind the same "apply only after" fence
10+
-- as the policy lockdown in 20260802_account_membership_lockdown.sql, which
11+
-- would otherwise 500 every leave/remove_member call for the entire deploy
12+
-- window between "edge function live" and "this migration applied".
13+
14+
-- ── RPC: bearer-token invite lookup ─────────────────────────────────────────
15+
-- The ONLY anon-callable function in this pair of migrations. Knowledge of
16+
-- the token is the credential (bearer semantics); it takes no wallet
17+
-- parameter. All other membership reads/writes go through the
18+
-- signature-verified org-membership edge function (service role). Rule: no
19+
-- anon-granted function may take a wallet parameter as an authorization
20+
-- input — owner wallets are public in account_owners, so such a parameter
21+
-- is attacker-controlled.
22+
create or replace function public.get_invite_by_token(p_token text)
23+
returns invite_tokens
24+
language sql security definer set search_path = public, pg_temp stable as $$
25+
select * from invite_tokens where token = p_token limit 1;
26+
$$;
27+
28+
revoke all on function public.get_invite_by_token(text) from public;
29+
grant execute on function public.get_invite_by_token(text) to anon, authenticated;
30+
31+
-- ── Guarded owner deletion ───────────────────────────────────────────────────
32+
-- Closes a TOCTOU race in the org-membership edge function's leave/
33+
-- remove_member handlers: without this, two concurrent calls against the
34+
-- same account could both read owner_count > 1 and both proceed, leaving
35+
-- the account with zero owners. `for update` locks the account's
36+
-- account_owners rows before counting, serializing the count-then-delete
37+
-- against any other concurrent call on the same account_id. service_role
38+
-- grant ONLY — the edge function is the only caller, never anon/authenticated
39+
-- (same wallet-parameter-is-attacker-controlled rule as above, but here the
40+
-- caller already had to pass signature verification before this runs).
41+
create or replace function public.delete_owner_guarded(p_account_id uuid, p_wallet text)
42+
returns text
43+
language plpgsql security definer set search_path = public, pg_temp as $$
44+
declare v_deleted_role text; v_owner_count int;
45+
begin
46+
perform 1 from account_owners where account_id = p_account_id for update;
47+
select role into v_deleted_role from account_owners
48+
where account_id = p_account_id and lower(wallet_address) = lower(p_wallet);
49+
if v_deleted_role is null then return 'not_a_member'; end if;
50+
select count(*) into v_owner_count from account_owners
51+
where account_id = p_account_id and role = 'owner';
52+
if v_deleted_role = 'owner' and v_owner_count <= 1 then return 'last_owner'; end if;
53+
delete from account_owners
54+
where account_id = p_account_id and lower(wallet_address) = lower(p_wallet);
55+
return 'deleted';
56+
end $$;
57+
58+
revoke all on function public.delete_owner_guarded(uuid,text) from public;
59+
grant execute on function public.delete_owner_guarded(uuid,text) to service_role;
60+
61+
-- ── Case-insensitive wallet uniqueness guard ─────────────────────────────────
62+
-- At least one production account_owners row stores a checksummed wallet
63+
-- address (pre-dates this lockdown's lower-casing convention). The edge
64+
-- function's lookups now use .ilike() to tolerate it, but that only papers
65+
-- over the read side — if a write path ever inserted a *lowercase* sibling
66+
-- row for the same (account_id, wallet) pair (e.g. accept_invite), ilike
67+
-- lookups (and .maybeSingle() in particular) would start matching 2 rows
68+
-- and 500 forever. This index makes that structurally impossible going
69+
-- forward. Verified zero duplicate-case pairs exist in production today, so
70+
-- this is safe to apply immediately. Existing data is NOT normalized —
71+
-- account_owners.wallet_address has no FK cascade from users that would
72+
-- make a case rewrite safe to do blindly here.
73+
create unique index if not exists uq_account_owners_account_lower_wallet
74+
on account_owners (account_id, lower(wallet_address));
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
-- 20260802_account_membership_lockdown.sql
2+
-- Closes SECURITY_FINDINGS_2026-07-28 §1 (+ the forgeable/enumerable invite_tokens
3+
-- corollary) and §2. Anon-key writes on accounts/account_owners/invite_tokens are
4+
-- replaced by the functions in 20260801_membership_functions.sql (which must
5+
-- already be applied) + the signature-verified org-membership edge function
6+
-- (service role).
7+
-- ⚠️ APPLY ONLY AFTER the org-membership edge function and rewired clients are live.
8+
9+
-- ── Policy lockdown ─────────────────────────────────────────────────────────
10+
-- accounts: reads stay public; every write becomes service-role/RPC only.
11+
drop policy if exists "accounts_insert" on accounts; -- 005:25
12+
drop policy if exists "accounts_update" on accounts; -- 005:26 (finding §2)
13+
drop policy if exists "accounts_delete" on accounts; -- 20260504_accounts_delete_policy.sql:15
14+
15+
-- account_owners: reads stay public (keystone + UI rely on it); writes locked.
16+
drop policy if exists "account_owners_insert" on account_owners; -- 005:42 (finding §1)
17+
drop policy if exists "account_owners_delete" on account_owners; -- 005:43
18+
19+
-- invite_tokens: fully locked; bearer lookup goes through get_invite_by_token.
20+
drop policy if exists "invite_tokens_select" on invite_tokens; -- 011:58 (enumeration hole)
21+
drop policy if exists "invite_tokens_insert" on invite_tokens; -- 011:59 (forgery hole)
22+
drop policy if exists "invite_tokens_update" on invite_tokens; -- 011:60

0 commit comments

Comments
 (0)