diff --git a/api/notify.ts b/api/notify.ts index 817032826..b7d87e4d5 100644 --- a/api/notify.ts +++ b/api/notify.ts @@ -16,6 +16,9 @@ import { jsonResponse } from './_json-response.js'; import { validateBearerToken } from '../server/auth-session'; import { getEntitlements } from '../server/_shared/entitlement-check'; +const VALID_SEVERITIES = new Set(['critical', 'high', 'medium', 'low', 'info']); +const INTERNAL_EVENT_TYPES = new Set(['flush_quiet_held', 'channel_welcome']); + export default async function handler(req: Request): Promise { if (isDisallowedOrigin(req)) { return jsonResponse({ error: 'Origin not allowed' }, 403); @@ -58,6 +61,14 @@ export default async function handler(req: Request): Promise { return jsonResponse({ error: 'eventType required (string, max 64 chars)' }, 400, cors); } + // Reject internal relay control events. These are dispatched by Railway + // cron scripts (seed-digest-notifications, quiet-hours) and must never be + // user-submittable. flush_quiet_held would let a Pro user force-drain their + // held queue on demand, bypassing batch_on_wake behaviour. + if (INTERNAL_EVENT_TYPES.has(body.eventType)) { + return jsonResponse({ error: 'Reserved event type' }, 403, cors); + } + if (typeof body.payload !== 'object' || body.payload === null || Array.isArray(body.payload)) { return jsonResponse({ error: 'payload must be an object' }, 400, cors); } @@ -69,8 +80,18 @@ export default async function handler(req: Request): Promise { return jsonResponse({ error: 'Service unavailable' }, 503, cors); } - const { eventType, payload } = body; - const severity = typeof body.severity === 'string' ? body.severity : 'high'; + const { eventType } = body; + + // Strip relay-internal scoring fields from user-supplied payload. These are + // computed server-side by the relay's importanceScore pipeline; allowing + // user-supplied values would let a Pro user bypass the IMPORTANCE_SCORE_MIN + // gate and fan out arbitrary alerts to every subscriber. + const payload = { ...(body.payload as Record) }; + delete payload.importanceScore; + delete payload.corroborationCount; + + const rawSeverity = typeof body.severity === 'string' ? body.severity : 'high'; + const severity = VALID_SEVERITIES.has(rawSeverity) ? rawSeverity : 'high'; const variant = typeof body.variant === 'string' ? body.variant : undefined; const msg = JSON.stringify({ diff --git a/scripts/notification-relay.cjs b/scripts/notification-relay.cjs index 9b9cb37c5..0e48fd8f5 100644 --- a/scripts/notification-relay.cjs +++ b/scripts/notification-relay.cjs @@ -687,9 +687,12 @@ async function processEvent(event) { // short-circuit, but we still pay the promise/microtask cost unless gated here. if (event.eventType === 'rss_alert') shadowLogScore(event).catch(() => {}); - // Score gate — only for rss_alert; other event types (oref_siren, conflict_escalation, - // notam_closure, etc.) never attach importanceScore so they must never be gated here. - if (IMPORTANCE_SCORE_LIVE && event.eventType === 'rss_alert') { + // Score gate — only for relay-emitted rss_alert (no userId). Browser-submitted + // events (with userId) have importanceScore stripped at ingestion and no server- + // computed score; gating them would drop every browser notification once + // IMPORTANCE_SCORE_LIVE=1 is activated. Other event types (oref_siren, + // conflict_escalation, notam_closure) never attach importanceScore. + if (IMPORTANCE_SCORE_LIVE && event.eventType === 'rss_alert' && !event.userId) { const score = event.payload?.importanceScore ?? 0; if (score < IMPORTANCE_SCORE_MIN) { console.log(`[relay] Score gate: dropped ${event.eventType} score=${score} < ${IMPORTANCE_SCORE_MIN}`); @@ -705,11 +708,17 @@ async function processEvent(event) { return; } + // If the event carries a userId (browser-submitted via /api/notify), scope + // delivery to ONLY that user's own rules. Relay-emitted events (ais-relay, + // regional-snapshot) have no userId and fan out to all matching Pro users. + // Without this guard, a Pro user can POST arbitrary rss_alert events that + // fan out to every other Pro subscriber — see todo #196. const matching = enabledRules.filter(r => - (!r.digestMode || r.digestMode === 'realtime') && // skip digest-mode rules — handled by seed-digest-notifications cron + (!r.digestMode || r.digestMode === 'realtime') && (r.eventTypes.length === 0 || r.eventTypes.includes(event.eventType)) && shouldNotify(r, event) && - (!event.variant || !r.variant || r.variant === event.variant) + (!event.variant || !r.variant || r.variant === event.variant) && + (!event.userId || r.userId === event.userId) ); if (matching.length === 0) return;