From 2ae7d8b968781d0ad15dde977ba7ae96910b8f15 Mon Sep 17 00:00:00 2001 From: bunker-admin Date: Thu, 30 Apr 2026 14:17:50 -0600 Subject: [PATCH] Bug fixes for video serving and updats to documentation for mobile use screenshots --- SECURITY_REDTEAM_2026-04-12.md | 134 ------------------ admin/src/components/media/AlbumCard.tsx | 16 +-- .../components/media/AlbumDetailDrawer.tsx | 34 +++-- admin/src/components/media/PhotoCard.tsx | 16 +-- .../src/components/media/PhotoViewerModal.tsx | 18 +-- admin/src/components/media/VideoCard.tsx | 16 +-- admin/src/components/media/VideoPlayer.tsx | 17 +-- .../src/components/media/VideoViewerModal.tsx | 14 +- admin/src/lib/media-url.ts | 113 +++++++++++++++ api/Dockerfile.media | 18 ++- api/media-docker-entrypoint.sh | 22 +++ .../media/routes/video-actions.routes.ts | 28 ++-- .../volunteer/achievements-mobile.png | Bin 0 -> 61223 bytes .../screenshots/volunteer/achievements.png | Bin 0 -> 155703 bytes .../images/screenshots/volunteer/activity.png | Bin 0 -> 66295 bytes .../volunteer/canvass-map-mobile.png | Bin 0 -> 115853 bytes .../screenshots/volunteer/canvass-map.png | Bin 0 -> 244453 bytes .../volunteer/dashboard-mobile.png | Bin 0 -> 54840 bytes .../screenshots/volunteer/dashboard.png | Bin 0 -> 97718 bytes .../images/screenshots/volunteer/discover.png | Bin 0 -> 61869 bytes .../images/screenshots/volunteer/feed.png | Bin 0 -> 66382 bytes .../images/screenshots/volunteer/friends.png | Bin 0 -> 65050 bytes .../screenshots/volunteer/notifications.png | Bin 0 -> 60735 bytes .../images/screenshots/volunteer/profile.png | Bin 0 -> 68676 bytes .../images/screenshots/volunteer/routes.png | Bin 0 -> 223171 bytes .../images/screenshots/volunteer/shifts.png | Bin 0 -> 70760 bytes mkdocs/docs/docs/volunteer/achievements.md | 4 + mkdocs/docs/docs/volunteer/canvassing.md | 8 ++ mkdocs/docs/docs/volunteer/index.md | 6 + mkdocs/docs/docs/volunteer/shifts.md | 4 + mkdocs/docs/docs/volunteer/social.md | 16 +++ scripts/build-release.sh | 1 + 32 files changed, 247 insertions(+), 238 deletions(-) delete mode 100644 SECURITY_REDTEAM_2026-04-12.md create mode 100644 admin/src/lib/media-url.ts create mode 100755 api/media-docker-entrypoint.sh create mode 100644 mkdocs/docs/assets/images/screenshots/volunteer/achievements-mobile.png create mode 100644 mkdocs/docs/assets/images/screenshots/volunteer/achievements.png create mode 100644 mkdocs/docs/assets/images/screenshots/volunteer/activity.png create mode 100644 mkdocs/docs/assets/images/screenshots/volunteer/canvass-map-mobile.png create mode 100644 mkdocs/docs/assets/images/screenshots/volunteer/canvass-map.png create mode 100644 mkdocs/docs/assets/images/screenshots/volunteer/dashboard-mobile.png create mode 100644 mkdocs/docs/assets/images/screenshots/volunteer/dashboard.png create mode 100644 mkdocs/docs/assets/images/screenshots/volunteer/discover.png create mode 100644 mkdocs/docs/assets/images/screenshots/volunteer/feed.png create mode 100644 mkdocs/docs/assets/images/screenshots/volunteer/friends.png create mode 100644 mkdocs/docs/assets/images/screenshots/volunteer/notifications.png create mode 100644 mkdocs/docs/assets/images/screenshots/volunteer/profile.png create mode 100644 mkdocs/docs/assets/images/screenshots/volunteer/routes.png create mode 100644 mkdocs/docs/assets/images/screenshots/volunteer/shifts.png diff --git a/SECURITY_REDTEAM_2026-04-12.md b/SECURITY_REDTEAM_2026-04-12.md deleted file mode 100644 index 79c01537..00000000 --- a/SECURITY_REDTEAM_2026-04-12.md +++ /dev/null @@ -1,134 +0,0 @@ -# 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/src/components/media/AlbumCard.tsx b/admin/src/components/media/AlbumCard.tsx index e844e787..63be9b47 100644 --- a/admin/src/components/media/AlbumCard.tsx +++ b/admin/src/components/media/AlbumCard.tsx @@ -1,17 +1,8 @@ import { Card, Tag, Badge } from 'antd'; import { FolderOpenOutlined, GlobalOutlined, PictureOutlined } from '@ant-design/icons'; -import { getAuthCallbacks } from '@/lib/api'; +import { useSignedMediaUrl } from '@/lib/media-url'; import type { PhotoAlbum } from '@/types/media'; -/** Append JWT access token as query param for src URLs */ -function getAuthenticatedUrl(url: string): string { - const { getAccessToken } = getAuthCallbacks(); - const accessToken = getAccessToken(); - if (!accessToken) return url; - const separator = url.includes('?') ? '&' : '?'; - return `${url}${separator}token=${accessToken}`; -} - interface AlbumCardProps { album: PhotoAlbum; onClick?: (album: PhotoAlbum) => void; @@ -19,6 +10,7 @@ interface AlbumCardProps { export default function AlbumCard({ album, onClick }: AlbumCardProps) { const coverUrl = album.coverThumbnailUrl; + const signedCoverUrl = useSignedMediaUrl(coverUrl); return ( - {coverUrl ? ( + {coverUrl && signedCoverUrl ? ( {album.title} src URLs */ -function getAuthenticatedUrl(url: string): string { - const { getAccessToken } = getAuthCallbacks(); - const accessToken = getAccessToken(); - if (!accessToken) return url; - const separator = url.includes('?') ? '&' : '?'; - return `${url}${separator}token=${accessToken}`; +function PhotoThumbnail({ url, alt }: { url: string; alt: string }) { + const signed = useSignedMediaUrl(url); + if (!signed) { + return ( +
+ ); + } + return ( + {alt} + ); } interface AlbumDetailDrawerProps { @@ -200,13 +210,7 @@ export default function AlbumDetailDrawer({ albumId, open, onClose, onRefresh }: + ) : (
diff --git a/admin/src/components/media/PhotoCard.tsx b/admin/src/components/media/PhotoCard.tsx index c1d14a92..337cab31 100644 --- a/admin/src/components/media/PhotoCard.tsx +++ b/admin/src/components/media/PhotoCard.tsx @@ -8,18 +8,9 @@ import { FolderOutlined, PictureOutlined, } from '@ant-design/icons'; -import { getAuthCallbacks } from '@/lib/api'; +import { useSignedMediaUrl } from '@/lib/media-url'; import type { Photo } from '@/types/media'; -/** Append JWT access token as query param for src URLs */ -function getAuthenticatedUrl(url: string): string { - const { getAccessToken } = getAuthCallbacks(); - const accessToken = getAccessToken(); - if (!accessToken) return url; - const separator = url.includes('?') ? '&' : '?'; - return `${url}${separator}token=${accessToken}`; -} - interface PhotoCardProps { photo: Photo; selected?: boolean; @@ -50,6 +41,7 @@ export default function PhotoCard({ onTogglePublish, }: PhotoCardProps) { const thumbnailUrl = photo.thumbnailUrl; + const signedThumbnailUrl = useSignedMediaUrl(thumbnailUrl); const hoverActions = (
- {thumbnailUrl ? ( + {thumbnailUrl && signedThumbnailUrl ? ( {photo.title src URLs */ -function getAuthenticatedUrl(url: string): string { - const { getAccessToken } = getAuthCallbacks(); - const accessToken = getAccessToken(); - if (!accessToken) return url; - const separator = url.includes('?') ? '&' : '?'; - return `${url}${separator}token=${accessToken}`; -} - interface PhotoViewerModalProps { photo: Photo | null; open: boolean; @@ -22,9 +13,10 @@ export default function PhotoViewerModal({ photo, open, onClose }: PhotoViewerMo const screens = Grid.useBreakpoint(); const isMobile = !screens.md; - if (!photo) return null; + const adminImageUrl = photo ? `/media/photos/${photo.id}/image?size=large` : null; + const signedImageUrl = useSignedMediaUrl(adminImageUrl); - const adminImageUrl = `/media/photos/${photo.id}/image?size=large`; + if (!photo) return null; return ( {photo.title/