Skip to content

fix(discord): stop the bot DM endpoint relaying anyone's text to anyone - #419

Open
QSchlegel wants to merge 1 commit into
preprodfrom
claude/daily-2026-10-21-discord-relay-auth
Open

QSchlegel wants to merge 1 commit into
preprodfrom
claude/daily-2026-10-21-discord-relay-auth

Conversation

@QSchlegel

Copy link
Copy Markdown
Collaborator

What and why

POST /api/discord/send-message accepted { discordIds, message } with no session, no validation and no rate limit. Anyone could have the project's Discord bot DM any Discord user any text. That makes it a phishing relay in the project's name. GET /api/discord/get-user put an unchecked discordId into a bot-authenticated Discord URL (for example @me/guilds) and passed Discord's error body back to the caller.

send-message

  • Session required. Without a wallet session the request gets 401.
  • Caller must belong to the wallet. The caller has to be a signer or owner of walletId, the same rule as assertWalletAccess. Otherwise 403. An unknown wallet gets 404.
  • Recipients are limited. Every id must be a Discord id registered to a signer of that wallet. If any id is not, the whole request gets 403.
  • The server writes the message. Text comes from four fixed templates chosen by kind (newTransaction, ccVote, transactionReminder, datumReminder), and any text the caller sends is ignored. This matters because "co-signer only" is not enough on its own: anyone can create a wallet that lists strangers' public addresses as signers.
    • The wallet name goes in inline code, with backticks, bidi and zero-width characters removed. Discord then shows no links, mentions or markdown from it.
    • Links use getSiteUrl(), the same as email notifications.
  • Rate limits. 15/min per IP and 10/min per signing address.
  • Input checks:
    • ids must be snowflakes, at most 50
    • walletId must match [A-Za-z0-9_-]{1,64}
    • an empty recipient list returns 200 and does nothing, as before

get-user

  • Checks the id is a snowflake.
  • Rate-limited.
  • Returns only id, avatar and discriminator.
  • Discord errors come back as 502 (404 stays 404), without Discord's error body.

Callers

  • All four callers now send (walletId, kind, discordIds). The old message texts and link paths are kept, now as the server templates.
  • voteCC now treats the notification as best-effort, like new-transaction. A Discord failure no longer blocks the redirect after the vote transaction has been created.

Tests

  • src/__tests__/discordRelay.test.ts: 33 tests, all passing. Against the old handlers, 16 of the first 23 failed.
  • npx tsc --noEmit: clean.
  • node scripts/run-tests.mjs:
    • CJS: 1689 passed, 2 skipped (151 suites)
    • ESM: 97 passed (11 suites)
  • next build --webpack and check-bundle-budget --report: within budget, no change to light routes.
  • An independent review agent read the diff twice. It found that free text plus "any co-signer" could still be abused through self-created wallets, which led to the server-side templates, the per-signer limit and the voteCC best-effort change. Its remaining notes are listed below.

Not verified / for the maintainer

  • Preprod links. Links used to come from window.location.origin and now depend on NOTIFICATION_LINK_BASE_URL (or NEXT_PUBLIC_SITE_URL). If neither is set on preprod, preprod DMs will link to production. Email notifications already depend on this.
  • Not tested end-to-end. No live Discord DM was sent; the bot token is not available here.
  • Remaining spam risk. wallet.createWallet accepts any signersAddresses. Someone can still create a wallet that lists a victim and send them up to 10/min of fixed-text "new transaction" DMs that link to our own site. Closing this fully would mean checking that a pending transaction exists, or requiring signers to opt in. That is a product decision, so it is not in this PR.
  • All-or-nothing 403. If a signer re-links Discord while another signer's page still holds a cached id list, notifications to every signer fail until the page is reloaded.

Generated by Claude Code

POST /api/discord/send-message took {discordIds, message} with no session,
so anyone could make the project's Discord bot DM any user any text — a
ready-made phishing relay in the project's name. GET /api/discord/get-user
interpolated an unchecked id into a bot-authenticated Discord URL and echoed
Discord's error body.

send-message now:
- requires a wallet session, and the caller must be a signer or owner of
  `walletId` (same rule as assertWalletAccess)
- only reaches Discord ids registered to that wallet's signers (403 otherwise)
- writes the text server-side from four fixed templates (`kind`), with the
  wallet name in inline code so it can carry no link or markup; links use
  getSiteUrl() like email notifications
- is rate-limited per IP (15/min) and per signer (10/min), since anyone can
  create a wallet listing a stranger's public address as a signer

get-user validates the id as a snowflake, rate-limits, and returns only
id/avatar/discriminator. voteCC's notification is now best-effort like
new-transaction's, so a Discord failure no longer skips the redirect.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JLLy41afbDVhpZUhmc61mX
@vercel

vercel Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
multisig Ready Ready Preview Oct 21, 2026 5:16am UTC

Request Review

QSchlegel commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator Author

playwright-browser failed on d51dff4: 1 of 39 tests, rejected-signing.spec.ts:148. I don't think this diff caused it.

  • Where it failed: the test's setup request POST /api/trpc/wallet.createWallet (e2e/helpers/apiHelpers.ts:41) got read ECONNRESET.
  • Not in this diff: this PR doesn't touch createWallet. The failure came before the test reached any Discord code.
  • App kept running: the remaining 21 tests passed against the same webapp afterwards.

Re-run needed: I can't re-run the job from here; the API returned 403. A maintainer re-run of the failed job would settle it. If it fails the same way again, I'll treat it as a real failure and look into it.


Generated by Claude Code

QSchlegel commented Oct 22, 2026 •

Copy link
Copy Markdown
Collaborator Author

playwright-browser is red on this head because of one test, rejected-signing.spec.ts:148 ("signTx rejection during approval adds no signature"). It failed with apiRequestContext.post: read ECONNRESET on its first request, POST /api/trpc/wallet.createWallet (createThrowawayWallet, apiHelpers.ts:41). That happens before any code this PR changes runs: the PR doesn't touch wallet.createWallet or the e2e helpers, and the other 38 specs passed, including the sibling test that calls the same helper. The test never reached the server, so this is not a failure in the PR's code. No fix exists for it yet.

I tried to re-run the failed job and got a 403, so this integration can't re-run it. A maintainer needs to click "Re-run failed jobs" on run 39548422012. If the test fails again, treat it as a real failure.


Generated by Claude Code

This branch was successfully deployed

1 active deployment
Preview — d51dff4a Deployed Oct 21, 2026 by vercel[bot]
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.

2 participants