Skip to content

Latest commit

 

History

History
120 lines (95 loc) · 26.3 KB

security.md

File metadata and controls

120 lines (95 loc) · 26.3 KB

Security

How Potluck protects its data, what it stores, and where the known weak spots are. To report a vulnerability, follow SECURITY.md. The mechanics (grants, RLS, definer RPCs, storage policies, edge function auth) are in architecture.md.

Threat model

What is worth protecting

  • Money history. Who paid whom, how much, and when. Tampering with it moves real debts between friends.
  • The social graph. Who is in which group, who is friends with whom, who blocked whom.
  • Plans. Availability grids and event locations say where people will be and when.
  • Receipts. Photos can carry addresses, card digits and location metadata. An expense holds up to five (photos and PDFs), all in the private receipts bucket.
  • Account data. Email addresses and display names.

Who we defend against

Actor Can Stopped by
Anyone on the internet call the API with the publishable key grants: anon can read app_config, call invite_preview, invite_preview_v2, friend_invite_preview (each needs a link code), email_unsubscribe (needs a signed token) and the two guest link RPCs (need a guest link code), and read the public buckets. Nothing else
A signed-in stranger call every RPC, query every table RLS on every table, require_member() in every group RPC
A group member overreaching edit others' expenses, promote themselves, delete the group role checks inside RPCs and policies, the role matrix in architecture.md
A removed or departed member keep reading the group membership is checked on every query. Realtime re-applies RLS per change
Someone holding an invite link see the group name, member count, first names; join expiry, max uses, revocation, optional email binding
Someone holding a guest link see one plan's title, window, the link creator's first name and how many people are free per slot (once it has a time, only guests who answered see it); add or edit one answer per browser expiry (24 hours to 30 days), revocation, a guest cap per link, plan and group state checked on every call, rate limits, the token kept as a sha256 hash, removed answers blocked
Someone holding a calendar feed URL read the owner's scheduled events 32-character random token, rotatable with calendar_feed_token(true)
A client with a tampered bundle send any payload to any RPC the server derives splits, FX rates, payer of a settlement and created_by itself
Someone calling edge functions directly trigger push or FX sync x-potluck-secret shared secret, constant-time compare
Someone guessing unsubscribe links turn off another person's emails each link carries an HMAC of user id and category; the key stays in private.signing_keys. A preview shows the address masked
Someone calling email_outbox_report mark emails sent or failed executable by the service role only
Someone who learned a password sign in as that person two-step sign in when it is on: the database refuses the half signed in session (below)
Someone taking a seat that is not theirs claim another person's placeholder and its history a claim link bound to an email only works for an account with that email, confirmed (email_not_verified); automatic claiming needs a confirmed matching email; an unbound link is single use and only its holder has it; nobody already in the group can claim (ADR 19)
A member spamming addresses make Potluck email strangers by adding placeholders one invite plus one reminder per placeholder, at most 3 addresses per placeholder and 20 emailed invites a day per person; every email has one-click unsubscribe
Someone holding a friend link see the inviter's first name and face; become their friend email links are single use and bound to a confirmed email; the personal link can be rotated; blocks win and read like a dead link (no name shown)
Someone who was blocked find out, or keep reaching the person who blocked them every refusal reads like a revoked link or a stranger (invite_revoked, not_a_friend, a plain no from can_split_with, previews with no name); notifications whose actor the recipient blocked are dropped, shared groups included; a seat's invite and reminder are not sent to an account with a block with the sender; in a 1:1 tab the blocked side can no longer change money

Out of scope: a compromised Supabase or Cloudflare account, a malicious maintainer, and a stolen unlocked phone with an active session.

Known gaps

