## 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
135 lines
9.1 KiB
Markdown
135 lines
9.1 KiB
Markdown
# 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.
|