POST /notify/:id accepted an attacker-supplied relay URL with a non-ws://
scheme (e.g. http://…). The URL was passed straight to @welshman/net's
load(), whose internal batcher throws 'Invalid relay url' asynchronously
(via setTimeout). That error escaped the route's try/catch as an unhandled
rejection, triggering process.exit(1) in src/index.ts — the entire server
died. Any registered subscriber could crash the service repeatedly.
Two fixes applied (either alone breaks the attack):
1. Validate relay scheme in the route handler before calling load()
(src/server.ts). Uses isRelayUrl() from @welshman/util, which accepts
only wss:// and ws:// schemes. Returns 400 immediately for invalid
URLs, preventing the bad URL from ever reaching the batcher.
2. Soften process.on('unhandledRejection') in src/index.ts to log and
continue instead of calling process.exit(1). Defence in depth: if any
other async error escapes a try/catch, the server stays up.
Test: test/notify-non-wss-relay-crash.test.ts — starts an ephemeral
server, creates a confirmed subscription, POSTs with http://127.0.0.1:9,
expects 400 with an error message.
Problem
-------
1. Re-clicking an already-confirmed confirmation link (e.g. /confirm?token=…)
returned from confirmSubscription because the SQL WHERE
clause required . The caller then threw an
ActionError('invalid or expired') which rendered the 'Email not confirmed'
error page — misleading for someone who had already confirmed.
2. A second PUT /subscription/email with the same email+frequency could
silently bypass the upsert path when getSubscriptionByPubkey found the
active row but updateSubscription returned it unchanged (email and
frequency matched). While the unique index prevented a true duplicate
INSERT, the code path was fragile and the regression test was missing.
Changes
-------
database.ts:
- confirmSubscription now returns { sub, alreadyConfirmed } | undefined.
First it tries the existing UPDATE (unconfirmed tokens only). If that
returns no rows, it looks up the key directly: if the row exists and is
already confirmed, returns { sub, alreadyConfirmed: true }. If the row
doesn't exist or is unsubscribed, returns undefined (invalid/expired).
- Exported new ConfirmResult type for callers.
actions.ts:
- confirmSubscriptionAction destructures the new return type.
- Only registers the cron job on fresh confirmation (not re-confirms).
- Returns the ConfirmResult so the route can distinguish the two cases.
server.ts:
- /confirm route checks result.alreadyConfirmed and renders
confirm-already.html instead of confirm-success.html.
pages/confirm-already.html:
- New page with title 'Email already confirmed' and an info message
explaining the address was already confirmed.
Tests:
- test/confirm-already-confirmed.test.ts — NEW (3 tests): first confirm
succeeds with alreadyConfirmed=false; second confirm returns
alreadyConfirmed=true; nonexistent token returns undefined.
- test/duplicate-subscription.test.ts — NEW (4 tests): full cycle of
register → confirm → re-register → assert one active row with
unchanged key, verifying the upsert is idempotent.
- Adapted 3 existing test files to destructure the new ConfirmResult.
The { brandName, brandAccent, brandLogo, settingsUrl } object was built
identically 3 times in the /confirm handler. Extract a brandingVars()
helper so it is defined once and reused via spread.
Closes mailship-90c
EVENT_VIEWER_URL.replace(/\/$/, '') was duplicated across:
- env.ts (BRAND_LOGO, 1x)
- server.ts (settingsUrl in confirm routes, 3x)
- mailer.ts (settingsUrl in sendConfirm/sendDigest, 2x)
Now trailing-slash normalization happens at the export source in env.ts,
so all consumers get a clean URL without ad-hoc stripping.
server.ts was building callback URLs from raw process.env.BASE_URL at
lines 175 and 217, while env.ts already validates and exports BASE_URL
as a typed constant. This adds BASE_URL to the import from ./env.js and
replaces both direct process.env references so the callback URL cannot
drift from the validated value.
Bug: when the event was not found at the relay, the handler returned
{ ok: true, skipped: true }, which did not match the documented contract
{ ok: true, stored: boolean } in README.md.
Fix: change the 'skipped' response to { ok: true, stored: false }, so the
response shape is consistent across all code paths.
- src/server.ts: changed line 287 from { ok: true, skipped: true } to
{ ok: true, stored: false }, with updated comment
- README.md: added a note documenting the skip case (stored: false)
- test/notify-response-shape.test.ts: new test that asserts stored=false
and that skipped is never present
Three browser-facing endpoints now require a kind-27235 HTTP auth event
(NIP-98) proving the caller controls the pubkey:
- GET /subscription/email — pubkey extracted from auth header instead
of query param; returns subscription for the authed pubkey.
- PUT /subscription/email — pubkey extracted from auth header instead
of trusting a client-supplied body field.
- DELETE /subscription/:key — verifies auth pubkey matches subscription
owner (returns 403 if mismatch).
Server-side: decode base64 'Nostr <b64>' Authorization header, JSON.parse,
check kind === 27235, verifyEvent (nostr-tools/pure), then check u /
method / payload tags against the request URL / method / body.
README updated to reflect 'implemented' auth (not 'planned').
Integration test updated to generate NIP-98 auth headers via a new helper
script (script/nip98-auth-header.mjs).
The server was setting Access-Control-Allow-Origin: * on every route,
including the unauthenticated GET /subscription/email which returns the
subscriber's email address. Any website could query known pubkeys and
harvest emails.
Changes:
- src/env.ts: require CORS_ORIGIN env var (fail closed, no wildcard)
- src/server.ts: scope CORS middleware to browser-facing routes only,
skip /notify (server-to-server), add Vary: Origin header, import
CORS_ORIGIN from env instead of defaulting to '*'
- .env.template: document new CORS_ORIGIN variable
- test/cors.test.sh: verify CORS on browser routes, no CORS on
server-to-server routes, Vary: Origin presence
Remove the browser admin SPA under web/ that let users log in via
a Nostr signer and manage subscription filters. The server is now
a headless API only.
Changes:
- Delete web/ directory entirely (SPA source, config, deps)
- Remove express.static('web/dist') serving from server.ts
- Simplify GET / handler to always return JSON (no fallback)
- Remove build:web from package.json build pipeline
- Remove web build steps from Dockerfile
- Remove web references from build-in-production.sh
Kept: core subscription/unsubscribe/confirm/notify backend and
transactional src/pages/*.html (part of email flow, not the UI).
Switch the subscribe endpoint from POST to an idempotent PUT so clients can
always upsert without a stateful lookup first.
- Add GET /subscription/email?pubkey= lookup so clients can avoid re-POSTing.
- insertSubscription no longer clears confirmed_at unless the email address
changes; a frequency change keeps the existing confirmation.
- registerSubscription only sends a confirmation email when the subscription
is new, unconfirmed, or the email address changed.
- Update integration test and README for the PUT endpoint.
- test/integration.sh: runs full E2E flow against fresh server
- pnpm test and pnpm test:server scripts added
- Root endpoint handles missing web UI gracefully (returns JSON)
- database.ts reads DATA_DIR env var for test isolation
- .gitignore updated for test artifacts