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

135 lines
9.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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