changemaker.lite/SECURITY_REDTEAM_2026-04-12.md
bunker-admin e55bc07eb6 Security hardening: red-team remediation + CCP/WIP updates
## 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
2026-04-12 15:17:00 -06:00

9.1 KiB
Raw Blame History

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.shconfig.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.