Skip to content

fix(security): close conversation hijacking, profile mass assignment, avatar MIME trust - #254

Merged
ralyodio merged 1 commit into
masterfrom
fix/security-advisories-wave3
Aug 16, 2026
Merged

fix(security): close conversation hijacking, profile mass assignment, avatar MIME trust#254
ralyodio merged 1 commit into
masterfrom
fix/security-advisories-wave3

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

Wave 3. Three findings from the consolidated assessment (GHSA-3hqc-9v44-j37g) that were still live in production, each verified against prod before writing the fix.

ID Finding Fix
V-007 UPDATE policy on conversations began (created_by IS NULL) AND (auth.uid() IS NOT NULL) — any authenticated account could take over any conversation with a NULL creator Creator-only policy, with a WITH CHECK so the row can't be handed off
V-009 Users can update own profile had no WITH CHECK and no column restriction — a user could rewrite any column of their own row, including phone_number and unique_identifier BEFORE UPDATE trigger pinning the identity columns
NEW-08 Avatar upload trusted the client's Content-Type Magic-byte sniffing drives both extension and stored content-type

Why a trigger and not column-level REVOKE for V-009

A REVOKE has to enumerate every writable column correctly, and any column added later defaults back to writable. The trigger states the invariant once and fails closed for columns nobody thought about. service_role is exempt because every legitimate writer of those columns is server-side — /api/auth/salt, /api/auth/verify-sms, the CoinPay callback, /api/auth/register-anon. The authenticated role legitimately writes only bio, website, updated_at and sms_notifications_enabled, none of which are pinned.

Dead policies dropped

Four UPDATE policies and one SELECT policy compare auth.uid() = id — the IA-040 identity domain drift, where auth.uid() is the Auth UUID and users.id is the internal key. Verified against production: 0 of 86 rows have id = auth_user_id, so these have never matched a row and grant nothing.

Still open from the umbrella

Not addressed here: V-011, V-012, V-013, V-022 (invitation quota / recipient validation), V-014 (recursive realtime subscription), V-023 (SSE spam), V-025 (register-anon atomicity), NEW-05 (webhook SSRF via DNS rebinding), and the remaining lows. V-017 and V-024 turned out to be moot — get_user_conversations_enhanced, get_user_groups and get_group_rooms do not exist in production.

Verification

  • 500 tests pass across 84 files, including new coverage for the image sniffer.
  • pnpm build clean.

🤖 Generated with Claude Code

… avatar MIME trust

Three findings from the consolidated assessment (GHSA-3hqc-9v44-j37g) that were
still live, verified against production before writing the fix.

V-007 -- orphaned conversation hijacking. The UPDATE policy on `conversations`
began `(created_by IS NULL) AND (auth.uid() IS NOT NULL)`, so any authenticated
account could take over any conversation with a NULL creator. Replaced with a
creator-only policy that also carries a WITH CHECK, so the row cannot be handed
to someone else on the way out.

V-009 -- mass assignment on `users`. `Users can update own profile` had no
WITH CHECK and no column restriction, letting a user rewrite any column of their
own row including `phone_number` and `unique_identifier`. Enforced with a
BEFORE UPDATE trigger rather than column-level REVOKE: a REVOKE has to enumerate
every writable column correctly and any column added later defaults back to
writable, whereas the trigger states the invariant once and fails closed.
service_role is exempt, since every legitimate writer of those columns is
server-side (salt, verify-sms, CoinPay callback, register-anon).

NEW-08 -- avatar upload trusted the client's Content-Type, so arbitrary content
could be stored in the public avatars bucket and served back as an image. Now
sniffed from magic bytes, and the detected type drives both the extension and the
stored content-type.

Also drops four UPDATE policies and one SELECT policy comparing `auth.uid() = id`
(IA-040 identity domain drift: auth.uid() is the Auth UUID, users.id is the
internal key). Confirmed against production that 0 of 86 rows have
id = auth_user_id, so these have never matched a row and grant nothing.

Not fixed here and still open from the umbrella: V-011/V-012/V-013/V-022
(invitation quota and recipient validation), V-014, V-023, V-025, NEW-05 (webhook
SSRF), and the remaining lows.

500 tests pass; build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

ThreatCrush Security Scan

91 finding(s)

HIGH/CRITICAL: 5 | 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
HIGH js-cors-origin-reflected src/lib/websocket/server.js:121
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:60
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

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

Snippets are redacted; ThreatCrush never prints matched credential material.

@ralyodio
ralyodio merged commit e1f172b into master Aug 16, 2026
10 checks passed
@ralyodio
ralyodio deleted the fix/security-advisories-wave3 branch August 16, 2026 02:54
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