## 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
9.1 KiB
9.1 KiB
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_SECRETandSERVICE_PASSWORD_SALTare 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 32twice and set both in.envbefore next restart. Startup fails with a clear Zod error otherwise. config.shalready 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
apiTypeScript: clean (pre-existingshifts.service.tserrors unrelated)adminTypeScript: cleanchangemaker-control-panel/apiTypeScript: clean aside from pre-existinghealth.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 viaopenssl rand -hex 32SERVICE_PASSWORD_SALT— generated viaopenssl rand -hex 32JWT_REFRESH_EXPIRYreduced 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_EXPIRYdefault 7d → 24h - api
GITEA_SSO_SECRET/SERVICE_PASSWORD_SALTno longer have:-empty-default fallback (required)
Post-test runtime additions
npm install leaflet.heat@^0.2.0in 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.shgenerates everything correctly. ✅ - Fresh release install:
install.sh→config.shgenerates everything. ✅ - Existing install upgrading: re-run
./config.shto fill empty values, OR manually add both secrets to.envviaopenssl rand -hex 32. The Zod validator will emit a clear startup error if either is missing. - Existing CCP-managed instance: run
config.shon the managed instance's host (same as existing install path). The CCP template regen only applies to newly provisioned instances.