Skip to content

fix(security): harden log_sms_notification and move SMS path to service role - #249

Merged
ralyodio merged 1 commit into
masterfrom
security/sms-notification-hardening
Aug 14, 2026
Merged

fix(security): harden log_sms_notification and move SMS path to service role#249
ralyodio merged 1 commit into
masterfrom
security/sms-notification-hardening

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

Closes the last function that was still abusable by an authenticated user, completing the remediation started in #244.

Already applied to production as 20260814040456. Full suite green locally: 81 files, 483 tests.

The problem

log_sms_notification inserts into sms_notifications with a caller-supplied phone number and message content, and had no checks whatsoever. Any authenticated user could write arbitrary rows.

It can't take a plain auth.uid() ownership binding, because it's legitimately cross-user — the sender logs a notification on behalf of the recipient. That's why it was left open in #248.

The fix

Guard on conversation membership instead: the caller must be an active participant, and the user being logged for must be a participant too (so arbitrary phone numbers can't be attached to a conversation).

Plus the SMS notification path in lib/websocket/handlers/messages.js now uses the service-role client. Reading other participants' phone numbers and writing rows on their behalf is server-side work; passing the caller's client is precisely what forced this function and get_inactive_participants to stay reachable by authenticated.

Why a guard and not a REVOKE

Railway deploys on merge, not in lockstep with migrations. A REVOKE applied now would break SMS notifications in the window before this code is live — and silently, since the call sits inside a try. The membership guard is correct in either order: service_role has a NULL auth.uid() and passes through, authenticated must be a real participant.

Follow-up once this is deployed: log_sms_notification and get_inactive_participants can both be locked to service_role outright, which also removes the residual — a genuine participant can still write an arbitrary phone/content pair for another participant of the same conversation.

Coverage gap worth knowing

tests/sms-notification-service.test.js is in vitest's exclude list, so this path has no automated coverage — it doesn't run in CI either. The change is verified by reading the call sites, not by tests.

🤖 Generated with Claude Code

…ce role

Closes the last function that was still abusable by an authenticated
user. log_sms_notification inserts into sms_notifications with a caller
supplied phone number and message content, and had no checks at all.

It cannot take a plain auth.uid() ownership binding because it is
legitimately cross-user -- the sender logs a notification on behalf of
the recipient. So the guard is conversation membership: the caller must
be an active participant, and the user being logged for must be a
participant too, which stops arbitrary phone numbers being attached to a
conversation.

Also switches the SMS notification path in the websocket handler to the
service-role client. Reading other participants' phone numbers and
writing rows on their behalf is server-side work, and passing the
caller's client is what forced this function and
get_inactive_participants to stay reachable by `authenticated`.

The guard is deliberately used instead of a REVOKE: Railway deploys on
merge rather than in lockstep with migrations, so a revoke would break
SMS notifications in the window before this code is live. The guard is
correct in either order. Once deployed, both functions can be locked to
service_role outright.

Note tests/sms-notification-service.test.js is in the vitest exclude
list, so this path has no automated coverage in CI.

Already applied to production as 20260814040456.
Full suite: 81 files, 483 tests, all passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

ThreatCrush Security Scan

90 finding(s)

HIGH/CRITICAL: 4 | MEDIUM: 10 | LOW: 76

Severity Rule Location
HIGH secret-generic-credential scripts/supabase-email.sh:21
HIGH secret-generic-credential scripts/supabase-twilio.sh:32
HIGH secret-generic-credential scripts/supabase-twilio.sh:47
HIGH js-host-header-trust src/lib/websocket/middleware/auth.js:31
MEDIUM manifest-install-lifecycle-script package.json:33
MEDIUM js-shell-exec-interpolation scripts/convert-routes.mjs:9
MEDIUM redos-nested-quantifier src/app/api/profile/update/route.js:73
MEDIUM js-unescaped-html-sink src/app/blog/[slug]/page.jsx:38
MEDIUM js-unescaped-html-sink src/app/blog/[slug]/page.jsx:66
MEDIUM js-unescaped-html-sink src/app/faq/page.jsx:57
MEDIUM js-unescaped-html-sink src/app/layout.jsx:138
MEDIUM js-unescaped-html-sink src/app/layout.jsx:142
MEDIUM js-unescaped-html-sink src/app/page.jsx:47
MEDIUM js-unescaped-html-sink src/lib/components/chat/MessageItem.jsx:57
LOW secret-generic-credential src/app/api/auth/invite-anon/route.test.js:24
LOW secret-generic-credential src/app/api/auth/register-anon/route.test.js:32
LOW secret-jwt src/app/api/auth/upload-avatar/route.test.js:27
LOW secret-generic-credential src/app/api/auth/upload-avatar/route.test.js:27
LOW secret-generic-credential src/app/api/profile/update/route.test.js:63
LOW secret-generic-credential src/lib/websocket/middleware/auth.test.js:47
LOW secret-generic-credential src/lib/websocket/middleware/auth.test.js:64
LOW secret-generic-credential tests/auth-key-generation.test.js:109
LOW secret-generic-credential tests/auth-key-generation.test.js:110
LOW secret-generic-credential tests/auth-key-generation.test.js:157
LOW secret-generic-credential tests/auth-key-generation.test.js:158
LOW secret-generic-credential tests/auth-key-generation.test.js:207
LOW secret-generic-credential tests/auth-key-generation.test.js:208
LOW secret-generic-credential tests/auth-key-generation.test.js:250
LOW secret-generic-credential tests/auth-key-generation.test.js:251
LOW secret-generic-credential tests/auth-key-generation.test.js:293
LOW secret-generic-credential tests/auth-key-generation.test.js:294
LOW secret-generic-credential tests/auth-key-generation.test.js:340
LOW secret-generic-credential tests/auth-key-generation.test.js:341
LOW secret-generic-credential tests/auth-key-generation.test.js:381
LOW secret-generic-credential tests/auth-key-generation.test.js:382
LOW secret-generic-credential tests/chat-archive.test.js:22
LOW secret-jwt tests/debug-sms.js:10
LOW secret-generic-credential tests/gpg-private-key-export.test.js:63
LOW secret-generic-credential tests/gpg-private-key-export.test.js:64
LOW secret-generic-credential tests/nuclear-delete.test.js:19
LOW secret-generic-credential tests/nuclear-delete.test.js:89
LOW secret-generic-credential tests/nuclear-delete.test.js:97
LOW secret-generic-credential tests/password-security-fix-verification.test.js:51
LOW secret-generic-credential tests/password-security-fix-verification.test.js:75
LOW secret-generic-credential tests/password-security-fix-verification.test.js:76
LOW secret-generic-credential tests/password-security-fix-verification.test.js:102
LOW secret-generic-credential tests/password-security-fix-verification.test.js:156
LOW secret-generic-credential tests/password-security-fix-verification.test.js:174
LOW secret-generic-credential tests/phone-auth-fix.test.js:49
LOW secret-generic-credential tests/phone-auth-fix.test.js:75

…and 40 more. Full results in the Security tab.

Snippets are redacted; ThreatCrush never prints matched credential material.

@ralyodio
ralyodio merged commit 4a3b54b into master Aug 14, 2026
10 checks passed
@ralyodio
ralyodio deleted the security/sms-notification-hardening branch August 14, 2026 04:07
ralyodio added a commit that referenced this pull request Aug 15, 2026
…250)

