From d899a3359abed528533ef853cde964a9787b2b9b Mon Sep 17 00:00:00 2001 From: whiteghostdev1 Date: Wed, 21 Oct 2026 17:56:44 +0000 Subject: [PATCH] feat: implement notification center, rate limiting, CSRF & CSP headers (#896 #920 #921 #922) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit implements four security and UX issues simultaneously. Below is a detailed breakdown of each issue, what was changed, and why. ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ #896 — Notification Center with email and in-app notifications ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Problem: The NotificationCenter component had duplicate const declarations for `unread` and `badgeCount` (TypeScript compile error), referenced `persistMarkAllRead()` which was undefined, and was missing the `reminder` notification type that notifications/page.tsx expected. The settings page only exposed in-app and browser toggles — no email preference. The emailer had no real sending logic. Changes: - src/components/NotificationCenter.tsx · Added `reminder` to NotificationType union and TYPE_ICONS map · Removed duplicate variable declarations (compile fix) · Defined persistMarkAllRead() — POSTs to /api/notifications/mark-read so read-state can be persisted server-side with CSRF protection · Imported apiFetch from @/lib/apiClient for CSRF-safe calls - src/app/settings/notifications/page.tsx · Added `email: boolean` to EventPrefs interface and DEFAULT_PREFS · Widened table grid from 2 to 3 toggle columns (In-App | Browser | Email) · Email toggle shows a tooltip directing users to configure RESEND_API_KEY · Persists email preference to localStorage alongside inApp/browser prefs - src/app/api/notifications/mark-read/route.ts [NEW] · POST endpoint that acknowledges mark-all-read operations · Protected by assertCsrf() — requires valid CSRF token - src/app/api/notifications/send-email/route.ts [NEW] · POST endpoint for sending transactional notification emails via Resend · Validates input (to, subject, message), checks email format · Gracefully degrades (skips with 200 ok + skipped:true) when RESEND_API_KEY is not set so dev environments work without config · Protected by assertCsrf() - src/lib/paymentReceiptEmailer.ts · Replaced TODO scaffold with real Resend HTTP call · Builds styled HTML receipt with amount, date, payer address, tx hash · Includes a 'View Invoice' CTA linked to the invoice page · Same graceful degradation as the route above when key is absent · Idempotency: checks/records sent state via checkIfEmailSent / markEmailAsSent before and after each send Closes #896 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ #920 — Rate limiting for payment operations ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Problem: The app had client-side 429 retry logic (api.ts) and a RateLimitContext for countdown UI, but NO server-side rate limiter — any client could bypass the client-side guard and hammer payment endpoints directly. Approach: In-memory sliding-window limiter (no Redis dependency) keyed by wallet address (X-Wallet-Address header) with IP fallback. 10 payment attempts per 60-second window per identity. Whitelist via env var. Changes: - src/lib/rateLimit.ts [NEW] · checkRateLimit(identity, config) — sliding window implementation · getRequestIdentity(request) — extracts wallet address or forwarded IP · pruneRateLimitStore() — prevents unbounded memory growth · PAYMENT_RATE_LIMIT config: 10 req / 60 s · QUERY_RATE_LIMIT config: 120 req / 60 s · RATE_LIMIT_WHITELIST env var for integration test / admin bypass - src/lib/middleware/rateLimitMiddleware.ts [NEW] · applyRateLimit(request, config) — drop-in guard for route handlers · Returns 429 with Retry-After + X-RateLimit-* standard headers on breach · Returns null (allowed) with rate limit headers to set on success responses - src/app/api/rate-limit/status/route.ts [NEW] · GET /api/rate-limit/status — lets the frontend query remaining budget · Used by RateLimitContext to show countdown without waiting for a 429 - src/app/api/invoices/[id]/payment-confirmed/route.ts · Added applyRateLimit(request, PAYMENT_RATE_LIMIT) guard at handler entry - src/app/api/invoices/[id]/installments/[index]/mark-paid/route.ts · Added applyRateLimit(request, PAYMENT_RATE_LIMIT) guard at handler entry - src/app/api/settlement/execute/route.ts · Added applyRateLimit(request, PAYMENT_RATE_LIMIT) guard at handler entry Closes #920 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ #921 — CSRF protection for state-changing operations ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Problem: The CSRF system (csrf.ts, apiClient.ts, csrfMiddleware.ts) was fully implemented but two gaps remained: (1) getCsrfSecret() threw an error in development when CSRF_SECRET was not set, making local dev require manual env setup, and (2) no documentation existed for the mechanism. Changes: - src/lib/csrf.ts · getCsrfSecret() now falls back to a fixed insecure dev secret when NODE_ENV !== 'production' and CSRF_SECRET is unset, with a console.warn · In production the hard throw is preserved — misconfiguration is fatal - docs/CSRF_PROTECTION.md [NEW] · Full explanation of the double-submit HMAC token approach · Token lifecycle, refresh strategy, retry-on-403 behaviour · How to protect a new route (code example) · Environment variable reference (CSRF_SECRET, generate with openssl) · Notes on SameSite cookie complementary defence Closes #921 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ #922 — Content Security Policy headers ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Problem: The middleware built a dynamic connectSrc list from env vars (analytics, backup webhook origins) and passed it to buildCSP(), but BuildCSPOptions only accepted allowFraming — the connectSrc parameter was silently dropped, meaning custom origins were never included in the policy. There was also no mechanism to receive CSP violation reports from browsers. Changes: - src/lib/csp.ts · Added connectSrc?: string[] to BuildCSPOptions · buildCSP() now merges provided origins with hardcoded Stellar origins and deduplicates them via Set before building the directive string · Added reportUri?: string to BuildCSPOptions · When set, appends report-uri as the final directive - src/middleware.ts · Added getCspReportUri() — reads CSP_REPORT_URI env var · Passes reportUri to buildCSP() so violation reporting is opt-in via env - src/app/api/csp-report/route.ts [NEW] · POST /api/csp-report — receives browser violation reports · Parses both wrapped ({csp-report: {...}}) and unwrapped payloads · Logs structured JSON for monitoring / alerting pipelines · Always returns 204 (browsers don't use the response body) - .env.example · Added CSP_REPORT_URI, RATE_LIMIT_WHITELIST, EMAIL_FROM, RESEND_API_KEY with descriptive comments - docs/CSP_HEADERS.md [NEW] · Policy directive table with values and rationale for each · Nonce flow diagram (middleware → Server Component → inline tag) · Configuration reference for all relevant env vars · Violation reporting setup instructions Closes #922 --- .env.example | 15 ++- docs/CSP_HEADERS.md | 43 +++++++ docs/CSRF_PROTECTION.md | 81 ++++++++++++ src/app/api/csp-report/route.ts | 39 ++++++ .../installments/[index]/mark-paid/route.ts | 2 + .../invoices/[id]/payment-confirmed/route.ts | 5 + src/app/api/notifications/mark-read/route.ts | 11 ++ src/app/api/notifications/send-email/route.ts | 105 ++++++++++++++++ src/app/api/rate-limit/status/route.ts | 17 +++ src/app/api/settlement/execute/route.ts | 4 + src/app/settings/notifications/page.tsx | 26 ++-- src/components/NotificationCenter.tsx | 20 +-- src/lib/csp.ts | 36 +++++- src/lib/csrf.ts | 12 +- src/lib/middleware/rateLimitMiddleware.ts | 40 ++++++ src/lib/paymentReceiptEmailer.ts | 113 ++++++++++++++--- src/lib/rateLimit.ts | 116 ++++++++++++++++++ src/middleware.ts | 5 + 18 files changed, 651 insertions(+), 39 deletions(-) create mode 100644 docs/CSP_HEADERS.md create mode 100644 docs/CSRF_PROTECTION.md create mode 100644 src/app/api/csp-report/route.ts create mode 100644 src/app/api/notifications/mark-read/route.ts create mode 100644 src/app/api/notifications/send-email/route.ts create mode 100644 src/app/api/rate-limit/status/route.ts create mode 100644 src/lib/middleware/rateLimitMiddleware.ts create mode 100644 src/lib/rateLimit.ts diff --git a/.env.example b/.env.example index cfa1b07a..e805af95 100644 --- a/.env.example +++ b/.env.example @@ -33,4 +33,17 @@ SENTRY_PROJECT= # SENTRY_AUTH_TOKEN: server-only token with `project:releases` scope for # sourcemap uploads. Never expose this to the client. Leave empty to skip # sourcemap upload (builds still succeed, traces will be minified). -SENTRY_AUTH_TOKEN= \ No newline at end of file +SENTRY_AUTH_TOKEN= + +# CSP violation reporting endpoint (optional) +# Set to your app URL + /api/csp-report to receive browser violation reports +CSP_REPORT_URI= + +# Rate limiting whitelist (comma-separated wallet addresses or IPs to bypass) +RATE_LIMIT_WHITELIST= + +# Email from address for notifications +EMAIL_FROM=noreply@stellarsplit-dapp.vercel.app + +# Resend API key for email notifications (optional - logs to console if not set) +RESEND_API_KEY= diff --git a/docs/CSP_HEADERS.md b/docs/CSP_HEADERS.md new file mode 100644 index 00000000..e89858fd --- /dev/null +++ b/docs/CSP_HEADERS.md @@ -0,0 +1,43 @@ +# Content Security Policy Headers + +## Overview +StellarSplit implements a per-request nonce-based CSP to prevent XSS attacks. +A fresh nonce is generated on every request by `src/middleware.ts` and embedded +into `script-src` and `style-src` directives. + +## Policy Breakdown + +| Directive | Value | Reason | +|-----------|-------|--------| +| `default-src` | `'self'` | Restrict everything to same origin by default | +| `script-src` | `'self' 'nonce-{n}' 'strict-dynamic'` | Nonce for inline scripts, strict-dynamic propagates trust to loaded scripts | +| `style-src` | `'self' 'nonce-{n}' 'unsafe-inline'` | Nonce for inline styles; unsafe-inline needed for Tailwind's CSS-in-JS | +| `img-src` | `'self' data: blob: https://www.gravatar.com` | Allow Gravatar avatars and data URIs | +| `connect-src` | `'self' https://*.stellar.org ...` | Stellar network endpoints + optional analytics | +| `object-src` | `'none'` | Block plugins (Flash, Java) entirely | +| `base-uri` | `'self'` | Prevent base tag hijacking | +| `form-action` | `'self'` | Prevent form submission to external sites | +| `frame-ancestors` | `'none'` (or `*` for /embed routes) | Prevent clickjacking | +| `upgrade-insecure-requests` | — | Rewrite HTTP subresource requests to HTTPS | +| `report-uri` | (optional) | Receive violation reports from browsers | + +## Configuration + +| Environment Variable | Description | +|---------------------|-------------| +| `CSP_REPORT_URI` | URL to receive violation reports (e.g. `https://your-app.vercel.app/api/csp-report`) | +| `ANALYTICS_ENABLED` | Set to `true` to add `ANALYTICS_ENDPOINT_ORIGINS` to `connect-src` | +| `ANALYTICS_ENDPOINT_ORIGINS` | Comma-separated analytics origins | +| `BACKUP_ALERTS_ENABLED` | Set to `true` to add `BACKUP_WEBHOOK_ORIGINS` to `connect-src` | +| `BACKUP_WEBHOOK_ORIGINS` | Comma-separated backup webhook origins | + +## Nonce Flow +1. Middleware generates `nonce = base64(randomUUID())` +2. Nonce is set as `x-nonce` request header for Server Components +3. CSP header is set on the response with the nonce embedded +4. `src/lib/csp.ts:getNonce()` reads it in Server Components via `next/headers` +5. `src/app/layout.tsx` passes the nonce to inline `