These are real today. Each should become an issue.

  • Closed accounts keep their calendar feed. delete_my_account() does not delete the calendar_feed_tokens row, and ics-feed does not check profiles.deleted_at. A closed account that still has an open balance in a group keeps that group's membership, so its old feed URL keeps listing that group's events.
  • Removed attachments can linger. Taking a file off an expense deletes it only when the person removing it uploaded it or is an admin (the bucket's delete rule); otherwise the file stays in the group folder, readable by members with nothing pointing at it, until the daily receipts-sweep deletes it once it is a day old. Deleting an expense keeps its files (it can be restored).
  • Avatar files outlive the account. The profile's avatar_path is cleared, but the file stays in the public avatars bucket, at a URL anyone who saw it can still open.
  • Admin invite rule mismatch. can() in core lets admins create admin invites. The server refuses, so this is a UI bug, not a hole.
  • Previews use production data. PR preview builds point at the production Supabase project. Anyone who can open a PR that gets a preview deploy can ship code that runs against real accounts. Today previews only build for branches in this repository, because fork PRs do not get the Cloudflare secret.
  • Edge function CORS is *. Harmless for the secret- and token-protected functions, and delete-account requires a JWT, but it is wider than the app needs.
  • Invite previews show first names to anyone with the code. That is the point of the join page, but it means an invite link is a small leak of who is in the group. A claim link also shows the saved seat's first name and its masked email.
  • A placeholder's name and masked email are visible to its group. The full address is readable by no client; the person who typed it in knows it.
  • Guest links are open doors until they close. Anyone with the code can add names and times up to the link's cap, and the per link limits (20 new guests per 10 minutes, 60 saves per guest per hour) are counted in the database, not per IP. Organizers clean up with remove and turn off. The preview itself is a read and is not rate limited.
  • Signing out elsewhere is not instant. "Sign out everywhere" and a password change revoke every other refresh token, but an access token already handed out keeps working until it expires (up to an hour, jwt_expiry).
  • A fresh stolen session can change the password without the old one. Within a day of signing in, Supabase sets a new password for the session alone; the Me page asks for the current password first, but the API does not unless the project turns on "require current password" (a dashboard setting, not in config.toml; the app already sends current_password). Older sessions need an emailed code (secure_password_change). Either way the account gets a "password changed" email.
  • A half signed in session can edit its own user metadata. Supabase Auth allows it at aal1 and the database never sees that write. Nothing there grants access (see ADR 13).

Sign in

  • Methods. Email and password, a magic link, and Google or Apple once their flags are on. Production requires a confirmed email before the first sign in.
  • Two-step sign in. An authenticator app (TOTP) from the Me page. Signing in with any method then asks for a code. The database enforces it (ADR 13): until the code is in, every REST and RPC call fails with mfa_required, Storage reads and writes nothing in any bucket, and Realtime sends nothing. Supabase Auth refuses that session's email, password and factor changes. Turning it off needs a fresh code.
  • Recovery codes. Ten one-time codes are shown once at setup (and again whenever they are replaced); the dialog only closes once the person says they saved them. They are stored as SHA-256 hashes, which is enough because they are random. One code, together with the first sign in step, removes the authenticator so the person can set it up again. Five tries per account per fifteen minutes, then too_many_attempts. Turning two-step sign in off clears them. Without the codes, an operator has to remove the factor by hand after checking who is asking.
  • Changing the email sends a link to the old and to the new address (double_confirm_changes); in production the switch happens only when both are opened, so a stolen session cannot quietly move the account to another inbox. (The local stack auto-confirms email, and Supabase then takes either link as enough.) A pending change can be called off from Me (cancel-email-change), which voids its links.
  • Changing the password asks for the current one (accounts that only ever used Google or Apple set a first password without it), and for an emailed code when the session is more than a day old (secure_password_change). Then every other session is signed out.
  • Security emails. Supabase emails the account when its password or email changes and when an authenticator is added or removed ([auth.email.notification.*]). A recovery code removes the authenticator with the admin API, which sends no email, so mfa-recover files an account.mfa_removed notice instead: inbox and push right away, email through the app's outbox once email notifications are on. Its security category is required: it has no setting, set_notification_pref refuses it, unsubscribe links (one category or all) leave it on, and these emails carry no unsubscribe links or headers.
  • Sign out everywhere revokes every session of the account, this one included.

Data handling

What is stored

Data Where Visible to
Email, password hash, sessions auth.users (Supabase Auth) nobody through the API. Supabase operators
Display name, avatar, time zone, locale, default currency profiles you, people you share a group with or who left one of your groups, friends
Groups, members, invites groups, group_members, group_invites members. Invites only to admins and the invite's creator
People not on Potluck yet: name, invite email profiles, placeholders members of that group (the email masked); the address is used only for their invite and reminder, and deleted when they claim the seat or the group goes
Friend links friend_invites only you
Blocks, reports blocks, reports only you (the Potluck team reads reports)
Expenses, splits, settlements, comments money tables, comments members of that group
Availability, RSVPs, events plans tables members of that group
Guest names and times event_guests, event_guest_availability members of that group. Guests see only counts per slot, never names. The guest's token is stored as a sha256 hash nobody can read
Busy blocks, calendar links busy_blocks, calendar_connections only you. OAuth tokens would live in Vault (feature is off)
Notifications, push subscriptions notifications, push_tokens only you
Notification settings notification_prefs only you
Receipts and other attachments (up to five per expense) private receipts bucket, expense_attachments members of the group (authenticated download or signed URL)
Avatars, group covers public buckets anyone with the URL
Authenticator secrets, recovery code hashes auth.mfa_factors, private.mfa_recovery_codes nobody through the API. Supabase operators

There is no analytics or tracking code in the app.

What leaves the system

  • Push messages go through the browser vendor's push service (Google, Mozilla, Apple). Web push payloads are encrypted for the subscribing browser, so the service cannot read the title and body, which may include an expense description or a comment.
  • Receipt scanning sends nothing anywhere. The photo is read in a web worker on the device (tesseract.js); the reader's code and English model are downloaded from Potluck's own origin, not a CDN. The photo leaves the device only if the person keeps it attached and saves. Photos are re-encoded to JPEG before upload, which drops their EXIF metadata (location included); HEIC photos the browser cannot decode, and PDFs, are uploaded as they are.
  • FX sync sends nothing about users to Frankfurter. It requests public reference rates.
  • Exports and backups (Me, Your data; group settings) are built in the browser and saved to the person's device; nothing is sent elsewhere. export_my_data() returns only groups the caller is in now, with the names of everyone those groups mention, and the caller's own sign in email; anon cannot call it. Imports run as the importing member with every check create_expense_v2 makes (ADR 18).
  • Calendar feeds contain event titles, descriptions, locations and group names for whoever has the URL.
  • Auth emails go out through Supabase's SMTP.
  • Notification emails (off until app_config.email_notifications is on) go through the configured provider (Resend). They carry the recipient's address, names, amounts and comment excerpts, so the provider can read them. That includes invites to people who are not on Potluck yet, sent to the address a group mate typed in.
  • Friend invites by email are not sent by Potluck: the app opens the inviter's own mail app with the link.

Account deletion

Users close their account from the app, which calls the delete-account edge function with their JWT. The flow is drawn in sequences.md.

What is removed: push subscriptions, calendar links, busy blocks, notifications, availability, availability requests, RSVPs, friendships, blocks, and memberships in groups where the balance is zero. Open invites the user created are revoked, except the claim links of seats they added, which stay usable. Groups they owned pass to the longest-standing admin, else the longest-standing member, and are deleted if nobody else is left. The auth user is soft deleted, so the email can no longer sign in.

What stays, and why: the profile row stays as a tombstone named "Former member" with no avatar, because other people's expenses and settlements reference it and their balances must not change. Memberships with an open balance stay so nobody's debt disappears. Expenses, splits, settlements, comments and activity the user took part in stay in their groups.

Closed accounts get account_deleted from every RPC and never receive notifications.

Secrets policy

  • Never commit secrets. .env and .env.* are git-ignored (except .env.example, which holds no values). The PR template asks every author to check.
  • The publishable key is public. It ships in the web bundle and in VITE_* variables. That is fine, because RLS and grants decide what it can do. Never rely on it being secret.
  • The service role key never leaves Supabase. Only edge functions use it, through _shared/admin.ts, and only after they have authorized the caller. It is never in GitHub, in .env files or in the web app.
  • EDGE_SECRET lives in two places: the edge function secrets and the Vault entry edge_secret. Rotate both together (deployment.md).
  • RESEND_API_KEY is an edge function secret only. Without it no notification email is sent.
  • The unsubscribe signing key is generated by a migration into private.signing_keys and never leaves the database. Replacing that row breaks every unsubscribe link already sent.
  • VAPID private key is an edge function secret only. Rotating it invalidates every existing push subscription, and browsers have to subscribe again.
  • CI secrets (listed in deployment.md) are GitHub Actions secrets, scoped as narrowly as the providers allow. Fork PRs do not receive them.
  • If a secret leaks, rotate it first, then investigate. Report leaks in this repository through private vulnerability reporting.