From e55bc07eb69eb27bee79d758c7dced5f6cd3ee53 Mon Sep 17 00:00:00 2001 From: bunker-admin Date: Sun, 12 Apr 2026 15:17:00 -0600 Subject: [PATCH] Security hardening: red-team remediation + CCP/WIP updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Security (red-team audit 2026-04-12) Public data exposure (P0): - Public map converted to server-side heatmap, 2-decimal (~1.1km) bucketing, no addresses/support-levels/sign-info returned - Petition signers endpoint strips displayName/signerComment/geoCity/geoCountry - Petition public-stats drops recentSigners entirely - Response wall strips userComment + submittedByName - Campaign createdByUserEmail + moderation fields gated to SUPER_ADMIN Access control (P1): - Campaign findById/update/delete/email-stats enforce owner === req.user.id (SUPER_ADMIN bypasses), return 404 to avoid enumeration - GPS tracking session route restricted to session owner or SUPER_ADMIN - Canvass volunteer stats restricted to self or SUPER_ADMIN - People household endpoints restricted to INFLUENCE + MAP roles (was ADMIN*) - CCP upgrade.service.ts + certificate.service.ts gate user-controlled shell inputs (branch, path, slug, SAN hostname) behind regex validators Token security (P2): - Query-param JWT auth replaced with HMAC-signed short-lived URLs (utils/signed-url.ts + /api/media/sign endpoint); legacy ?token= removed from media streaming, photos, chat-notifications, and social SSE - GITEA_SSO_SECRET + SERVICE_PASSWORD_SALT now REQUIRED (min 32 chars); JWT_ACCESS_SECRET fallback removed — BREAKING for existing deployments - Refresh tokens bound to device fingerprint (UA + /24 IP) via `df` JWT claim; mismatch revokes all user sessions - Refresh expiry reduced 7d → 24h - Refresh/logout via request body removed — httpOnly cookie only - Password-reset + verification-resend rate limits now keyed on (IP, email) composite to prevent both IP rotation and email enumeration Defense-in-depth (P3): - DOMPurify sanitization applied to GrapesJS landing page HTML/CSS - /api/health?detailed=true disk-space leak removed - Password-reset/verification token log lines no longer include userId ## Deployment - docker-compose.yml + docker-compose.prod.yml: media-api now receives GITEA_SSO_SECRET + SERVICE_PASSWORD_SALT; empty fallbacks removed - CCP templates/env.hbs adds both new secrets; refresh expiry → 24h - CCP secret-generator.ts generates giteaSsoSecret + servicePasswordSalt - leaflet.heat added to admin/package.json for heatmap rendering ## Operator action required on existing installs Run `./config.sh` once (idempotent — only fills empty values) or manually add GITEA_SSO_SECRET + SERVICE_PASSWORD_SALT to .env via `openssl rand -hex 32`. Startup fails with a clear Zod error otherwise. See SECURITY_REDTEAM_2026-04-12.md for full audit and verification matrix. ## Other Includes in-flight CCP work: instance schema tweaks, agent server updates, health service, tunnel service, DEV_WORKFLOW doc updates, and new migration dropping composeProject uniqueness. Bunker Admin --- .env.example | 23 ++- DEV_WORKFLOW.md | 2 +- SECURITY_REDTEAM_2026-04-12.md | 134 ++++++++++++++++ admin/package-lock.json | 6 + admin/package.json | 1 + admin/src/hooks/useChatNotifications.ts | 70 +++++---- admin/src/hooks/useSSE.ts | 30 ++-- admin/src/pages/public/LandingPage.tsx | 15 +- admin/src/pages/public/MapPage.tsx | 104 +++++-------- admin/src/pages/public/PetitionPage.tsx | 38 +---- admin/src/pages/public/ResponseWallPage.tsx | 16 +- admin/src/types/api.ts | 9 +- admin/src/types/leaflet-heat.d.ts | 26 ++++ admin/src/utils/sanitize.ts | 29 ++++ api/src/config/env.ts | 30 ++-- api/src/media-server.ts | 3 + api/src/modules/auth/auth.rate-limits.ts | 26 +++- api/src/modules/auth/auth.routes.ts | 22 +-- api/src/modules/auth/auth.service.ts | 35 ++++- api/src/modules/auth/gitea-sso.routes.ts | 2 +- .../campaign-emails/campaign-emails.routes.ts | 8 +- .../campaigns/campaigns-moderation.routes.ts | 4 +- .../influence/campaigns/campaigns.routes.ts | 8 +- .../influence/campaigns/campaigns.service.ts | 114 +++++++++++--- .../influence/petitions/petitions.service.ts | 31 ++-- .../influence/responses/responses.service.ts | 5 +- api/src/modules/map/canvass/canvass.routes.ts | 8 +- .../modules/map/locations/locations.routes.ts | 11 +- .../map/locations/locations.service.ts | 88 +++++------ .../modules/map/tracking/tracking.routes.ts | 17 +- api/src/modules/media/middleware/auth.ts | 108 ++++++------- .../media/routes/chat-notifications.routes.ts | 27 ++-- api/src/modules/media/routes/photos.routes.ts | 43 +++--- api/src/modules/media/routes/sign.routes.ts | 44 ++++++ .../media/routes/video-streaming.routes.ts | 51 +++--- api/src/modules/people/people.routes.ts | 11 +- api/src/modules/social/social.routes.ts | 41 ++++- api/src/server.ts | 31 +--- .../services/password-reset-token.service.ts | 4 +- .../user-provisioning/gitea.provisioner.ts | 2 +- .../rocketchat.provisioner.ts | 2 +- .../services/verification-token.service.ts | 3 +- api/src/utils/device-fingerprint.ts | 57 +++++++ api/src/utils/signed-url.ts | 105 +++++++++++++ .../admin/src/pages/InstanceDetailPage.tsx | 88 +++++++---- changemaker-control-panel/agent/src/server.ts | 39 ++++- .../migration.sql | 2 + .../api/prisma/schema.prisma | 2 +- .../api/src/modules/agents/agents.routes.ts | 45 ++++++ .../src/modules/instances/instances.routes.ts | 14 ++ .../modules/instances/instances.schemas.ts | 5 +- .../api/src/services/certificate.service.ts | 18 +++ .../api/src/services/health.service.ts | 13 +- .../api/src/services/secret-generator.ts | 8 + .../api/src/services/tunnel.service.ts | 145 +++++++++++++++++- .../api/src/services/upgrade.service.ts | 27 ++++ changemaker-control-panel/templates/env.hbs | 10 +- docker-compose.prod.yml | 20 ++- docker-compose.yml | 17 +- 59 files changed, 1387 insertions(+), 510 deletions(-) create mode 100644 SECURITY_REDTEAM_2026-04-12.md create mode 100644 admin/src/types/leaflet-heat.d.ts create mode 100644 api/src/modules/media/routes/sign.routes.ts create mode 100644 api/src/utils/device-fingerprint.ts create mode 100644 api/src/utils/signed-url.ts create mode 100644 changemaker-control-panel/api/prisma/migrations/20260412184734_drop_compose_project_unique/migration.sql diff --git a/.env.example b/.env.example index 0e814ba8..f4f9a929 100644 --- a/.env.example +++ b/.env.example @@ -46,20 +46,27 @@ JWT_ACCESS_SECRET=GENERATE_WITH_openssl_rand_hex_32 JWT_REFRESH_SECRET=GENERATE_WITH_openssl_rand_hex_32 JWT_INVITE_SECRET=GENERATE_WITH_openssl_rand_hex_32 JWT_ACCESS_EXPIRY=15m -JWT_REFRESH_EXPIRY=7d +# Reduced from 7d → 24h on 2026-04-12 (P2-3 hardening). Combined with +# device-fingerprint binding in the JWT payload, this tightens the +# exploitation window for stolen refresh tokens. +JWT_REFRESH_EXPIRY=24h # Encryption key for DB-stored secrets (SMTP password, etc.) # REQUIRED in production — must NOT reuse JWT_ACCESS_SECRET # Generate with: openssl rand -hex 32 ENCRYPTION_KEY=GENERATE_WITH_openssl_rand_hex_32 -# Gitea SSO cookie signing secret (separate from JWT — falls back to JWT_ACCESS_SECRET if empty) -# Generate with: openssl rand -hex 32 -GITEA_SSO_SECRET= -# Salt for deriving deterministic service passwords (Gitea, Rocket.Chat) -# Falls back to JWT_ACCESS_SECRET if empty — set a dedicated value to isolate secret rotation -# Generate with: openssl rand -hex 32 -SERVICE_PASSWORD_SALT= +# BREAKING CHANGE (2026-04-12): both GITEA_SSO_SECRET and SERVICE_PASSWORD_SALT +# are now REQUIRED (min 32 chars). The previous fallback to JWT_ACCESS_SECRET +# has been removed — a JWT leak must not compromise SSO cookies or service +# account passwords. Both values must be distinct from each other and from +# all JWT_* secrets. Generate with: openssl rand -hex 32 + +# Gitea SSO cookie signing secret (required, ≥32 chars, distinct from JWT secrets) +GITEA_SSO_SECRET=GENERATE_WITH_openssl_rand_hex_32 +# Salt for deriving deterministic service passwords (Gitea, Rocket.Chat). +# Required, ≥32 chars, distinct from all other secrets. +SERVICE_PASSWORD_SALT=GENERATE_WITH_openssl_rand_hex_32 # --- Initial Super Admin User (auto-created during database seeding) --- # These credentials are used to create the initial super admin account diff --git a/DEV_WORKFLOW.md b/DEV_WORKFLOW.md index ac11eae2..589b4435 100644 --- a/DEV_WORKFLOW.md +++ b/DEV_WORKFLOW.md @@ -43,7 +43,7 @@ All three methods share the same Gitea container registry at `gitea.bnkops.com/a │ Step 3: ./scripts/build-release.sh --tag vX.Y.Z --upload │ │ Packages runtime files into ~9MB tarball, uploads to │ │ Gitea Releases │ - └──────────────────┬───────────────────────────────────────────────┘ + └──────────────────┬─────────────────100.90.78.47──────────────────────────────┘ │ ┌───────────┴───────────┐ ▼ ▼ diff --git a/SECURITY_REDTEAM_2026-04-12.md b/SECURITY_REDTEAM_2026-04-12.md new file mode 100644 index 00000000..79c01537 --- /dev/null +++ b/SECURITY_REDTEAM_2026-04-12.md @@ -0,0 +1,134 @@ +# Red-Team Findings Tracker — 2026-04-12 + +Source: Red-team audit (auth, IDOR, public exposure, injection). +Coordinator: Claude (main). Status legend: ⬜ pending · 🟡 in progress · ✅ done · ❎ wontfix. + +--- + +## P0 — Zero-Auth Data Exposure (public endpoints leaking PII) + +| # | Finding | File(s) | Status | +|---|---------|---------|--------| +| P0-1 | **Public map → server-side heatmap with ~1km bucketing** | `api/.../locations.service.ts` · `.../locations.routes.ts` · `admin/.../public/MapPage.tsx` · `admin/src/types/leaflet-heat.d.ts` (new) | ✅ | +| P0-2 | Petition signers endpoint — stripped displayName/comment/geo | `petitions.service.ts:545` | ✅ | +| P0-3 | Petition public-stats — removed `recentSigners` | `petitions.service.ts:618` | ✅ | +| P0-4 | Campaign response wall — removed `submittedByName` + `userComment` | `responses.service.ts:121` | ✅ | +| P0-5 | `createdByUserEmail` gated to SUPER_ADMIN only | `campaigns.service.ts` | ✅ | + +## P1 — IDOR / Broken Access Control + +| # | Finding | File(s) | Status | +|---|---------|---------|--------| +| P1-1..3 | Campaign findById/update/delete ownership checks | `campaigns.{service,routes}.ts` | ✅ | +| P1-4,5 | `/campaigns/:id/emails` + `/email-stats` ownership checks | `campaign-emails.routes.ts` | ✅ | +| P1-6 | GPS tracking route — SUPER_ADMIN or session owner only | `tracking.routes.ts` | ✅ | +| P1-7 | Canvass volunteer stats — SUPER_ADMIN or self only | `canvass.routes.ts` | ✅ | +| P1-8 | People household endpoints restricted to INFLUENCE_ROLES ∪ MAP_ROLES | `people.routes.ts` | ✅ | +| P1-9 | CCP upgrade branch/path validation (SAFE_BRANCH, SAFE_PATH) | `upgrade.service.ts` | ✅ | + +## P2 — Token Security + +| # | Finding | File(s) | Status | +|---|---------|---------|--------| +| P2-1 | Query-param JWT tokens → HMAC-signed short-lived URLs | `utils/signed-url.ts` (new), `media/middleware/auth.ts`, `video-streaming.routes.ts`, `photos.routes.ts`, `chat-notifications.routes.ts`, `social.routes.ts`, `media/routes/sign.routes.ts` (new), `useChatNotifications.ts`, `useSSE.ts` | ✅ | +| P2-2 | `GITEA_SSO_SECRET` & `SERVICE_PASSWORD_SALT` — fallback removed | `env.ts`, provisioners, routes, `.env.example` | ✅ **BREAKING** | +| P2-3..5 | Refresh-token device fingerprint + 24h expiry + per-email rate limits + body-fallback removed | `auth.service.ts`, `auth.routes.ts`, `auth.rate-limits.ts`, `utils/device-fingerprint.ts` (new), `env.ts` | ✅ | + +## P3 — Hardening / Defense-in-depth + +| # | Finding | File(s) | Status | +|---|---------|---------|--------| +| P3-1 | Landing page HTML/CSS — DOMPurify sanitization | `LandingPage.tsx`, `utils/sanitize.ts` | ✅ | +| P3-2 | CCP certificate slug + hostname validation | `certificate.service.ts` | ✅ | +| P3-3 | `/api/health?detailed=true` disk-space leak — mode removed | `server.ts:218` | ✅ | +| P3-4 | Search endpoint tighter rate limits | `search.service.ts` | ⬜ (deferred — low risk) | +| P3-5 | Activity feed exposure review | `activity-public.service.ts` | ⬜ (deferred — cached, low risk) | +| P3-6 | Moderation notes gated to SUPER_ADMIN | `campaigns.service.ts` | ✅ (folded into P0-5) | +| P3-7 | userId removed from reset/verify token logs | `*-token.service.ts` | ✅ | + +--- + +## Breaking Changes Operators Must Handle + +**P2-2** (required restart-blocker): +- `GITEA_SSO_SECRET` and `SERVICE_PASSWORD_SALT` are now **required** (≥32 chars). +- Previously, an empty value silently fell back to `JWT_ACCESS_SECRET` — a JWT leak compromised SSO cookies + service passwords. +- Operators must run `openssl rand -hex 32` twice and set both in `.env` before next restart. Startup fails with a clear Zod error otherwise. +- `config.sh` already generates distinct values, so fresh installs are fine; upgrades need manual action. + +--- + +## Deferred (none remaining) + +All audit findings have been landed. Remaining `⬜` rows in the P3 table are +low-risk polish items (search rate-limit tweak, activity-feed exposure review) +tracked for normal maintenance — not shipping blockers. + +--- + +## Verification — End-to-End Tested 2026-04-12 + +### Static checks +- `api` TypeScript: clean (pre-existing `shifts.service.ts` errors unrelated) +- `admin` TypeScript: clean +- `changemaker-control-panel/api` TypeScript: clean aside from pre-existing `health.service.ts` + +### Runtime verification (curl against running containers) + +| Finding | Test | Result | +|---------|------|--------| +| P0-1 | `GET /api/map/locations/public` — shape is `{points:[{lat,lng,count}]}`, 2-decimal bucketing, no address/supportLevel/signSize | ✅ 17 points, banned fields absent | +| P0-2 | `GET /api/petitions/:slug/signers` — sample keys only `id/isAnonymous/createdAt` | ✅ no `displayName/signerComment/geoCity/geoCountry` | +| P0-3 | `GET /api/petitions/:slug/public-stats` — no `recentSigners` | ✅ keys: total/verified/goal/percentComplete/byCountry/byRegion | +| P0-4 | `GET /api/campaigns/:slug/responses` — no submitter PII | ✅ shape clean | +| P0-5 | SUPER_ADMIN vs INFLUENCE_ADMIN field-level RBAC on `/campaigns/:id` | ✅ super sees email+modNotes; influence gets 404 via IDOR | +| P1-1..5 | Non-owner IDOR on campaign findById/update/delete/emails/email-stats | ✅ all return 404 | +| P1-6 | MAP_ADMIN accessing another user's tracking session route | ✅ 404; SUPER_ADMIN passes 200 | +| P1-7 | Canvass volunteer stats cross-user access | ✅ 404 for non-owner non-SUPER | +| P1-8 | MEDIA_ADMIN blocked from `/api/people/household/:id` | ✅ 403; INFLUENCE_ADMIN passes 200 | +| P1-9 / P3-2 | SAFE_BRANCH + SAFE_SLUG reject injection payloads (`;`, `$()`, backticks, `/O=Evil`) | ✅ all blocked | +| P2-1 | `POST /api/media/sign` returns signed URL; signed URL authenticates; legacy `?token=JWT` rejected; tampered/expired/cross-path all 401 | ✅ all cases | +| P2-2 | Startup fails cleanly when `GITEA_SSO_SECRET`/`SERVICE_PASSWORD_SALT` empty | ✅ confirmed; operator must set both | +| P2-3 | Refresh JWT contains `df` claim, 24h expiry | ✅ | +| P2-3 | Fingerprint mismatch on refresh revokes all sessions | ✅ 401 FINGERPRINT_MISMATCH | +| P2-4 | Refresh token via request body rejected | ✅ 401 (cookie only) | +| P2-5 | 4th password-reset request for same (IP, email) → 429; different email same IP → 200 | ✅ composite key works | +| P3-1 | LandingPage uses `sanitizeLandingHtml` + `sanitizeLandingCss` | ✅ wired | +| P3-3 | `GET /api/health?detailed=true` no longer leaks `diskFreeGB` or `mkdocs` status | ✅ | +| P3-7 | "Password reset token created" log line contains no userId cuid | ✅ | + +### Operational changes required to .env (now applied) +- `GITEA_SSO_SECRET` — generated via `openssl rand -hex 32` +- `SERVICE_PASSWORD_SALT` — generated via `openssl rand -hex 32` +- `JWT_REFRESH_EXPIRY` reduced 7d → 24h + +### docker-compose.yml changes +- media-api now receives `GITEA_SSO_SECRET`, `SERVICE_PASSWORD_SALT`, `JWT_ACCESS_EXPIRY`, `JWT_REFRESH_EXPIRY` (it shares the api's env schema) +- api+media-api `JWT_REFRESH_EXPIRY` default 7d → 24h +- api `GITEA_SSO_SECRET` / `SERVICE_PASSWORD_SALT` no longer have `:-` empty-default fallback (required) + +### Post-test runtime additions +- `npm install leaflet.heat@^0.2.0` in the admin container (package.json already updated — install just needed to be materialized) + +--- + +## Deployment System Audit (post-hoc, 2026-04-12) + +Confirmed ripple effects across every deployment path: + +| System | Status | Action | +|--------|--------|--------| +| `config.sh` | ✅ already generates `GITEA_SSO_SECRET` + `SERVICE_PASSWORD_SALT` via `update_env_var_if_empty` (lines 508-519) — fresh installs and re-runs on old installs will fill empty values | No change needed | +| `docker-compose.yml` (dev/source installs) | ✅ updated earlier during test plan | Done | +| `docker-compose.prod.yml` (release installs) | ✅ **fixed now**: removed `:-` empty fallback for both secrets, default refresh expiry 7d→24h, added both secrets + expiry to media-api service | Done | +| `changemaker-control-panel/templates/env.hbs` (CCP-provisioned instances) | ✅ **fixed now**: added `GITEA_SSO_SECRET` + `SERVICE_PASSWORD_SALT` template fields, refresh expiry 7d→24h | Done | +| `changemaker-control-panel/api/src/services/secret-generator.ts` | ✅ **fixed now**: `InstanceSecrets` interface + `generateSecrets()` now produce `giteaSsoSecret` + `servicePasswordSalt` | Done | +| CCP's own app (separate from Changemaker instances) | ✅ not affected — has its own env schema | No change needed | +| `scripts/validate-env.sh`, `scripts/install.sh`, `scripts/upgrade.sh`, `scripts/build-release.sh` | ✅ none reference the changed env vars directly | No change needed | + +### Operator migration paths + +- **Fresh source install:** `config.sh` generates everything correctly. ✅ +- **Fresh release install:** `install.sh` → `config.sh` generates everything. ✅ +- **Existing install upgrading:** re-run `./config.sh` to fill empty values, OR manually add both secrets to `.env` via `openssl rand -hex 32`. The Zod validator will emit a clear startup error if either is missing. +- **Existing CCP-managed instance:** run `config.sh` on the managed instance's host (same as existing install path). The CCP template regen only applies to *newly provisioned* instances. diff --git a/admin/package-lock.json b/admin/package-lock.json index 726e5d9c..7bba49a4 100644 --- a/admin/package-lock.json +++ b/admin/package-lock.json @@ -36,6 +36,7 @@ "html5-qrcode": "^2.3.8", "jwt-decode": "^4.0.0", "leaflet": "^1.9.4", + "leaflet.heat": "^0.2.0", "minisearch": "^7.2.0", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -2722,6 +2723,11 @@ "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", "license": "BSD-2-Clause" }, + "node_modules/leaflet.heat": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/leaflet.heat/-/leaflet.heat-0.2.0.tgz", + "integrity": "sha512-Cd5PbAA/rX3X3XKxfDoUGi9qp78FyhWYurFg3nsfhntcM/MCNK08pRkf4iEenO1KNqwVPKCmkyktjW3UD+h9bQ==" + }, "node_modules/leaflet.markercluster": { "version": "1.5.3", "resolved": "https://registry.npmjs.org/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz", diff --git a/admin/package.json b/admin/package.json index 10ce366a..84199571 100644 --- a/admin/package.json +++ b/admin/package.json @@ -37,6 +37,7 @@ "html5-qrcode": "^2.3.8", "jwt-decode": "^4.0.0", "leaflet": "^1.9.4", + "leaflet.heat": "^0.2.0", "minisearch": "^7.2.0", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/admin/src/hooks/useChatNotifications.ts b/admin/src/hooks/useChatNotifications.ts index a4d6ed5e..632990a3 100644 --- a/admin/src/hooks/useChatNotifications.ts +++ b/admin/src/hooks/useChatNotifications.ts @@ -35,42 +35,48 @@ export function useChatNotifications() { return; } - // Use relative URL through nginx proxy - const url = `/media/media/notifications/stream?token=${encodeURIComponent(accessToken)}`; - const es = new EventSource(url); + // Obtain a short-lived signed URL (2026-04-12). Previously we put the full + // access-token JWT in the URL, which leaked via access logs / referer. + let es: EventSource | null = null; + let cancelled = false; - es.onmessage = (event) => { + const wire = (src: EventSource) => { + src.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + if (data.type === 'connected') return; + if (data.type === 'chat_reply') { + const notif: ChatNotification = { + ...data, + id: `notif-${++notifCounterRef.current}-${Date.now()}`, + receivedAt: Date.now(), + }; + setNotifications((prev) => [...prev, notif].slice(-10)); + } + } catch { /* ignore parse errors */ } + }; + src.onerror = () => { /* EventSource handles reconnect */ }; + }; + + (async () => { try { - const data = JSON.parse(event.data); - - if (data.type === 'connected') return; - - if (data.type === 'chat_reply') { - const notif: ChatNotification = { - ...data, - id: `notif-${++notifCounterRef.current}-${Date.now()}`, - receivedAt: Date.now(), - }; - - setNotifications((prev) => { - // Keep max 10 notifications - const updated = [...prev, notif]; - return updated.slice(-10); - }); - } - } catch { - // Ignore parse errors - } - }; - - es.onerror = () => { - // Auto-reconnect is handled by EventSource - }; - - eventSourceRef.current = es; + const signRes = await fetch('/media/api/media/sign', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}` }, + body: JSON.stringify({ path: '/api/media/notifications/stream', ttlSeconds: 300 }), + }); + if (!signRes.ok || cancelled) return; + const { url: signedPath } = await signRes.json(); + if (cancelled) return; + es = new EventSource(`/media${signedPath}`); + eventSourceRef.current = es; + wire(es); + } catch { /* notifications unavailable */ } + })(); return () => { - es.close(); + cancelled = true; + es?.close(); eventSourceRef.current = null; }; }, [isAuthenticated, accessToken]); diff --git a/admin/src/hooks/useSSE.ts b/admin/src/hooks/useSSE.ts index 5a322dbd..ad471bdb 100644 --- a/admin/src/hooks/useSSE.ts +++ b/admin/src/hooks/useSSE.ts @@ -70,7 +70,7 @@ export function useSSE() { useSocialStore.getState().fetchFriends(); }, []); - const connect = useCallback(() => { + const connect = useCallback(async () => { if (!accessToken || !enableSocial) return; // Close existing connection if any @@ -79,36 +79,48 @@ export function useSSE() { esRef.current = null; } - const url = `/api/social/sse?token=${encodeURIComponent(accessToken)}`; + // Obtain a signed URL instead of embedding the full JWT in the query + // string (2026-04-12 — see utils/signed-url.ts for rationale). + let url: string; + try { + const signRes = await fetch('/media/api/media/sign', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}` }, + body: JSON.stringify({ path: '/api/social/sse', ttlSeconds: 300 }), + }); + if (!signRes.ok) throw new Error('sign failed'); + const { url: signedPath } = await signRes.json(); + url = signedPath; // absolute path, nginx routes /api/social to Express + } catch { + return; // SSE unavailable; non-critical + } + const es = new EventSource(url); esRef.current = es; es.addEventListener('connected', () => { - retryCount.current = 0; // Reset backoff on successful connect + retryCount.current = 0; }); es.addEventListener('notification', handleNotification); es.addEventListener('presence', handlePresence); es.addEventListener('friend_request', handleFriendRequest); es.addEventListener('friend_accepted', handleFriendAccepted); - es.addEventListener('poke', handleNotification); // Pokes also increment notification count + es.addEventListener('poke', handleNotification); es.onerror = () => { es.close(); esRef.current = null; - - // Exponential backoff: 1s, 2s, 4s, 8s, ... up to 30s const delay = Math.min(1000 * 2 ** retryCount.current, maxRetryDelay); retryCount.current++; - - retryRef.current = setTimeout(connect, delay); + retryRef.current = setTimeout(() => { void connect(); }, delay); }; }, [accessToken, enableSocial, handleNotification, handlePresence, handleFriendRequest, handleFriendAccepted]); useEffect(() => { if (!isAuthenticated || !enableSocial) return; - connect(); + void connect(); // Fetch initial online friends list useSocialStore.getState().fetchOnlineFriends(); diff --git a/admin/src/pages/public/LandingPage.tsx b/admin/src/pages/public/LandingPage.tsx index c64c1bee..adb51b3e 100644 --- a/admin/src/pages/public/LandingPage.tsx +++ b/admin/src/pages/public/LandingPage.tsx @@ -13,6 +13,7 @@ import { CampaignFormWidget } from '@/components/influence/CampaignFormWidget'; import { SchedulingPollWidget } from '@/components/scheduling/SchedulingPollWidget'; import GalleryAdCard from '@/components/media/GalleryAdCard'; import type { GalleryAd } from '@/types/gallery-ads'; +import { sanitizeLandingHtml, sanitizeLandingCss } from '@/utils/sanitize'; export default function PublicLandingPage() { const { slug } = useParams<{ slug: string }>(); @@ -413,12 +414,18 @@ export default function PublicLandingPage() { ); } - // HTML/CSS is admin-authored via GrapesJS editor (not user-submitted content). - // Only authenticated admins can create/edit pages, so XSS risk is accepted. + // HTML/CSS is admin-authored via GrapesJS, but we still DOMPurify-sanitize as + // defense-in-depth (2026-04-12): compromised admin accounts / stored XSS via + // embeddable widgets would otherwise reach every public visitor. return ( <> - {page.cssOutput &&