* fix(security): scope cleanup endpoints and harden backup PIN storage

Closes the two GitHub security advisories still open against this repo.
The other four were remediated in #244-#249.

GHSA-2wqx-qppx-4rgf (High) -- cleanup endpoints:
/api/cleanup/legacy-messages and /api/cleanup/empty-conversations
authenticated with getSession(), which reads the cookie without
re-validating the JWT against the auth server, and then operated
platform-wide: legacy-messages selected and deleted up to 1000 matching
messages with no user filter at all, and empty-conversations enumerated
every conversation in the table. Both now use getUser(), resolve the
internal user id, and scope every query to the caller -- matching the
pattern already used by cleanup/legacy-keys.

GHSA-jpfm-vrpc-p6rr (Medium) -- backup PIN:
PINs were hashed with unsalted SHA-256, while users_select_authenticated
lets any logged-in account read every row of `users`, so one throwaway
signup could dump every backup_pin_hash and reverse the 6-12 digit
keyspace with a precomputed table.

Narrowing the RLS policy alone does not fix this -- the app legitimately
reads other users' rows and several callers do select('*'), which a
column-level REVOKE would break. So the credential column moves out of
`users` into user_backup_pins, a table anon and authenticated hold no
privileges on. New PINs are derived with scrypt (N=16384, r=8, p=1) over
a per-user random salt.

The old digests are discarded rather than relocated: nothing verified
them server-side, they only ever backed the hasPin boolean, and being
reversible is the entire problem. Row existence preserves hasPin for the
5 affected users.

The column drop is a separate migration (20260815120100) because it must
not land until this code is deployed. 20260815120000 is additive and is
already applied to prod.

* chore(db): align migration filenames with the applied prod versions

MCP apply_migration restamps the version it records; prod logged
20260815204334, not the 20260815120000 in the filename. Rename so
supabase db push does not rerun it, and renumber the follow-up drop
migration to stay after it.
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