From fdc4579aa76ab501aa52b118ed2305e5e835f088 Mon Sep 17 00:00:00 2001 From: Agent Date: Thu, 10 Sep 2026 11:29:18 -0400 Subject: [PATCH 1/2] fix: scope CORS to browser routes only, require CORS_ORIGIN (fail closed) 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 --- .env.template | 4 ++ src/env.ts | 2 + src/server.ts | 18 ++++-- test/cors.test.sh | 156 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 175 insertions(+), 5 deletions(-) create mode 100644 test/cors.test.sh diff --git a/.env.template b/.env.template index 9112de5..483338c 100644 --- a/.env.template +++ b/.env.template @@ -12,6 +12,10 @@ BRAND_LOGO= INDEXER_RELAYS=purplepag.es,relay.damus.io,relay.nostr.band DEFAULT_RELAYS=relay.damus.io,nos.lol SEARCH_RELAYS=relay.nostr.band +# CORS_ORIGIN must be set to the exact origin your browser client runs on +# (e.g. https://app.example.com). There is no wildcard fallback — the server +# will refuse to start without it. +CORS_ORIGIN= POSTMARK_API_KEY= POSTMARK_SENDER_ADDRESS= PORT=4738 diff --git a/src/env.ts b/src/env.ts index 3588110..3892bcd 100644 --- a/src/env.ts +++ b/src/env.ts @@ -17,6 +17,7 @@ if (!process.env.DEFAULT_RELAYS) throw new Error('DEFAULT_RELAYS is not defined. if (!process.env.INDEXER_RELAYS) throw new Error('INDEXER_RELAYS is not defined.') if (!process.env.SEARCH_RELAYS) throw new Error('SEARCH_RELAYS is not defined.') if (!process.env.PORT) throw new Error('PORT is not defined.') +if (!process.env.CORS_ORIGIN) throw new Error('CORS_ORIGIN is not defined.') if (!process.env.BASE_URL) throw new Error('BASE_URL is not defined.') export const MAILSHIP_URL = process.env.MAILSHIP_URL @@ -31,6 +32,7 @@ export const appSigner = Nip01Signer.fromSecret(process.env.MAILSHIP_SECRET) export const DEFAULT_RELAYS = process.env.DEFAULT_RELAYS.split(',').map(normalizeRelayUrl) export const INDEXER_RELAYS = process.env.INDEXER_RELAYS.split(',').map(normalizeRelayUrl) export const SEARCH_RELAYS = process.env.SEARCH_RELAYS.split(',').map(normalizeRelayUrl) +export const CORS_ORIGIN = process.env.CORS_ORIGIN export const PORT = process.env.PORT export const SMTP_HOST = process.env.SMTP_HOST export const SMTP_PORT = process.env.SMTP_PORT diff --git a/src/server.ts b/src/server.ts index eb3fe92..6dbbaf9 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,7 +1,7 @@ import { instrument } from 'succinct-async' import express, { Request, Response, NextFunction } from 'express' import rateLimit from 'express-rate-limit' -import { appSigner, BRAND_NAME, BRAND_ACCENT, BRAND_LOGO, EVENT_VIEWER_URL } from './env.js' +import { appSigner, BRAND_NAME, BRAND_ACCENT, BRAND_LOGO, EVENT_VIEWER_URL, CORS_ORIGIN } from './env.js' import { render } from './templates.js' import { confirmSubscriptionAction, unsubscribeAction, registerSubscription, ActionError } from './actions.js' import { getSubscriptionById, insertEvent, getSubscriptionByKey, getSubscriptionByPubkey } from './database.js' @@ -13,23 +13,31 @@ import { verifyEvent } from 'nostr-tools/pure' export const server: express.Application = express() +// CORS middleware for browser-facing routes only. // The browser hits /subscription with an Authorization header and Content-Type: // application/json, which triggers a CORS preflight. Answer it and allow the // configured origin so the client can register. -const corsOrigin = process.env.CORS_ORIGIN ?? '*' +// Server-to-server routes (/notify) intentionally do NOT get CORS headers. +const corsMiddleware = (req: Request, res: Response, next: NextFunction) => { + // Skip server-to-server routes + if (req.path.startsWith('/notify')) { + return next() + } -server.use((req: Request, res: Response, next: NextFunction) => { - res.setHeader('Access-Control-Allow-Origin', corsOrigin) + res.setHeader('Access-Control-Allow-Origin', CORS_ORIGIN) res.setHeader('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE,OPTIONS') res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization') res.setHeader('Access-Control-Max-Age', '86400') + res.setHeader('Vary', 'Origin') if (req.method === 'OPTIONS') { return res.sendStatus(204) } next() -}) +} + +server.use('/', corsMiddleware) server.use(express.json()) diff --git a/test/cors.test.sh b/test/cors.test.sh new file mode 100644 index 0000000..3684e55 --- /dev/null +++ b/test/cors.test.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +# Passing test: CORS is scoped to browser routes only, no wildcard default. +# +# The fix: src/env.ts now requires CORS_ORIGIN (fail closed, no wildcard). +# src/server.ts applies CORS middleware only to browser-facing routes +# (/, /subscription/*, /confirm, /unsubscribe) and adds Vary: Origin. +# Server-to-server routes (/notify) get no CORS headers. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +PORT="${PORT:-4742}" +BASE_URL="http://localhost:$PORT" +PASS=0 +FAIL=0 + +GREEN='\033[0;32m' +RED='\033[0;31m' +NC='\033[0m' + +cleanup() { + kill "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + rm -rf "$PROJECT_DIR/test-data-cors" +} +trap cleanup EXIT + +# Build if needed +cd "$PROJECT_DIR" +if [ ! -d "dist" ]; then + pnpm exec tsc +fi + +SECRET="$(openssl rand -hex 32)" + +# Set CORS_ORIGIN to a specific allowed origin (fail closed — must be set) +export CORS_ORIGIN="https://app.example.com" +export MAILSHIP_SECRET="$SECRET" +export MAILSHIP_NAME="Mailship Test" +export MAILSHIP_URL="$BASE_URL" +export BASE_URL="$BASE_URL" +export POSTMARK_API_KEY="test" +export POSTMARK_SENDER_ADDRESS="test@test.com" +export DEFAULT_RELAYS="wss://relay.damus.io" +export INDEXER_RELAYS="wss://purplepag.es" +export SEARCH_RELAYS="wss://relay.nostr.band" +export PORT="$PORT" +export DATA_DIR="$PROJECT_DIR/test-data-cors" +# SMTP env vars (required by src/env.ts) +export SMTP_HOST="localhost" +export SMTP_PORT="1025" +export SMTP_USER="test" +export SMTP_PASSWORD="test" +export SMTP_FROM="test@test.com" + +mkdir -p "$DATA_DIR" + +echo "=== Starting server on port $PORT (CORS_ORIGIN=$CORS_ORIGIN) ===" +node dist/index.js & +SERVER_PID=$! + +# Poll until server responds +for i in 1 2 3 4 5 6 7 8 9 10; do + if curl -sf "http://localhost:$PORT/" > /dev/null 2>&1; then + echo "Server ready after ${i}s" + break + fi + sleep 1 +done + +if ! kill -0 "$SERVER_PID" 2>/dev/null; then + echo -e "${RED}Server failed to start${NC}" + exit 1 +fi + +echo "" +echo "=========================================" +echo " CORS TESTS" +echo "=========================================" +echo "" + +pass() { + PASS=$((PASS + 1)) + echo -e " ${GREEN}✓${NC} $1" +} + +fail() { + FAIL=$((FAIL + 1)) + echo -e " ${RED}✗${NC} $1" +} + +# Test 1: Browser-facing GET /subscription/email returns the configured origin +echo "1. CORS on GET /subscription/email" +CORS_HEADER=$(curl -s -o /dev/null -D - \ + "http://localhost:$PORT/subscription/email?pubkey=test_pubkey_123" \ + -H "Origin: https://app.example.com" 2>/dev/null | grep -ia 'access-control-allow-origin' || true | head -1 | tr -d '\r') + +if echo "$CORS_HEADER" | grep -q 'https://app.example.com'; then + pass "subscription/email returns configured origin (not wildcard)" +elif echo "$CORS_HEADER" | grep -q '\*'; then + fail "BUG: subscription/email still returns wildcard '${CORS_HEADER}'" +else + fail "subscription/email missing Access-Control-Allow-Origin (got: ${CORS_HEADER:-})" +fi + +# Test 2: Vary: Origin is present on browser routes +echo "" +echo "2. Vary: Origin header on browser route" +VARY=$(curl -s -o /dev/null -D - \ + "http://localhost:$PORT/" \ + -H "Origin: https://app.example.com" 2>/dev/null | grep -ia 'vary' || true | head -1 | tr -d '\r') + +if echo "$VARY" | grep -qi 'origin'; then + pass "Vary: Origin is present on GET /" +else + fail "Missing Vary: Origin on GET / (got: ${VARY:-})" +fi + +# Test 3: Server-to-server /notify has NO CORS headers (relay callback) +echo "" +echo "3. No CORS on server-to-server POST /notify/:id" +CORS_NOTIFY=$(curl -s -o /dev/null -D - \ + "http://localhost:$PORT/notify/test-id" \ + -X POST -H "Content-Type: application/json" \ + -H "Origin: https://evil.com" \ + -d '{"id":"abc","relay":"wss://relay.primal.net"}' 2>/dev/null | grep -ia 'access-control-allow-origin' || true | head -1 | tr -d '\r') + +if [ -z "$CORS_NOTIFY" ]; then + pass "/notify has no Access-Control-Allow-Origin (server-to-server route)" +else + fail "BUG: /notify returns '${CORS_NOTIFY}' — server-to-server route should have no CORS" +fi + +# Test 4: CORS methods on preflight for subscription route +echo "" +echo "4. CORS preflight on PUT /subscription/email" +METHODS=$(curl -s -o /dev/null -D - \ + "http://localhost:$PORT/subscription/email" \ + -X OPTIONS -H "Origin: https://app.example.com" -H "Access-Control-Request-Method: PUT" 2>/dev/null | grep -ia 'access-control-allow-methods' || true | head -1 | tr -d '\r') + +if echo "$METHODS" | grep -qi 'PUT'; then + pass "OPTIONS preflight returns allowed methods" +else + fail "Preflight missing allowed methods (got: ${METHODS:-})" +fi + +echo "" +echo "=========================================" +echo " RESULTS: $PASS passed, $FAIL failed" +echo "=========================================" + +if [ "$FAIL" -gt 0 ]; then + exit 1 +fi +exit 0 \ No newline at end of file -- 2.45.2 From 97e92232c7c65c2cc1f4d16901c98b5f94c535dd Mon Sep 17 00:00:00 2001 From: Agent Date: Thu, 10 Sep 2026 11:31:44 -0400 Subject: [PATCH 2/2] chore: remove unused import of netContext from env.ts eslint flagged netContext as imported but never used (pre-existing). Removing the unused import so the repo's eslint gate passes on the modified area. --- src/env.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/env.ts b/src/env.ts index 3892bcd..a2a5e6b 100644 --- a/src/env.ts +++ b/src/env.ts @@ -1,7 +1,6 @@ import 'dotenv/config' import { always } from '@welshman/lib' import { normalizeRelayUrl } from '@welshman/util' -import { netContext } from '@welshman/net' import { Nip01Signer } from '@welshman/signer' import { routerContext } from '@welshman/router' -- 2.45.2