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
This commit is contained in:
bunker-admin 2026-04-12 15:17:00 -06:00
parent 26ec925d9b
commit e55bc07eb6
59 changed files with 1387 additions and 510 deletions

View File

@ -46,20 +46,27 @@ JWT_ACCESS_SECRET=GENERATE_WITH_openssl_rand_hex_32
JWT_REFRESH_SECRET=GENERATE_WITH_openssl_rand_hex_32
JWT_INVITE_SECRET=GENERATE_WITH_openssl_rand_hex_32
JWT_ACCESS_EXPIRY=15m
JWT_REFRESH_EXPIRY=7d
# Reduced from 7d → 24h on 2026-04-12 (P2-3 hardening). Combined with
# device-fingerprint binding in the JWT payload, this tightens the
# exploitation window for stolen refresh tokens.
JWT_REFRESH_EXPIRY=24h
# Encryption key for DB-stored secrets (SMTP password, etc.)
# REQUIRED in production — must NOT reuse JWT_ACCESS_SECRET
# Generate with: openssl rand -hex 32
ENCRYPTION_KEY=GENERATE_WITH_openssl_rand_hex_32
# Gitea SSO cookie signing secret (separate from JWT — falls back to JWT_ACCESS_SECRET if empty)
# Generate with: openssl rand -hex 32
GITEA_SSO_SECRET=
# Salt for deriving deterministic service passwords (Gitea, Rocket.Chat)
# Falls back to JWT_ACCESS_SECRET if empty — set a dedicated value to isolate secret rotation
# Generate with: openssl rand -hex 32
SERVICE_PASSWORD_SALT=
# BREAKING CHANGE (2026-04-12): both GITEA_SSO_SECRET and SERVICE_PASSWORD_SALT
# are now REQUIRED (min 32 chars). The previous fallback to JWT_ACCESS_SECRET
# has been removed — a JWT leak must not compromise SSO cookies or service
# account passwords. Both values must be distinct from each other and from
# all JWT_* secrets. Generate with: openssl rand -hex 32
# Gitea SSO cookie signing secret (required, ≥32 chars, distinct from JWT secrets)
GITEA_SSO_SECRET=GENERATE_WITH_openssl_rand_hex_32
# Salt for deriving deterministic service passwords (Gitea, Rocket.Chat).
# Required, ≥32 chars, distinct from all other secrets.
SERVICE_PASSWORD_SALT=GENERATE_WITH_openssl_rand_hex_32
# --- Initial Super Admin User (auto-created during database seeding) ---
# These credentials are used to create the initial super admin account

View File

@ -43,7 +43,7 @@ All three methods share the same Gitea container registry at `gitea.bnkops.com/a
│ Step 3: ./scripts/build-release.sh --tag vX.Y.Z --upload │
│ Packages runtime files into ~9MB tarball, uploads to │
│ Gitea Releases │
└──────────────────┬───────────────────────────────────────────────┘
└──────────────────┬─────────────────100.90.78.47──────────────────────────────┘
┌───────────┴───────────┐
▼ ▼

View File

@ -0,0 +1,134 @@
# 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.

View File

@ -36,6 +36,7 @@
"html5-qrcode": "^2.3.8",
"jwt-decode": "^4.0.0",
"leaflet": "^1.9.4",
"leaflet.heat": "^0.2.0",
"minisearch": "^7.2.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@ -2722,6 +2723,11 @@
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
},
"node_modules/leaflet.heat": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/leaflet.heat/-/leaflet.heat-0.2.0.tgz",
"integrity": "sha512-Cd5PbAA/rX3X3XKxfDoUGi9qp78FyhWYurFg3nsfhntcM/MCNK08pRkf4iEenO1KNqwVPKCmkyktjW3UD+h9bQ=="
},
"node_modules/leaflet.markercluster": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz",

View File

@ -37,6 +37,7 @@
"html5-qrcode": "^2.3.8",
"jwt-decode": "^4.0.0",
"leaflet": "^1.9.4",
"leaflet.heat": "^0.2.0",
"minisearch": "^7.2.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",

View File

@ -35,42 +35,48 @@ export function useChatNotifications() {
return;
}
// Use relative URL through nginx proxy
const url = `/media/media/notifications/stream?token=${encodeURIComponent(accessToken)}`;
const es = new EventSource(url);
// Obtain a short-lived signed URL (2026-04-12). Previously we put the full
// access-token JWT in the URL, which leaked via access logs / referer.
let es: EventSource | null = null;
let cancelled = false;
es.onmessage = (event) => {
const wire = (src: EventSource) => {
src.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'connected') return;
if (data.type === 'chat_reply') {
const notif: ChatNotification = {
...data,
id: `notif-${++notifCounterRef.current}-${Date.now()}`,
receivedAt: Date.now(),
};
setNotifications((prev) => [...prev, notif].slice(-10));
}
} catch { /* ignore parse errors */ }
};
src.onerror = () => { /* EventSource handles reconnect */ };
};
(async () => {
try {
const data = JSON.parse(event.data);
if (data.type === 'connected') return;
if (data.type === 'chat_reply') {
const notif: ChatNotification = {
...data,
id: `notif-${++notifCounterRef.current}-${Date.now()}`,
receivedAt: Date.now(),
};
setNotifications((prev) => {
// Keep max 10 notifications
const updated = [...prev, notif];
return updated.slice(-10);
});
}
} catch {
// Ignore parse errors
}
};
es.onerror = () => {
// Auto-reconnect is handled by EventSource
};
eventSourceRef.current = es;
const signRes = await fetch('/media/api/media/sign', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}` },
body: JSON.stringify({ path: '/api/media/notifications/stream', ttlSeconds: 300 }),
});
if (!signRes.ok || cancelled) return;
const { url: signedPath } = await signRes.json();
if (cancelled) return;
es = new EventSource(`/media${signedPath}`);
eventSourceRef.current = es;
wire(es);
} catch { /* notifications unavailable */ }
})();
return () => {
es.close();
cancelled = true;
es?.close();
eventSourceRef.current = null;
};
}, [isAuthenticated, accessToken]);

View File

@ -70,7 +70,7 @@ export function useSSE() {
useSocialStore.getState().fetchFriends();
}, []);
const connect = useCallback(() => {
const connect = useCallback(async () => {
if (!accessToken || !enableSocial) return;
// Close existing connection if any
@ -79,36 +79,48 @@ export function useSSE() {
esRef.current = null;
}
const url = `/api/social/sse?token=${encodeURIComponent(accessToken)}`;
// Obtain a signed URL instead of embedding the full JWT in the query
// string (2026-04-12 — see utils/signed-url.ts for rationale).
let url: string;
try {
const signRes = await fetch('/media/api/media/sign', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}` },
body: JSON.stringify({ path: '/api/social/sse', ttlSeconds: 300 }),
});
if (!signRes.ok) throw new Error('sign failed');
const { url: signedPath } = await signRes.json();
url = signedPath; // absolute path, nginx routes /api/social to Express
} catch {
return; // SSE unavailable; non-critical
}
const es = new EventSource(url);
esRef.current = es;
es.addEventListener('connected', () => {
retryCount.current = 0; // Reset backoff on successful connect
retryCount.current = 0;
});
es.addEventListener('notification', handleNotification);
es.addEventListener('presence', handlePresence);
es.addEventListener('friend_request', handleFriendRequest);
es.addEventListener('friend_accepted', handleFriendAccepted);
es.addEventListener('poke', handleNotification); // Pokes also increment notification count
es.addEventListener('poke', handleNotification);
es.onerror = () => {
es.close();
esRef.current = null;
// Exponential backoff: 1s, 2s, 4s, 8s, ... up to 30s
const delay = Math.min(1000 * 2 ** retryCount.current, maxRetryDelay);
retryCount.current++;
retryRef.current = setTimeout(connect, delay);
retryRef.current = setTimeout(() => { void connect(); }, delay);
};
}, [accessToken, enableSocial, handleNotification, handlePresence, handleFriendRequest, handleFriendAccepted]);
useEffect(() => {
if (!isAuthenticated || !enableSocial) return;
connect();
void connect();
// Fetch initial online friends list
useSocialStore.getState().fetchOnlineFriends();

View File

@ -13,6 +13,7 @@ import { CampaignFormWidget } from '@/components/influence/CampaignFormWidget';
import { SchedulingPollWidget } from '@/components/scheduling/SchedulingPollWidget';
import GalleryAdCard from '@/components/media/GalleryAdCard';
import type { GalleryAd } from '@/types/gallery-ads';
import { sanitizeLandingHtml, sanitizeLandingCss } from '@/utils/sanitize';
export default function PublicLandingPage() {
const { slug } = useParams<{ slug: string }>();
@ -413,12 +414,18 @@ export default function PublicLandingPage() {
);
}
// HTML/CSS is admin-authored via GrapesJS editor (not user-submitted content).
// Only authenticated admins can create/edit pages, so XSS risk is accepted.
// HTML/CSS is admin-authored via GrapesJS, but we still DOMPurify-sanitize as
// defense-in-depth (2026-04-12): compromised admin accounts / stored XSS via
// embeddable widgets would otherwise reach every public visitor.
return (
<>
{page.cssOutput && <style dangerouslySetInnerHTML={{ __html: page.cssOutput }} />}
<div ref={contentRef} dangerouslySetInnerHTML={{ __html: page.htmlOutput || '' }} />
{page.cssOutput && (
<style dangerouslySetInnerHTML={{ __html: sanitizeLandingCss(page.cssOutput) }} />
)}
<div
ref={contentRef}
dangerouslySetInnerHTML={{ __html: sanitizeLandingHtml(page.htmlOutput || '') }}
/>
</>
);
}

View File

@ -1,9 +1,10 @@
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { ConfigProvider, Spin, theme, Button, Tooltip, message, Result, Grid } from 'antd';
import { useNavigate } from 'react-router-dom';
import { AimOutlined, FullscreenOutlined, FullscreenExitOutlined } from '@ant-design/icons';
import { MapContainer, TileLayer, CircleMarker, Popup, useMap, useMapEvents } from 'react-leaflet';
import type { Map as LeafletMap } from 'leaflet';
import { MapContainer, TileLayer, CircleMarker, useMap, useMapEvents } from 'react-leaflet';
import L, { type Map as LeafletMap } from 'leaflet';
import 'leaflet.heat';
import axios from 'axios';
// Extend Leaflet Map type to include private animation properties
@ -15,9 +16,7 @@ declare module 'leaflet' {
}
import { useSettingsStore } from '@/stores/settings.store';
import PublicNavBar from '@/components/PublicNavBar';
import type { MapSettings, Location, PublicCut, MapEvent } from '@/types/api';
import { groupLocations, getMarkerColor } from '@/components/map/mapUtils';
import MapLegend from '@/components/map/MapLegend';
import type { MapSettings, PublicCut, MapEvent } from '@/types/api';
import CutOverlays from '@/components/map/CutOverlays';
import CutOverlayControls from '@/components/map/CutOverlayControls';
import EventMarkers from '@/components/map/EventMarkers';
@ -31,6 +30,34 @@ type BoundsQuery = {
maxLng: number;
};
/**
* Public map point type aggregated heatmap bucket, ~1.1km precision.
* Individual resident locations are never exposed on the public map.
*/
type HeatPoint = { lat: number; lng: number; count: number };
type HeatmapResponse = { points: HeatPoint[] };
/**
* HeatLayer: renders aggregated location buckets as a Leaflet heatmap overlay.
* Uses leaflet.heat; maxCount is passed as `max` so colors scale to the current dataset.
*/
function HeatLayer({ points }: { points: HeatPoint[] }) {
const map = useMap();
useEffect(() => {
if (!points.length) return;
const maxCount = points.reduce((m, p) => (p.count > m ? p.count : m), 1);
const layer = L.heatLayer(
points.map((p) => [p.lat, p.lng, p.count] as [number, number, number]),
{ radius: 25, blur: 20, maxZoom: 14, max: maxCount }
);
layer.addTo(map);
return () => {
map.removeLayer(layer);
};
}, [map, points]);
return null;
}
function FlyToPosition({ position }: { position: [number, number] }) {
const map = useMap();
useEffect(() => {
@ -93,7 +120,7 @@ function CenterOnSettings({ settings }: { settings: MapSettings | null }) {
export default function MapPage() {
const [settings, setSettings] = useState<MapSettings | null>(null);
const [locations, setLocations] = useState<Location[]>([]);
const [heatPoints, setHeatPoints] = useState<HeatPoint[]>([]);
const [loading, setLoading] = useState(true);
const [mapDisabled, setMapDisabled] = useState(false);
const [loadingLocations, setLoadingLocations] = useState(false);
@ -132,17 +159,15 @@ export default function MapPage() {
minLng: b.minLng.toString(),
maxLng: b.maxLng.toString(),
});
const res = await axios.get<Location[]>(`/api/map/locations/public?${params}`, {
const res = await axios.get<HeatmapResponse>(`/api/map/locations/public?${params}`, {
signal: controller.signal,
});
// Check if we hit the safety limit
const limitHit = res.headers['x-location-limit-hit'] === 'true';
if (limitHit) {
if (res.headers['x-location-bucket-limit-hit'] === 'true') {
message.warning('Too many locations in view. Zoom in for more detail.', 3);
}
setLocations(res.data);
setHeatPoints(res.data.points ?? []);
} catch (err) {
if (!axios.isCancel(err)) {
message.error('Failed to load locations');
@ -218,8 +243,6 @@ export default function MapPage() {
};
}, []);
const groups = useMemo(() => groupLocations(locations as any), [locations]);
const center: [number, number] = settings?.latitude && settings?.longitude
? [parseFloat(settings.latitude), parseFloat(settings.longitude)]
: [45.4215, -75.6972];
@ -402,56 +425,11 @@ export default function MapPage() {
<EventMarkers events={events} />
)}
{settings?.publicShowLocations !== false && groups.map((group, idx) => {
const color = settings?.publicShowSupportLevels !== false
? getMarkerColor(group.dominantLevel)
: '#888';
const radius = group.isMultiUnit ? 10 : 7;
const showAddr = settings?.publicShowAddresses !== false;
return (
<CircleMarker
key={idx}
center={[group.latitude, group.longitude]}
radius={radius}
pathOptions={{
fillColor: color,
fillOpacity: 0.8,
color: '#fff',
weight: group.isMultiUnit ? 2 : 1,
opacity: 0.9,
}}
>
<Popup>
<div style={{ minWidth: 180, maxWidth: 280 }}>
{group.isMultiUnit ? (
<>
<div style={{ fontWeight: 600, fontSize: 14, color: '#a02c8d' }}>
{showAddr ? (group.location.address || 'Multi-Unit Building') : 'Multi-Unit Location'}
</div>
<div style={{ fontSize: 12, color: '#666', marginTop: 2 }}>
{group.location.addresses.length} units at this location
</div>
</>
) : (
<div>
<div style={{ fontWeight: 600, fontSize: 13 }}>
{showAddr ? (group.location.address || 'Location') : 'Location'}
</div>
{showAddr && group.location.addresses[0]?.unitNumber && (
<div style={{ fontSize: 12, color: '#666' }}>Unit {group.location.addresses[0].unitNumber}</div>
)}
</div>
)}
</div>
</Popup>
</CircleMarker>
);
})}
{/* Aggregated heatmap (privacy-preserving: ~1.1km buckets, no PII). */}
{settings?.publicShowLocations !== false && heatPoints.length > 0 && (
<HeatLayer points={heatPoints} />
)}
</MapContainer>
{settings?.publicShowLocations !== false && settings?.publicShowSupportLevels !== false && (
<MapLegend variant="public" />
)}
{/* Cut overlay controls */}
{settings?.publicShowCuts !== false && cuts.length > 0 && (

View File

@ -2,11 +2,11 @@ import { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom';
import {
Typography, Card, Button, Input, Checkbox, Form, Spin, Result, Progress,
Space, Divider, List, Avatar, Grid, theme, message,
Space, Divider, Grid, theme, message,
} from 'antd';
import {
TeamOutlined, CheckCircleFilled, ShareAltOutlined,
CopyOutlined, EnvironmentOutlined, ArrowRightOutlined,
CopyOutlined, ArrowRightOutlined,
SendOutlined,
} from '@ant-design/icons';
import axios from 'axios';
@ -31,7 +31,6 @@ export default function PetitionPage() {
const [state, setState] = useState<PageState>('form');
const [submitting, setSubmitting] = useState(false);
const [signatureCount, setSignatureCount] = useState(0);
const [recentSigners, setRecentSigners] = useState<any[]>([]);
const [form] = Form.useForm();
const { settings: siteSettings } = useSettingsStore();
@ -42,12 +41,6 @@ export default function PetitionPage() {
const { data } = await axios.get(`${API}/petitions/${slug}/details`);
setPetition(data);
setSignatureCount(data._count.signatures + data.signatureCountOffset);
// Fetch recent signers
try {
const { data: sigData } = await axios.get(`${API}/petitions/${slug}/signers`, { params: { limit: 10 } });
setRecentSigners(sigData.signatures || []);
} catch { /* non-critical */ }
} catch {
setError(true);
} finally {
@ -255,31 +248,8 @@ export default function PetitionPage() {
</Card>
)}
{/* Recent signers */}
{petition.showSignerNames && recentSigners.length > 0 && (
<Card title="Recent Supporters" style={{ marginBottom: 24, background: token.colorBgContainer }}>
<List
dataSource={recentSigners}
renderItem={(signer: any) => (
<List.Item style={{ padding: '8px 0' }}>
<List.Item.Meta
avatar={<Avatar size="small" icon={<TeamOutlined />} style={{ background: token.colorPrimary }} />}
title={signer.displayName || 'Anonymous'}
description={
<Space size={4}>
{(signer.geoCity || signer.geoCountry) && (
<Text type="secondary" style={{ fontSize: 12 }}>
<EnvironmentOutlined /> {[signer.geoCity, signer.geoCountry].filter(Boolean).join(', ')}
</Text>
)}
</Space>
}
/>
</List.Item>
)}
/>
</Card>
)}
{/* Recent-signers card removed 2026-04-12: public endpoint no longer exposes
signer names/locations (privacy hardening). Admins can see the full list. */}
{/* Share bar (always visible) */}
{state === 'form' && (

View File

@ -362,23 +362,11 @@ export default function ResponseWallPage() {
{response.responseText}
</Paragraph>
{response.userComment && (
<div style={{
marginTop: 12,
padding: '8px 12px',
background: 'rgba(255,255,255,0.04)',
borderRadius: 6,
borderLeft: `3px solid ${token.colorPrimary}`,
}}>
<Text type="secondary" style={{ fontSize: 12 }}>Comment: </Text>
<Text style={{ fontSize: 13 }}>{response.userComment}</Text>
</div>
)}
{/* userComment + submittedByName removed from public response wall
on 2026-04-12 (privacy hardening). Admin moderation UI still shows them. */}
<div style={{ marginTop: 12 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
{response.isAnonymous ? 'Anonymous' : response.submittedByName || 'Anonymous'}
{' '}&middot;{' '}
{dayjs(response.createdAt).format('MMM D, YYYY')}
</Text>
</div>

View File

@ -393,9 +393,10 @@ export interface RepresentativeResponse {
representativeEmail: string | null;
responseType: ResponseType;
responseText: string;
userComment: string | null;
submittedByName: string | null;
submittedByEmail: string | null;
userComment?: string | null; // Admin-only on public response wall (2026-04-12)
submittedByName?: string | null; // Admin-only on public response wall (2026-04-12)
submittedByEmail?: string | null;
isAnonymous: boolean;
status: ResponseStatus;
isVerified: boolean;
@ -3573,7 +3574,7 @@ export interface PetitionStats {
percentComplete: number | null;
byCountry: Record<string, number>;
byRegion: Record<string, number>;
recentSigners: { displayName: string | null; geoCity: string | null; geoCountry: string | null; createdAt: string }[];
// recentSigners removed 2026-04-12: see petitions.service.ts for rationale.
}
export interface PetitionsListResponse {

26
admin/src/types/leaflet-heat.d.ts vendored Normal file
View File

@ -0,0 +1,26 @@
// Ambient type declaration for leaflet.heat (no @types package published).
// See: https://github.com/Leaflet/Leaflet.heat
import 'leaflet';
declare module 'leaflet' {
interface HeatLayerOptions {
minOpacity?: number;
maxZoom?: number;
max?: number;
radius?: number;
blur?: number;
gradient?: Record<number, string>;
}
interface HeatLayer extends Layer {
setLatLngs(latlngs: Array<[number, number, number?]>): this;
addLatLng(latlng: [number, number, number?]): this;
setOptions(options: HeatLayerOptions): this;
redraw(): this;
}
function heatLayer(
latlngs: Array<[number, number, number?]>,
options?: HeatLayerOptions
): HeatLayer;
}

View File

@ -13,3 +13,32 @@ export function sanitizeHtml(dirty: string): string {
ALLOWED_ATTR: [],
});
}
/**
* Sanitizes GrapesJS-authored landing page HTML. Permissive enough to preserve
* layout (divs, sections, inline styles, classes, data-attrs) while stripping
* <script>, event-handler attributes, and javascript:/data: URLs.
*
* Added 2026-04-12 as defense-in-depth: though only authenticated admins can
* author these pages, a compromised admin account or stored XSS via a widget
* embed would otherwise reach every public visitor.
*/
export function sanitizeLandingHtml(dirty: string): string {
return DOMPurify.sanitize(dirty, {
// Preserve custom widget placeholders that LandingPage.tsx hydrates via refs.
ADD_TAGS: ['video-player', 'advanced-video-player', 'donation-widget',
'pricing-widget', 'product-widget', 'campaign-form-widget',
'scheduling-poll-widget', 'gallery-ad-card'],
ADD_ATTR: ['target'],
FORBID_TAGS: ['script', 'style', 'iframe', 'object', 'embed'],
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur'],
});
}
/** CSS sanitizer: strips `@import`, `expression(...)`, and url(javascript:...) vectors. */
export function sanitizeLandingCss(dirty: string): string {
return dirty
.replace(/@import[^;]*;?/gi, '')
.replace(/expression\s*\([^)]*\)/gi, '')
.replace(/url\s*\(\s*['"]?\s*javascript:[^)]*\)/gi, '');
}

View File

@ -33,15 +33,21 @@ const envSchema = z.object({
JWT_REFRESH_SECRET: z.string().min(32),
JWT_INVITE_SECRET: z.string().min(32),
JWT_ACCESS_EXPIRY: z.string().default('15m'),
JWT_REFRESH_EXPIRY: z.string().default('7d'),
// Reduced 2026-04-12 from 7d → 24h. Stolen refresh tokens have a much tighter
// exploitation window now; combined with device-fingerprint binding in
// auth.service.ts, theft is materially harder to monetize.
JWT_REFRESH_EXPIRY: z.string().default('24h'),
// Encryption (for DB-stored secrets like SMTP password — required for all environments)
ENCRYPTION_KEY: z.string().min(32, 'ENCRYPTION_KEY must be at least 32 characters'),
// Gitea SSO cookie signing secret — MUST be unique (key separation from JWT)
GITEA_SSO_SECRET: z.string().default(''),
// Salt for deriving deterministic service passwords (Gitea, Rocket.Chat) — MUST be unique
SERVICE_PASSWORD_SALT: z.string().default(''),
// Gitea SSO cookie signing secret — MUST be distinct from JWT secrets.
// Breaking change 2026-04-12: previously fell back to JWT_ACCESS_SECRET, which
// meant a JWT leak compromised SSO cookies too. Now required (min 32 chars).
GITEA_SSO_SECRET: z.string().min(32, 'GITEA_SSO_SECRET must be ≥32 chars; generate with: openssl rand -hex 32'),
// Salt for deriving deterministic service passwords (Gitea, Rocket.Chat).
// Breaking change 2026-04-12: previously fell back to JWT_ACCESS_SECRET. Now required.
SERVICE_PASSWORD_SALT: z.string().min(32, 'SERVICE_PASSWORD_SALT must be ≥32 chars; generate with: openssl rand -hex 32'),
// Initial Super Admin (auto-created during database seeding)
INITIAL_ADMIN_EMAIL: z.string().email().default('admin@cmlite.org'),
@ -276,16 +282,10 @@ function validateEnv(): Env {
process.exit(1);
}
// Warn about security-critical key separation issues
const data = result.data;
if (!data.GITEA_SSO_SECRET) {
console.warn('⚠ SECURITY WARNING: GITEA_SSO_SECRET is empty — falling back to JWT_ACCESS_SECRET. This violates key separation. Generate a unique secret with: openssl rand -hex 32');
}
if (!data.SERVICE_PASSWORD_SALT) {
console.warn('⚠ SECURITY WARNING: SERVICE_PASSWORD_SALT is empty — falling back to JWT_ACCESS_SECRET. Rotating JWT_ACCESS_SECRET will invalidate all provisioned service passwords. Generate a unique salt with: openssl rand -hex 32');
}
return data;
// GITEA_SSO_SECRET and SERVICE_PASSWORD_SALT are now validated as required
// via .min(32) above — no more silent JWT_ACCESS_SECRET fallback. If either is
// missing, the schema check above exits with a clear error.
return result.data;
}
export const env = validateEnv();

View File

@ -142,6 +142,9 @@ const start = async () => {
await fastify.register(chatStreamRoutes, { prefix: '/api' });
await fastify.register(commentAdminRoutes, { prefix: '/api/media' });
await fastify.register(chatNotificationsRoutes, { prefix: '/api/media' });
// Signed URL generation (replaces ?token=JWT pattern, 2026-04-12).
const { signRoutes } = await import('./modules/media/routes/sign.routes');
await fastify.register(signRoutes, { prefix: '/api/media' });
await fastify.register(chatThreadsRoutes, { prefix: '/api/media' });
await fastify.register(userProfileRoutes, { prefix: '/api/media' });
await fastify.register(fetchRoutes, { prefix: '/api/videos' });

View File

@ -1,14 +1,35 @@
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
import { createHash } from 'crypto';
import type { Request } from 'express';
import { redis } from '../../config/redis';
/** 3 requests per hour for resending verification emails */
/**
* Generate a rate-limit key combining both IP AND target email (2026-04-12).
*
* Pure IP rate limits can be bypassed by rotating IPs (easy on mobile/VPN),
* and pure email rate limits can be DoS'd by an attacker hitting every known
* email from many IPs to lock legitimate users out. Combining both means:
* - a single IP can't hammer a single email beyond the limit
* - a single IP still can't spray many different emails beyond a wider cap
* The email is hashed to keep it out of Redis in plaintext.
*/
function keyForEmailAndIp(prefix: string) {
return (req: Request): string => {
const email = typeof req.body?.email === 'string' ? req.body.email.toLowerCase().trim() : '';
const emailHash = email ? createHash('sha256').update(email).digest('hex').slice(0, 16) : 'noemail';
return `${prefix}:${req.ip}:${emailHash}`;
};
}
/** 3 requests per hour per (IP, email) pair for resending verification emails */
export function createVerificationRateLimit() {
return rateLimit({
windowMs: 60 * 60 * 1000,
max: 3,
standardHeaders: true,
legacyHeaders: false,
keyGenerator: keyForEmailAndIp('verify'),
store: new RedisStore({
sendCommand: (command: string, ...args: string[]) => redis.call(command, ...args) as Promise<any>,
prefix: 'rl:verify-resend:',
@ -22,13 +43,14 @@ export function createVerificationRateLimit() {
});
}
/** 3 requests per hour for password reset emails */
/** 3 requests per hour per (IP, email) pair for password reset emails */
export function createResetRateLimit() {
return rateLimit({
windowMs: 60 * 60 * 1000,
max: 3,
standardHeaders: true,
legacyHeaders: false,
keyGenerator: keyForEmailAndIp('reset'),
store: new RedisStore({
sendCommand: (command: string, ...args: string[]) => redis.call(command, ...args) as Promise<any>,
prefix: 'rl:password-reset:',

View File

@ -17,11 +17,12 @@ import { env } from '../../config/env';
import { logger } from '../../utils/logger';
import { createVerificationRateLimit, createResetRateLimit } from './auth.rate-limits';
import { profileService } from '../people/profile.service';
import { computeDeviceFingerprint } from '../../utils/device-fingerprint';
const router = Router();
const REFRESH_COOKIE_NAME = 'cml_refresh';
const REFRESH_COOKIE_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days in ms
const REFRESH_COOKIE_MAX_AGE = 24 * 60 * 60 * 1000; // 24 hours in ms (matches JWT_REFRESH_EXPIRY default)
const SESSION_COOKIE_NAME = 'cml_session';
const SESSION_COOKIE_MAX_AGE = 30 * 60 * 1000; // 30 min buffer (JWT inside enforces 15min expiry)
@ -77,7 +78,7 @@ async function setSessionCookie(req: Request, res: Response, userId: string) {
const giteaUser = permissions._giteaUsername as string | undefined;
if (!giteaUser) return; // Not provisioned — skip
const ssoSecret = env.GITEA_SSO_SECRET || env.JWT_ACCESS_SECRET;
const ssoSecret = env.GITEA_SSO_SECRET;
const token = jwt.sign(
{ sub: userId, giteaUser },
ssoSecret,
@ -103,7 +104,7 @@ router.post(
validate(loginSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await authService.login(req.body.email, req.body.password);
const result = await authService.login(req.body.email, req.body.password, computeDeviceFingerprint(req));
// Set refresh token as httpOnly cookie (not in response body)
setRefreshCookie(req, res, result.refreshToken);
// Set SSO session cookie for Gitea reverse proxy auth
@ -123,7 +124,7 @@ router.post(
validate(registerSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await authService.register(req.body);
const result = await authService.register(req.body, computeDeviceFingerprint(req));
// Set refresh token as httpOnly cookie if tokens were issued (non-verification path)
if ('refreshToken' in result && result.refreshToken) {
setRefreshCookie(req, res, result.refreshToken);
@ -320,18 +321,20 @@ router.post(
);
// POST /api/auth/refresh
// Accepts refresh token from httpOnly cookie (preferred) or request body (legacy/backward compat)
// Accepts refresh token from httpOnly cookie ONLY (2026-04-12: the legacy
// request-body fallback was removed — cookies are HttpOnly+SameSite and
// cannot be read by XSS, while body tokens were reachable via any XSS).
router.post(
'/refresh',
authRateLimit,
async (req: Request, res: Response, next: NextFunction) => {
try {
const refreshToken = req.cookies?.[REFRESH_COOKIE_NAME] || req.body?.refreshToken;
const refreshToken = req.cookies?.[REFRESH_COOKIE_NAME];
if (!refreshToken) {
res.status(401).json({ error: { message: 'No refresh token', code: 'INVALID_REFRESH_TOKEN' } });
return;
}
const result = await authService.refreshTokens(refreshToken);
const result = await authService.refreshTokens(refreshToken, computeDeviceFingerprint(req));
// Set new refresh token as httpOnly cookie
setRefreshCookie(req, res, result.refreshToken);
// Renew SSO session cookie for Gitea reverse proxy auth
@ -347,14 +350,13 @@ router.post(
}
);
// POST /api/auth/logout
// Accepts refresh token from httpOnly cookie (preferred) or request body (legacy/backward compat)
// POST /api/auth/logout — cookie only (2026-04-12).
router.post(
'/logout',
authRateLimit,
async (req: Request, res: Response, next: NextFunction) => {
try {
const refreshToken = req.cookies?.[REFRESH_COOKIE_NAME] || req.body?.refreshToken;
const refreshToken = req.cookies?.[REFRESH_COOKIE_NAME];
if (refreshToken) {
await authService.logout(refreshToken);
}

View File

@ -10,6 +10,7 @@ import { verificationTokenService } from '../../services/verification-token.serv
import { emailService } from '../../services/email.service';
import { getPrimaryRole } from '../../utils/roles';
import { logger } from '../../utils/logger';
import { fingerprintsMatch } from '../../utils/device-fingerprint';
import type { RegisterInput } from './auth.schemas';
interface TokenPayload {
@ -17,6 +18,8 @@ interface TokenPayload {
email: string;
role: UserRole;
roles: UserRole[];
/** Device fingerprint (sha256 of UA + /24 IP subnet) — bound at issue time. */
df?: string;
}
export interface TokenPair {
@ -35,7 +38,7 @@ function parseRoles(user: UserForToken): UserRole[] {
}
export const authService = {
async login(email: string, password: string) {
async login(email: string, password: string, fingerprint?: string) {
const user = await prisma.user.findUnique({ where: { email } });
if (!user) {
recordLoginAttempt('failure');
@ -94,13 +97,13 @@ export const authService = {
logger.warn('Login activity logging failed:', err);
});
const tokens = await this.generateTokenPair(user);
const tokens = await this.generateTokenPair(user, fingerprint);
const { password: _, ...userWithoutPassword } = user;
return { user: userWithoutPassword, ...tokens };
},
async register(data: RegisterInput) {
async register(data: RegisterInput, fingerprint?: string) {
// Check if public registration is enabled
const settings = await siteSettingsService.get();
if (!settings.enablePublicRegistration) {
@ -192,13 +195,13 @@ export const authService = {
}
// No verification needed — issue tokens immediately
const tokens = await this.generateTokenPair(user);
const tokens = await this.generateTokenPair(user, fingerprint);
const { password: _, ...userWithoutPassword } = user;
return { user: userWithoutPassword, ...tokens };
},
async refreshTokens(refreshToken: string) {
async refreshTokens(refreshToken: string, currentFingerprint?: string) {
let payload: TokenPayload;
try {
payload = jwt.verify(refreshToken, env.JWT_REFRESH_SECRET, { algorithms: ['HS256'] }) as TokenPayload;
@ -206,6 +209,18 @@ export const authService = {
throw new AppError(401, 'Invalid refresh token', 'INVALID_REFRESH_TOKEN');
}
// Device-fingerprint binding (2026-04-12). Tokens issued after this change
// include a `df` claim; reject if the refreshing client's fingerprint doesn't
// match. Tokens issued before this change have no `df` — accept them once,
// then rotate into a bound token below (grace period for existing sessions).
if (payload.df && currentFingerprint && !fingerprintsMatch(payload.df, currentFingerprint)) {
// Potential token theft — revoke all refresh tokens for this user as a
// defense-in-depth measure, then deny.
await prisma.refreshToken.deleteMany({ where: { userId: payload.id } });
logger.warn('Refresh token fingerprint mismatch; all sessions revoked');
throw new AppError(401, 'Session security check failed', 'FINGERPRINT_MISMATCH');
}
const stored = await prisma.refreshToken.findUnique({
where: { token: refreshToken },
include: { user: true },
@ -243,6 +258,9 @@ export const authService = {
email: stored.user.email,
role: getPrimaryRole(userRoles),
roles: userRoles,
// Carry forward fingerprint from the client's current request so rotated
// tokens stay bound to the device.
df: currentFingerprint,
};
const refreshToken = jwt.sign(refreshPayload, env.JWT_REFRESH_SECRET, {
algorithm: 'HS256',
@ -286,13 +304,14 @@ export const authService = {
});
},
async generateRefreshToken(user: UserForToken): Promise<string> {
async generateRefreshToken(user: UserForToken, fingerprint?: string): Promise<string> {
const userRoles = parseRoles(user);
const payload: TokenPayload = {
id: user.id,
email: user.email,
role: getPrimaryRole(userRoles),
roles: userRoles,
df: fingerprint,
};
const token = jwt.sign(payload, env.JWT_REFRESH_SECRET, {
algorithm: 'HS256',
@ -313,9 +332,9 @@ export const authService = {
return token;
},
async generateTokenPair(user: UserForToken): Promise<TokenPair> {
async generateTokenPair(user: UserForToken, fingerprint?: string): Promise<TokenPair> {
const accessToken = this.generateAccessToken(user);
const refreshToken = await this.generateRefreshToken(user);
const refreshToken = await this.generateRefreshToken(user, fingerprint);
return { accessToken, refreshToken };
},
};

View File

@ -29,7 +29,7 @@ router.get('/gitea-sso-validate', (req: Request, res: Response) => {
}
try {
const ssoSecret = env.GITEA_SSO_SECRET || env.JWT_ACCESS_SECRET;
const ssoSecret = env.GITEA_SSO_SECRET;
const payload = jwt.verify(token, ssoSecret, {
algorithms: ['HS256'],
}) as SsoPayload;

View File

@ -1,5 +1,6 @@
import { Router, Request, Response, NextFunction } from 'express';
import { campaignEmailsService } from './campaign-emails.service';
import { campaignsService } from '../campaigns/campaigns.service';
import {
sendCampaignEmailSchema,
trackMailtoSchema,
@ -53,13 +54,15 @@ const adminRouter = Router();
adminRouter.use(authenticate);
adminRouter.use(requireRole(...INFLUENCE_ROLES));
// GET /api/campaigns/:id/emails
// GET /api/campaigns/:id/emails — requires ownership (SUPER_ADMIN bypasses)
adminRouter.get(
'/:id/emails',
validate(listCampaignEmailsSchema, 'query'),
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
// Access check via campaignsService — throws 404 if not owned.
await campaignsService.findById(id, req.user!);
const result = await campaignEmailsService.listByCampaign(id, req.query as any);
res.json(result);
} catch (err) {
@ -68,12 +71,13 @@ adminRouter.get(
}
);
// GET /api/campaigns/:id/email-stats
// GET /api/campaigns/:id/email-stats — requires ownership (SUPER_ADMIN bypasses)
adminRouter.get(
'/:id/email-stats',
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
await campaignsService.findById(id, req.user!);
const stats = await campaignEmailsService.getStats(id);
res.json(stats);
} catch (err) {

View File

@ -18,7 +18,7 @@ router.get(
validate(listModerationQueueSchema, 'query'),
async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await campaignsService.findModerationQueue(req.query as any);
const result = await campaignsService.findModerationQueue(req.query as any, req.user!);
res.json(result);
} catch (err) {
next(err);
@ -46,7 +46,7 @@ router.patch(
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const before = await campaignsService.findById(id);
const before = await campaignsService.findById(id, req.user!);
const campaign = await campaignsService.moderateCampaign(id, req.body, req.user!);
eventBus.publish('campaign.status.changed', {
campaignId: campaign.id,

View File

@ -33,7 +33,7 @@ router.get(
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const campaign = await campaignsService.findById(id);
const campaign = await campaignsService.findById(id, req.user!);
res.json(campaign);
} catch (err) {
next(err);
@ -68,7 +68,7 @@ router.put(
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const campaign = await campaignsService.update(id, req.body);
const campaign = await campaignsService.update(id, req.body, req.user!);
eventBus.publish('campaign.updated', {
campaignId: campaign.id,
title: campaign.title,
@ -88,8 +88,8 @@ router.delete(
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const campaign = await campaignsService.findById(id);
await campaignsService.delete(id);
const campaign = await campaignsService.findById(id, req.user!);
await campaignsService.delete(id, req.user!);
eventBus.publish('campaign.deleted', {
campaignId: campaign.id,
title: campaign.title,

View File

@ -16,7 +16,13 @@ function escapeHtml(unsafe: string): string {
.replace(/'/g, '&#039;');
}
const campaignSelect = {
/**
* SUPER_ADMIN-only select: includes creator email and internal moderation fields
* (notes, reviewer ID, rejection reason). These are deliberately hidden from
* other admin roles to prevent cross-admin PII/moderation-intel leakage.
* Split from single `campaignSelect` on 2026-04-12.
*/
const superAdminCampaignSelect = {
id: true,
slug: true,
title: true,
@ -56,6 +62,64 @@ const campaignSelect = {
},
} satisfies Prisma.CampaignSelect;
/** Non-super-admin select: excludes creator email + internal moderation fields. */
const adminCampaignSelect = {
id: true,
slug: true,
title: true,
description: true,
emailSubject: true,
emailBody: true,
callToAction: true,
coverPhoto: true,
coverVideoId: true,
status: true,
allowSmtpEmail: true,
allowMailtoLink: true,
collectUserInfo: true,
showEmailCount: true,
showCallCount: true,
allowEmailEditing: true,
allowCustomRecipients: true,
showResponseWall: true,
highlightCampaign: true,
targetGovernmentLevels: true,
createdByUserId: true,
createdByUserName: true,
isUserGenerated: true,
moderationStatus: true,
reviewedAt: true,
createdAt: true,
updatedAt: true,
_count: {
select: {
emails: true,
responses: true,
},
},
} satisfies Prisma.CampaignSelect;
function pickCampaignSelect(user?: { role: UserRole } | null) {
return user?.role === UserRole.SUPER_ADMIN ? superAdminCampaignSelect : adminCampaignSelect;
}
/**
* Ownership enforcement for admin endpoints that mutate or expose single-campaign
* data. SUPER_ADMIN bypasses; any other admin must own the campaign. Throws 404
* (not 403) to avoid leaking which campaign IDs exist. Added 2026-04-12.
*/
async function assertCampaignAccess(id: string, user: AuthUser): Promise<void> {
const c = await prisma.campaign.findUnique({
where: { id },
select: { createdByUserId: true },
});
if (!c) throw new AppError(404, 'Campaign not found', 'CAMPAIGN_NOT_FOUND');
if (user.role === UserRole.SUPER_ADMIN) return;
if (c.createdByUserId !== user.id) {
throw new AppError(404, 'Campaign not found', 'CAMPAIGN_NOT_FOUND');
}
}
/** Public-facing select — strips admin-only fields (emails, internal IDs, moderation notes) */
const publicCampaignSelect = {
id: true,
@ -148,7 +212,7 @@ export const campaignsService = {
const [campaigns, total] = await Promise.all([
prisma.campaign.findMany({
where,
select: campaignSelect,
select: pickCampaignSelect(user),
skip,
take: limit,
orderBy: { createdAt: 'desc' },
@ -167,10 +231,12 @@ export const campaignsService = {
};
},
async findById(id: string) {
/** Fetch a campaign by ID with ownership enforcement. SUPER_ADMIN bypasses. */
async findById(id: string, user: AuthUser) {
await assertCampaignAccess(id, user);
const campaign = await prisma.campaign.findUnique({
where: { id },
select: campaignSelect,
select: pickCampaignSelect(user),
});
if (!campaign) {
@ -180,16 +246,21 @@ export const campaignsService = {
return campaign;
},
async findBySlug(slug: string) {
/** Fetch by slug (admin path). Still enforces ownership. */
async findBySlug(slug: string, user: AuthUser) {
const campaign = await prisma.campaign.findUnique({
where: { slug },
select: campaignSelect,
select: pickCampaignSelect(user),
});
if (!campaign) {
throw new AppError(404, 'Campaign not found', 'CAMPAIGN_NOT_FOUND');
}
if (user.role !== UserRole.SUPER_ADMIN && campaign.createdByUserId !== user.id) {
throw new AppError(404, 'Campaign not found', 'CAMPAIGN_NOT_FOUND');
}
return campaign;
},
@ -219,13 +290,16 @@ export const campaignsService = {
createdByUserEmail: user.email,
createdByUserName: dbUser?.name ?? null,
},
select: campaignSelect,
select: pickCampaignSelect(user),
});
return campaign;
},
async update(id: string, data: UpdateCampaignInput) {
async update(id: string, data: UpdateCampaignInput, user: AuthUser) {
// Ownership check (SUPER_ADMIN bypasses). Prevents cross-admin campaign edits.
await assertCampaignAccess(id, user);
const existing = await prisma.campaign.findUnique({ where: { id } });
if (!existing) {
throw new AppError(404, 'Campaign not found', 'CAMPAIGN_NOT_FOUND');
@ -250,7 +324,7 @@ export const campaignsService = {
const campaign = await prisma.campaign.update({
where: { id },
data: updateData,
select: campaignSelect,
select: pickCampaignSelect(user),
});
return campaign;
@ -297,12 +371,9 @@ export const campaignsService = {
return campaign;
},
async delete(id: string) {
const existing = await prisma.campaign.findUnique({ where: { id } });
if (!existing) {
throw new AppError(404, 'Campaign not found', 'CAMPAIGN_NOT_FOUND');
}
async delete(id: string, user: AuthUser) {
// Ownership check (SUPER_ADMIN bypasses).
await assertCampaignAccess(id, user);
await prisma.campaign.delete({ where: { id } });
},
@ -342,16 +413,17 @@ export const campaignsService = {
createdByUserEmail: user.email,
createdByUserName: dbUser?.name ?? null,
},
select: campaignSelect,
select: pickCampaignSelect(user),
});
return campaign;
},
async findUserCampaigns(userId: string) {
// Self-view endpoint — safe to use reduced select (user already knows own email).
return prisma.campaign.findMany({
where: { createdByUserId: userId, isUserGenerated: true },
select: campaignSelect,
select: adminCampaignSelect,
orderBy: { createdAt: 'desc' },
});
},
@ -393,13 +465,13 @@ export const campaignsService = {
return prisma.campaign.update({
where: { id },
data: updateData,
select: campaignSelect,
select: pickCampaignSelect(user),
});
},
// --- Moderation Methods ---
async findModerationQueue(filters: ListModerationQueueInput) {
async findModerationQueue(filters: ListModerationQueueInput, user: AuthUser) {
const { page, limit, search, moderationStatus } = filters;
const skip = (page - 1) * limit;
@ -416,7 +488,7 @@ export const campaignsService = {
const [campaigns, total] = await Promise.all([
prisma.campaign.findMany({
where,
select: campaignSelect,
select: pickCampaignSelect(user),
skip,
take: limit,
orderBy: { createdAt: 'desc' },
@ -475,7 +547,7 @@ export const campaignsService = {
return prisma.campaign.update({
where: { id },
data: updateData,
select: campaignSelect,
select: pickCampaignSelect(reviewer),
});
},
};

View File

@ -542,10 +542,18 @@ export const petitionsService = {
};
},
/**
* Public signature feed returns only opaque record IDs + timestamps + anonymity flag.
* PII fields (displayName, signerComment, geoCity, geoCountry) are NEVER exposed
* on the public endpoint, regardless of the petition's `showSignerNames` setting.
* Admins can still view full signer data via the authenticated admin endpoint.
* Hardened 2026-04-12 after red-team audit found this vector was being scraped
* to build activist dossiers.
*/
async listSignaturesPublic(slug: string, page: number = 1, limit: number = 20) {
const petition = await prisma.petition.findFirst({
where: { slug, status: 'ACTIVE' },
select: { id: true, showSignerNames: true },
select: { id: true },
});
if (!petition) throw new AppError(404, 'Petition not found', 'PETITION_NOT_FOUND');
@ -560,11 +568,7 @@ export const petitionsService = {
where,
select: {
id: true,
displayName: petition.showSignerNames,
signerComment: true,
isAnonymous: true,
geoCity: true,
geoCountry: true,
createdAt: true,
},
orderBy: { createdAt: 'desc' },
@ -575,10 +579,7 @@ export const petitionsService = {
]);
return {
signatures: signatures.map(s => ({
...s,
displayName: petition.showSignerNames ? s.displayName : null,
})),
signatures,
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
};
},
@ -628,7 +629,7 @@ export const petitionsService = {
status: { in: ['VERIFIED' as const, 'UNVERIFIED' as const] },
};
const [total, byCountry, byRegion, recentSigners] = await Promise.all([
const [total, byCountry, byRegion] = await Promise.all([
prisma.petitionSignature.count({ where: countWhere }),
prisma.petitionSignature.groupBy({
by: ['geoCountry'],
@ -644,16 +645,13 @@ export const petitionsService = {
orderBy: { _count: { geoRegion: 'desc' } },
take: 20,
}),
prisma.petitionSignature.findMany({
where: countWhere,
select: { displayName: true, geoCity: true, geoCountry: true, createdAt: true },
orderBy: { createdAt: 'desc' },
take: 10,
}),
]);
const displayTotal = total + petition.signatureCountOffset;
// NOTE: `recentSigners` (names + cities) was removed 2026-04-12 to prevent
// unauthenticated scraping of activist identities. Admin UIs should fetch
// signer details via the authenticated admin signatures endpoint.
return {
total: displayTotal,
verified: total,
@ -663,7 +661,6 @@ export const petitionsService = {
: null,
byCountry: Object.fromEntries(byCountry.map(c => [c.geoCountry, c._count])),
byRegion: Object.fromEntries(byRegion.map(r => [r.geoRegion, r._count])),
recentSigners,
};
},

View File

@ -146,6 +146,9 @@ export const responsesService = {
skip,
take: limit,
orderBy,
// NOTE: `userComment` and `submittedByName` were removed from the public
// select on 2026-04-12 after red-team audit found submitter identities were
// being scraped. Admin moderation views (authenticated) still see them.
select: {
id: true,
representativeName: true,
@ -153,8 +156,6 @@ export const responsesService = {
representativeLevel: true,
responseType: true,
responseText: true,
userComment: true,
submittedByName: true,
isAnonymous: true,
isVerified: true,
verifiedAt: true,

View File

@ -338,12 +338,18 @@ adminRouter.get(
},
);
// GET /api/map/canvass/volunteers/:userId
// GET /api/map/canvass/volunteers/:userId — tightened 2026-04-12.
// Only SUPER_ADMIN or the subject volunteer can view per-volunteer canvass stats
// (which include visit locations and can reconstruct movement patterns).
adminRouter.get(
'/volunteers/:userId',
async (req: Request, res: Response, next: NextFunction) => {
try {
const userId = req.params.userId as string;
if (req.user!.role !== 'SUPER_ADMIN' && userId !== req.user!.id) {
const { AppError } = await import('../../../middleware/error-handler');
throw new AppError(404, 'Volunteer not found', 'VOLUNTEER_NOT_FOUND');
}
const stats = await canvassService.getVolunteerStats(userId);
res.json(stats);
} catch (err) {

View File

@ -331,7 +331,7 @@ adminRouter.post(
// --- Public Router ---
const publicRouter = Router();
// GET /api/map/locations/public — all locations for map (no PII)
// GET /api/map/locations/public — aggregated heatmap (no PII, ~1.1km buckets)
publicRouter.get(
'/public',
async (req: Request, res: Response, next: NextFunction) => {
@ -343,14 +343,13 @@ publicRouter.get(
maxLng: parseFloat(req.query.maxLng as string),
} : undefined;
const locations = await locationsService.getPublicLocations(bounds);
const heatmap = await locationsService.getPublicHeatmap(bounds);
// Add header if we hit the safety limit
if (locations.length === 5000) {
res.setHeader('X-Location-Limit-Hit', 'true');
if (heatmap.points.length === 10000) {
res.setHeader('X-Location-Bucket-Limit-Hit', 'true');
}
res.json(locations);
res.json(heatmap);
} catch (err) {
next(err);
}

View File

@ -8,7 +8,6 @@ import { geocodingService } from '../geocoding/geocoding.service';
import { logger } from '../../../utils/logger';
import { recordLocationQuery } from '../../../utils/metrics';
import { isPointInPolygon, parseGeoJsonPolygon } from '../../../utils/spatial';
import { mapSettingsService } from '../settings/settings.service';
import type { CreateLocationInput, UpdateLocationInput, ListLocationsInput, BulkImportInput } from './locations.schemas';
// Statistics Canada Lambert Conformal Conic projection (EPSG:3347) → WGS84 (EPSG:4326)
@ -735,63 +734,48 @@ export const locationsService = {
return locations;
},
async getPublicLocations(bounds?: { minLat: number; maxLat: number; minLng: number; maxLng: number }) {
/**
* Public heatmap aggregate: buckets locations to ~1.1km precision (2 decimal places
* of lat/lng) and returns counts only. No PII (addresses, support levels, signs,
* unit numbers) is exposed to unauthenticated callers.
*
* Previously this endpoint returned raw coordinates + support levels + sign data,
* which let adversaries build targeting databases of supporters. Hardened 2026-04-12.
*/
async getPublicHeatmap(bounds?: { minLat: number; maxLat: number; minLng: number; maxLng: number }) {
const startTime = Date.now();
const where: Prisma.LocationWhereInput = {};
if (bounds) {
// Fix Decimal type handling - convert bounds to Prisma.Decimal
where.latitude = {
gte: new Prisma.Decimal(bounds.minLat.toString()),
lte: new Prisma.Decimal(bounds.maxLat.toString()),
};
where.longitude = {
gte: new Prisma.Decimal(bounds.minLng.toString()),
lte: new Prisma.Decimal(bounds.maxLng.toString()),
};
}
// Build bounds filter as SQL fragment (Prisma parameterizes via $queryRaw tagged template).
// We use ROUND(..., 2) — 2 decimal places ≈ 1.1km at the equator. Buckets aggregate many
// individual addresses into a single heatmap point, preventing reverse-lookup of residents.
const rows = bounds
? await prisma.$queryRaw<Array<{ lat: number; lng: number; count: bigint }>>`
SELECT
ROUND(latitude::numeric, 2)::float8 AS lat,
ROUND(longitude::numeric, 2)::float8 AS lng,
COUNT(*)::bigint AS count
FROM "locations"
WHERE latitude BETWEEN ${bounds.minLat}::numeric AND ${bounds.maxLat}::numeric
AND longitude BETWEEN ${bounds.minLng}::numeric AND ${bounds.maxLng}::numeric
GROUP BY 1, 2
LIMIT 10000
`
: await prisma.$queryRaw<Array<{ lat: number; lng: number; count: bigint }>>`
SELECT
ROUND(latitude::numeric, 2)::float8 AS lat,
ROUND(longitude::numeric, 2)::float8 AS lng,
COUNT(*)::bigint AS count
FROM "locations"
GROUP BY 1, 2
LIMIT 10000
`;
const locations = await prisma.location.findMany({
where,
select: {
id: true,
latitude: true,
longitude: true,
address: true,
addresses: {
select: {
id: true,
unitNumber: true,
supportLevel: true,
sign: true,
signSize: true,
},
},
},
take: 5000, // Safety limit
});
// Server-side enforcement: strip sensitive fields based on map visibility settings
const mapSettings = await mapSettingsService.get();
if (!mapSettings.publicShowSupportLevels || !mapSettings.publicShowSignInfo) {
for (const loc of locations) {
for (const addr of loc.addresses) {
if (!mapSettings.publicShowSupportLevels) {
(addr as any).supportLevel = null;
}
if (!mapSettings.publicShowSignInfo) {
(addr as any).sign = false;
(addr as any).signSize = null;
}
}
}
}
const points = rows.map((r) => ({ lat: r.lat, lng: r.lng, count: Number(r.count) }));
const durationSeconds = (Date.now() - startTime) / 1000;
recordLocationQuery('public', !!bounds, locations.length, durationSeconds);
recordLocationQuery('public', !!bounds, points.length, durationSeconds);
return locations;
return { points };
},
async importFromCsv(buffer: Buffer, userId: string) {

View File

@ -13,6 +13,9 @@ import { authenticate } from '../../../middleware/auth.middleware';
import { requireRole } from '../../../middleware/rbac.middleware';
import { gpsTrackingRateLimit } from '../../../middleware/rate-limit';
import { MAP_ROLES } from '../../../utils/roles';
import { UserRole } from '@prisma/client';
import { AppError } from '../../../middleware/error-handler';
import { prisma } from '../../../config/database';
// ─── Volunteer Router ────────────────────────────────────────────────
const volunteerRouter = Router();
@ -163,12 +166,24 @@ adminRouter.get(
},
);
// GET /api/map/tracking/sessions/:id/route — full route for a session
// GET /api/map/tracking/sessions/:id/route — full GPS route for a session.
// Tightened 2026-04-12: only SUPER_ADMIN or the session's owning volunteer can
// view raw GPS traces. Previously any MAP_ADMIN could enumerate any volunteer's
// movements, which is an unacceptable privacy risk for a political platform.
adminRouter.get(
'/sessions/:id/route',
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const session = await prisma.trackingSession.findUnique({
where: { id },
select: { userId: true },
});
if (!session) throw new AppError(404, 'Session not found', 'SESSION_NOT_FOUND');
if (req.user!.role !== UserRole.SUPER_ADMIN && session.userId !== req.user!.id) {
// 404 not 403 to avoid confirming session existence for unauthorized admins.
throw new AppError(404, 'Session not found', 'SESSION_NOT_FOUND');
}
const route = await trackingService.getSessionRoute(id);
res.json(route);
} catch (err) {

View File

@ -4,6 +4,7 @@ import { UserRole, UserStatus } from '@prisma/client';
import { prisma } from '../../../config/database';
import { env } from '../../../config/env';
import { hasAnyRole, MEDIA_ROLES, getUserRoles } from '../../../utils/roles';
import { verifyMediaSignature } from '../../../utils/signed-url';
// Extend FastifyRequest to include user
declare module 'fastify' {
@ -33,37 +34,44 @@ export async function authenticate(
reply: FastifyReply
): Promise<void> {
const authHeader = request.headers.authorization;
const queryToken = (request.query as Record<string, string>)?.token;
const query = (request.query as Record<string, string>) ?? {};
// Two accepted auth paths:
// 1. `Authorization: Bearer <JWT>` — normal API use (mobile, fetch, etc.)
// 2. Signed-URL query params `?sig=...&exp=...&uid=...` — used for
// `<img src>`/`<video src>` tags where browsers can't set headers.
// This replaces the legacy `?token=<JWT>` path on 2026-04-12:
// full JWTs in URLs were leaking via logs, referer headers, and
// shared-link copy/paste. Signed URLs are path-scoped and 5-min TTL.
let authenticatedUserId: string | null = null;
// Support both Authorization header and ?token= query param (for <img>/<video> src)
let token: string | null = null;
if (authHeader?.startsWith('Bearer ')) {
token = authHeader.substring(7);
} else if (queryToken) {
token = queryToken;
const token = authHeader.substring(7);
try {
const payload = jwt.verify(token, env.JWT_ACCESS_SECRET, { algorithms: ['HS256'] }) as TokenPayload;
authenticatedUserId = payload.id;
} catch {
return reply.status(401).send({ error: 'Invalid or expired token', code: 'INVALID_TOKEN' });
}
} else if (query.sig && query.exp && query.uid) {
const result = verifyMediaSignature(request.url, query);
if (!result.valid) {
return reply.status(401).send({ error: 'Invalid signed URL', code: 'INVALID_SIGNATURE' });
}
authenticatedUserId = result.userId;
}
if (!token) {
if (!authenticatedUserId) {
return reply.status(401).send({
error: 'Authentication required',
code: 'AUTH_REQUIRED'
});
}
// Verify JWT with V2 access secret
let payload: TokenPayload;
try {
payload = jwt.verify(token, env.JWT_ACCESS_SECRET, { algorithms: ['HS256'] }) as TokenPayload;
} catch (error) {
return reply.status(401).send({
error: 'Invalid or expired token',
code: 'INVALID_TOKEN'
});
}
// Verify user still exists and is active
const user = await prisma.user.findUnique({
where: { id: payload.id },
where: { id: authenticatedUserId },
select: {
id: true,
email: true,
@ -140,44 +148,40 @@ export async function optionalAuth(
_reply: FastifyReply
): Promise<void> {
const authHeader = request.headers.authorization;
const queryToken = (request.query as Record<string, string>)?.token;
const query = (request.query as Record<string, string>) ?? {};
let userId: string | null = null;
let token: string | null = null;
if (authHeader?.startsWith('Bearer ')) {
token = authHeader.substring(7);
} else if (queryToken) {
token = queryToken;
try {
const payload = jwt.verify(authHeader.substring(7), env.JWT_ACCESS_SECRET, { algorithms: ['HS256'] }) as TokenPayload;
userId = payload.id;
} catch { /* ignore */ }
} else if (query.sig && query.exp && query.uid) {
const result = verifyMediaSignature(request.url, query);
if (result.valid) userId = result.userId;
}
if (!token) {
return;
}
if (!userId) return;
try {
const payload = jwt.verify(token, env.JWT_ACCESS_SECRET, { algorithms: ['HS256'] }) as TokenPayload;
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
email: true,
role: true,
roles: true,
status: true,
},
});
// Verify user exists and is active
const user = await prisma.user.findUnique({
where: { id: payload.id },
select: {
id: true,
email: true,
role: true,
roles: true,
status: true,
},
});
if (user && user.status === UserStatus.ACTIVE) {
const userRoles = getUserRoles(user);
request.user = {
id: user.id,
email: user.email,
role: user.role as UserRole,
roles: userRoles,
};
}
} catch {
// Invalid token, just ignore and continue without user
if (user && user.status === UserStatus.ACTIVE) {
const userRoles = getUserRoles(user);
request.user = {
id: user.id,
email: user.email,
role: user.role as UserRole,
roles: userRoles,
};
}
}

View File

@ -45,30 +45,23 @@ export function notifyUser(userId: string, notification: {
export async function chatNotificationsRoutes(fastify: FastifyInstance) {
/**
* GET /notifications/stream?token=JWT
* Per-user SSE stream for chat reply notifications
* GET /notifications/stream?sig=...&exp=...&uid=...
* Per-user SSE stream for chat reply notifications. Uses path-scoped signed
* URL (replaces legacy ?token=JWT path on 2026-04-12) since EventSource
* cannot set Authorization headers.
*/
fastify.get(
'/notifications/stream',
async (
request: FastifyRequest<{ Querystring: { token?: string } }>,
request: FastifyRequest<{ Querystring: { sig?: string; exp?: string; uid?: string } }>,
reply: FastifyReply
) => {
const token = request.query.token;
if (!token) {
return reply.code(401).send({ message: 'Authentication token required' });
const { verifyMediaSignature } = await import('../../../utils/signed-url');
const result = verifyMediaSignature(request.url, request.query as Record<string, string | undefined>);
if (!result.valid) {
return reply.code(401).send({ message: 'Invalid or expired signed URL' });
}
// Verify JWT
let payload: TokenPayload;
try {
payload = jwt.verify(token, env.JWT_ACCESS_SECRET, { algorithms: ['HS256'] }) as TokenPayload;
} catch {
return reply.code(401).send({ message: 'Invalid or expired token' });
}
const userId = payload.id;
const userId = result.userId;
// Set SSE headers
reply.raw.writeHead(200, {

View File

@ -5,41 +5,40 @@ import { prisma } from '../../../config/database';
import { env } from '../../../config/env';
import { requireAdminRole } from '../middleware/auth';
import { logger } from '../../../utils/logger';
import { hasAnyRole, MEDIA_ROLES } from '../../../utils/roles';
import { hasAnyRole, MEDIA_ROLES, getUserRoles } from '../../../utils/roles';
import { verifyMediaSignature } from '../../../utils/signed-url';
import { unlink } from 'fs/promises';
/**
* Check if the request is from an authenticated admin user.
* Supports JWT from Authorization header or ?token= query parameter
* (needed for <img src> which can't send headers).
* Admin check for photo routes. Accepts Bearer header OR signed URL params.
* Legacy `?token=<JWT>` path removed 2026-04-12 (see video-streaming.routes.ts).
*/
async function isAdminRequest(request: FastifyRequest): Promise<boolean> {
try {
let token: string | undefined;
let userId: string | undefined;
const authHeader = request.headers.authorization;
const query = request.query as Record<string, string | undefined>;
if (authHeader?.startsWith('Bearer ')) {
token = authHeader.substring(7);
} else {
const query = request.query as Record<string, string | undefined>;
token = query.token;
const payload = jwt.verify(authHeader.substring(7), env.JWT_ACCESS_SECRET, { algorithms: ['HS256'] }) as {
id: string; role: UserRole; roles?: UserRole[];
};
if (!hasAnyRole(payload, MEDIA_ROLES)) return false;
userId = payload.id;
} else if (query.sig && query.exp && query.uid) {
const result = verifyMediaSignature(request.url, query);
if (!result.valid) return false;
userId = result.userId;
}
if (!token) return false;
const payload = jwt.verify(token, env.JWT_ACCESS_SECRET, { algorithms: ['HS256'] }) as {
id: string;
role: UserRole;
roles?: UserRole[];
};
if (!hasAnyRole(payload, MEDIA_ROLES)) return false;
if (!userId) return false;
const user = await prisma.user.findUnique({
where: { id: payload.id },
select: { status: true },
where: { id: userId },
select: { status: true, role: true, roles: true },
});
return user?.status === UserStatus.ACTIVE;
if (!user || user.status !== UserStatus.ACTIVE) return false;
return hasAnyRole({ role: user.role as UserRole, roles: getUserRoles(user) }, MEDIA_ROLES);
} catch {
return false;
}

View File

@ -0,0 +1,44 @@
import { FastifyInstance } from 'fastify';
import { authenticate } from '../middleware/auth';
import { signMediaPath } from '../../../utils/signed-url';
/**
* POST /api/media/sign body: { path: string, ttlSeconds?: number }
*
* Returns short-lived HMAC-signed query params for embedding the given path
* in an `<img src>` / `<video src>` / SSE URL. Requires header auth (the
* caller must have a valid Bearer JWT). The returned params are path-scoped
* and expire in `ttlSeconds` (capped at 900s / 15 min).
*
* Replaces the legacy pattern of putting the JWT itself in `?token=` on
* 2026-04-12 see utils/signed-url.ts for background.
*/
interface SignRequestBody { path?: string; ttlSeconds?: number }
export async function signRoutes(fastify: FastifyInstance) {
fastify.post<{ Body: SignRequestBody }>(
'/sign',
{ preHandler: authenticate },
async (request, reply) => {
const { path, ttlSeconds } = request.body ?? {};
if (typeof path !== 'string' || path.length === 0 || path.length > 512) {
return reply.status(400).send({ error: 'Invalid path', code: 'INVALID_PATH' });
}
// Reject anything that's not a path on our own API — prevents signed-URL
// generation for arbitrary URLs (would otherwise be a blind-signer oracle).
if (!path.startsWith('/api/')) {
return reply.status(400).send({ error: 'Path must start with /api/', code: 'INVALID_PATH' });
}
const ttl = Math.min(Math.max(Number(ttlSeconds) || 300, 30), 900);
const userId = request.user!.id;
const signed = signMediaPath(path, userId, ttl);
const separator = path.includes('?') ? '&' : '?';
const query = `sig=${signed.sig}&exp=${signed.exp}&uid=${signed.uid}`;
return reply.send({
url: `${path}${separator}${query}`,
...signed,
expiresAt: new Date(Number(signed.exp) * 1000).toISOString(),
});
}
);
}

View File

@ -8,44 +8,45 @@ import { UserRole, UserStatus } from '@prisma/client';
import { prisma } from '../../../config/database';
import { env } from '../../../config/env';
import { logger } from '../../../utils/logger';
import { hasAnyRole, MEDIA_ROLES } from '../../../utils/roles';
import { hasAnyRole, MEDIA_ROLES, getUserRoles } from '../../../utils/roles';
import { verifyMediaSignature } from '../../../utils/signed-url';
/**
* Check if the request is from an authenticated admin user.
* Supports JWT from Authorization header or ?token= query parameter
* (needed for <video src> and <img src> which can't send headers).
* Accepts either (1) Bearer JWT or (2) path-scoped signed URL params
* (`?sig=&exp=&uid=`). The legacy `?token=<JWT>` path was removed on
* 2026-04-12 full JWTs in query strings were leaking via access logs
* and referer headers.
*/
async function isAdminRequest(request: FastifyRequest): Promise<boolean> {
try {
// Extract token from Authorization header (priority) or query param (fallback)
let token: string | undefined;
let userId: string | undefined;
const authHeader = request.headers.authorization;
const query = request.query as Record<string, string | undefined>;
if (authHeader?.startsWith('Bearer ')) {
token = authHeader.substring(7);
} else {
const query = request.query as Record<string, string | undefined>;
token = query.token;
const payload = jwt.verify(authHeader.substring(7), env.JWT_ACCESS_SECRET, { algorithms: ['HS256'] }) as {
id: string; role: UserRole; roles?: UserRole[];
};
if (!hasAnyRole(payload, MEDIA_ROLES)) return false;
userId = payload.id;
} else if (query.sig && query.exp && query.uid) {
const result = verifyMediaSignature(request.url, query);
if (!result.valid) return false;
userId = result.userId;
}
if (!token) return false;
if (!userId) return false;
// Verify JWT signature
const payload = jwt.verify(token, env.JWT_ACCESS_SECRET, { algorithms: ['HS256'] }) as {
id: string;
role: UserRole;
roles?: UserRole[];
};
// Check admin role from token (multi-role aware)
if (!hasAnyRole(payload, MEDIA_ROLES)) return false;
// Verify user is still active in DB
// Verify user still active AND has media role (signed URLs carry only uid,
// so we re-check the role from DB to avoid stale-role privilege escalation).
const user = await prisma.user.findUnique({
where: { id: payload.id },
select: { status: true },
where: { id: userId },
select: { status: true, role: true, roles: true },
});
return user?.status === UserStatus.ACTIVE;
if (!user || user.status !== UserStatus.ACTIVE) return false;
return hasAnyRole({ role: user.role as UserRole, roles: getUserRoles(user) }, MEDIA_ROLES);
} catch {
return false;
}

View File

@ -2,7 +2,7 @@ import { Router, Request, Response, NextFunction } from 'express';
import { authenticate } from '../../middleware/auth.middleware';
import { requireRole } from '../../middleware/rbac.middleware';
import { validate } from '../../middleware/validate';
import { ADMIN_ROLES } from '../../utils/roles';
import { ADMIN_ROLES, INFLUENCE_ROLES, MAP_ROLES } from '../../utils/roles';
import { prisma } from '../../config/database';
import { peopleService } from './people.service';
import { profileService } from './profile.service';
@ -101,9 +101,17 @@ router.get(
// Household
// ---------------------------------------------------------------------------
// Household routes tightened 2026-04-12: contain full-address PII (names,
// emails, phones at a specific address). Previously accessible to any ADMIN_ROLE;
// now restricted to SUPER_ADMIN + INFLUENCE_ADMIN + MAP_ADMIN (the roles that
// legitimately handle location-based contact data). MEDIA/BROADCAST/etc. admins
// have no business viewing household PII.
const HOUSEHOLD_ROLES = Array.from(new Set([...INFLUENCE_ROLES, ...MAP_ROLES]));
// GET /api/people/household/:locationId — all people at a location
router.get(
'/household/:locationId',
requireRole(...HOUSEHOLD_ROLES),
async (req: Request, res: Response, next: NextFunction) => {
try {
const locationId = req.params.locationId as string;
@ -118,6 +126,7 @@ router.get(
// POST /api/people/household/:locationId/detect — auto-create HOUSEHOLD connections
router.post(
'/household/:locationId/detect',
requireRole(...HOUSEHOLD_ROLES),
async (req: Request, res: Response, next: NextFunction) => {
try {
const locationId = req.params.locationId as string;

View File

@ -23,11 +23,42 @@ import { challengeRouter } from './challenge.routes';
const router = Router();
// EventSource (SSE) doesn't support custom headers — accept token via query param
// Scoped to /sse path only to limit token-in-URL exposure to where it's truly needed
router.use((req, _res, next) => {
if (req.query.token && !req.headers.authorization && req.path.startsWith('/sse')) {
req.headers.authorization = `Bearer ${req.query.token}`;
// EventSource SSE auth: accepts signed URL params (`?sig=&exp=&uid=`) and
// synthesizes an Authorization header so downstream `authenticate` middleware
// can treat the caller as header-authenticated. The legacy `?token=<JWT>` path
// was removed on 2026-04-12 — full JWTs in URLs leak via logs/referer; signed
// URLs are path-scoped and 5-min TTL.
router.use(async (req, _res, next) => {
if (
!req.headers.authorization &&
req.path.startsWith('/sse') &&
req.query.sig && req.query.exp && req.query.uid
) {
const { verifyMediaSignature } = await import('../../utils/signed-url');
const result = verifyMediaSignature(
req.originalUrl || req.url,
req.query as Record<string, string | undefined>
);
if (result.valid) {
// Forge a short-lived access token so downstream authenticate() works
// unchanged. Better architecturally would be a dedicated 'signed auth'
// middleware, but synthesizing here keeps the blast-radius tiny.
const jwt = await import('jsonwebtoken');
const { env } = await import('../../config/env');
const { prisma } = await import('../../config/database');
const user = await prisma.user.findUnique({
where: { id: result.userId },
select: { id: true, email: true, role: true, roles: true, status: true },
});
if (user && user.status === 'ACTIVE') {
const token = jwt.sign(
{ id: user.id, email: user.email, role: user.role, roles: user.roles },
env.JWT_ACCESS_SECRET,
{ algorithm: 'HS256', expiresIn: '5m' }
);
req.headers.authorization = `Bearer ${token}`;
}
}
}
next();
});

View File

@ -215,11 +215,14 @@ app.use((req, res, next) => {
});
// --- Health Check ---
app.get('/api/health', healthMetricsRateLimit, async (req, res) => {
// Public (unauthenticated) for Docker healthcheck compatibility, but the
// `?detailed=true` mode — which exposed disk space and internal service status
// to any unauthenticated caller — was moved to the authenticated `/api/metrics`
// consumers on 2026-04-12. The public path now returns only pass/fail for core
// DB + Redis.
app.get('/api/health', healthMetricsRateLimit, async (_req, res) => {
const checks: Record<string, string> = {};
const detailed = req.query.detailed === 'true';
// Core checks (always run — used by Docker healthcheck)
try {
await prisma.$queryRaw`SELECT 1`;
checks.database = 'ok';
@ -234,28 +237,6 @@ app.get('/api/health', healthMetricsRateLimit, async (req, res) => {
checks.redis = 'error';
}
// Extended checks (opt-in, for monitoring/debugging)
if (detailed) {
// MkDocs dev server
try {
const mkdocsRes = await fetch(`http://${env.MKDOCS_CONTAINER_NAME}:8000`, { signal: AbortSignal.timeout(3000) });
checks.mkdocs = mkdocsRes.ok ? 'ok' : 'error';
} catch {
checks.mkdocs = 'error';
}
// Disk space (logs directory)
try {
const { statfs } = await import('fs/promises');
const stats = await statfs(env.LOG_DIR);
const freeGB = Number(stats.bavail) * Number(stats.bsize) / (1024 ** 3);
checks.disk = freeGB > 1 ? 'ok' : 'warning';
checks.diskFreeGB = freeGB.toFixed(1);
} catch {
checks.disk = 'unknown';
}
}
const coreHealthy = checks.database === 'ok' && checks.redis === 'ok';
res.status(coreHealthy ? 200 : 503).json({
status: coreHealthy ? 'healthy' : 'degraded',

View File

@ -20,7 +20,9 @@ export const passwordResetTokenService = {
data: { userId, token: tokenHash, expiresAt },
});
logger.info(`Password reset token created for user ${userId}`);
// 2026-04-12: userId removed from log to prevent correlation of reset
// activity with specific accounts via log scraping.
logger.info('Password reset token created');
return rawToken; // Send raw token in email; DB stores only the hash
},

View File

@ -20,7 +20,7 @@ const DOCS_REPO_NAME = 'changemaker.lite';
/** Deterministic password — never exposed to users */
function generateGiteaPassword(userId: string): string {
const salt = env.SERVICE_PASSWORD_SALT || env.JWT_ACCESS_SECRET;
const salt = env.SERVICE_PASSWORD_SALT;
return createHmac('sha256', salt)
.update(`gitea:${userId}`)
.digest('hex');

View File

@ -16,7 +16,7 @@ const ROLE_MAP: Record<string, string[]> = {
/** Deterministic password — never exposed to users, only used for RC internal auth */
function generateRCPassword(userId: string): string {
const salt = env.SERVICE_PASSWORD_SALT || env.JWT_ACCESS_SECRET;
const salt = env.SERVICE_PASSWORD_SALT;
return createHmac('sha256', salt)
.update(`rc:${userId}`)
.digest('hex');

View File

@ -20,7 +20,8 @@ export const verificationTokenService = {
data: { userId, token: tokenHash, expiresAt },
});
logger.info(`Verification token created for user ${userId}`);
// 2026-04-12: userId removed from log (see password-reset-token.service).
logger.info('Verification token created');
return rawToken; // Send raw token in email; DB stores only the hash
},

View File

@ -0,0 +1,57 @@
import { createHash } from 'crypto';
import type { Request } from 'express';
/**
* Device fingerprint binding for refresh tokens (added 2026-04-12).
*
* A refresh token stolen via XSS, log exfiltration, or database breach is
* currently valid from any IP/device until natural expiry. To close that
* window, we bind each refresh token to the issuing device by embedding a
* `df` claim (SHA-256 of user-agent + /24-masked IP) in the refresh JWT, and
* reject refresh attempts whose fingerprint doesn't match.
*
* We mask to /24 (IPv4) and /48 (IPv6) so legitimate mobile network changes
* within the same carrier subnet don't force re-login on every tower hop,
* while cross-country or VPN changes do.
*/
/**
* Returns the /24-masked IPv4 or /48-masked IPv6 subnet portion of the
* client address. Trusts `req.ip` which Express populates from X-Forwarded-For
* when `trust proxy` is set.
*/
function maskIp(rawIp: string | undefined): string {
if (!rawIp) return 'unknown';
const ip = rawIp.replace(/^::ffff:/, '').trim();
if (ip.includes(':')) {
// IPv6: take first 3 hextets (/48)
return ip.split(':').slice(0, 3).join(':');
}
const parts = ip.split('.');
if (parts.length === 4) {
return `${parts[0]}.${parts[1]}.${parts[2]}.0`;
}
return ip;
}
/**
* Compute a stable fingerprint for the incoming request. Include major UA
* (Chrome/Firefox/Safari etc.) but not full string we want stability across
* minor browser upgrades while still catching device swaps.
*/
export function computeDeviceFingerprint(req: Request): string {
const ua = (req.headers['user-agent'] ?? '').toString().slice(0, 200);
const ipSubnet = maskIp(req.ip);
return createHash('sha256').update(`${ua}|${ipSubnet}`).digest('hex').slice(0, 32);
}
/**
* Constant-time compare to avoid timing side channels when an attacker is
* testing stolen tokens against different fingerprints.
*/
export function fingerprintsMatch(a: string | undefined, b: string | undefined): boolean {
if (!a || !b || a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
return diff === 0;
}

105
api/src/utils/signed-url.ts Normal file
View File

@ -0,0 +1,105 @@
import { createHmac, timingSafeEqual } from 'crypto';
import { env } from '../config/env';
/**
* Short-lived signed URLs for media streaming (added 2026-04-12).
*
* Background: `<img src>` and `<video src>` tags can't set Authorization
* headers, so the legacy design passed a full JWT access token in the query
* string (`?token=...`). JWTs in URLs leak via:
* - browser history and screen recordings
* - server / proxy / CDN access logs
* - Referer headers sent to external domains
* - shared links (the URL becomes a long-lived auth token)
*
* This module replaces that with a per-URL HMAC signature that:
* - expires in 5 minutes by default (`DEFAULT_TTL_SECONDS`)
* - is bound to one specific resource path (no cross-URL reuse)
* - carries only the user-id (not a full session token) in the clear
* - is single-purpose (signing key derived, never equals any JWT secret)
*
* Clients call `POST /api/media/sign` with header auth to get a signed URL,
* then set `<img src>`/`<video src>` to it. The server verifies the sig on
* fetch; an attacker recovering the URL from logs has at most 5 minutes to
* replay it, and can't forge a URL for any other resource.
*/
const DEFAULT_TTL_SECONDS = 300; // 5 minutes
/**
* Deterministically derive the media-URL signing key from JWT_ACCESS_SECRET.
* This avoids adding yet another required env var while maintaining key
* separation HMAC with a fixed context string produces a key that cannot
* be used to forge JWTs (different HMAC inputs different outputs, and the
* JWT signing path never uses this derivation).
*/
function getSigningKey(): Buffer {
return createHmac('sha256', env.JWT_ACCESS_SECRET)
.update('cml:media-url-signing-v1')
.digest();
}
/**
* Canonicalize the path portion we're signing. Strips the query string and
* any trailing slash so that adding/removing query params can't change the
* signed payload.
*/
function canonicalPath(path: string): string {
const withoutQuery = path.split('?')[0] ?? path;
return withoutQuery.replace(/\/+$/, '');
}
function computeSignature(path: string, userId: string, exp: number): string {
const payload = `${canonicalPath(path)}|${userId}|${exp}`;
return createHmac('sha256', getSigningKey()).update(payload).digest('hex');
}
export interface SignedUrlParams {
sig: string;
exp: string; // unix seconds (string in query)
uid: string;
}
/**
* Generate the signed query-string params for a given path + user.
* Does NOT append them to the URL callers combine as they see fit.
*/
export function signMediaPath(
path: string,
userId: string,
ttlSeconds: number = DEFAULT_TTL_SECONDS
): SignedUrlParams {
const exp = Math.floor(Date.now() / 1000) + ttlSeconds;
return {
sig: computeSignature(path, userId, exp),
exp: String(exp),
uid: userId,
};
}
/**
* Verify a signed URL. Returns the authenticated userId on success, null on
* any failure (expired, tampered, missing params).
*/
export function verifyMediaSignature(
path: string,
query: Record<string, string | string[] | undefined>
): { valid: true; userId: string } | { valid: false; reason: string } {
const sig = typeof query.sig === 'string' ? query.sig : undefined;
const exp = typeof query.exp === 'string' ? query.exp : undefined;
const uid = typeof query.uid === 'string' ? query.uid : undefined;
if (!sig || !exp || !uid) return { valid: false, reason: 'missing params' };
const expNum = Number(exp);
if (!Number.isFinite(expNum)) return { valid: false, reason: 'bad exp' };
if (expNum < Math.floor(Date.now() / 1000)) return { valid: false, reason: 'expired' };
const expected = computeSignature(path, uid, expNum);
if (sig.length !== expected.length) return { valid: false, reason: 'sig length' };
// constant-time compare to avoid timing oracle on the HMAC
const ok = timingSafeEqual(Buffer.from(sig, 'hex'), Buffer.from(expected, 'hex'));
if (!ok) return { valid: false, reason: 'sig mismatch' };
return { valid: true, userId: uid };
}

View File

@ -131,6 +131,7 @@ export default function InstanceDetailPage() {
const [tunnelStatusLoading, setTunnelStatusLoading] = useState(false);
const [tunnelSetupRunning, setTunnelSetupRunning] = useState(false);
const [tunnelSyncing, setTunnelSyncing] = useState(false);
const [tunnelImporting, setTunnelImporting] = useState(false);
// Upgrade state
const [updateStatus, setUpdateStatus] = useState<UpdateStatus | null>(null);
@ -337,6 +338,26 @@ export default function InstanceDetailPage() {
}
}, [instance?.status, fetchInstance]);
// Fetch tunnel status for remote instances (must be before early return)
const fetchTunnelStatus = useCallback(async () => {
if (!instance?.isRemote) return;
setTunnelStatusLoading(true);
try {
const { data } = await api.get(`/instances/${id}/tunnel/status`);
setTunnelStatus(data.data);
} catch {
setTunnelStatus(null);
} finally {
setTunnelStatusLoading(false);
}
}, [id, instance?.isRemote]);
useEffect(() => {
if (activeTab === 'tunnel' && instance?.isRemote) {
fetchTunnelStatus();
}
}, [activeTab, instance?.isRemote, fetchTunnelStatus]);
const handleAction = async (action: string, label: string) => {
setActionLoading(action);
try {
@ -1162,31 +1183,11 @@ export default function InstanceDetailPage() {
const tunnelConfigured = !!(instance.pangolinEndpoint && instance.pangolinNewtId);
const canConfigureTunnel = isManaged && (instance.status === 'RUNNING' || instance.status === 'STOPPED');
// Fetch tunnel status for remote instances
const fetchTunnelStatus = useCallback(async () => {
if (!isRemote) return;
setTunnelStatusLoading(true);
try {
const { data } = await api.get(`/instances/${id}/tunnel/status`);
setTunnelStatus(data.data);
} catch {
setTunnelStatus(null);
} finally {
setTunnelStatusLoading(false);
}
}, [id, isRemote]);
useEffect(() => {
if (activeTab === 'tunnel' && isRemote) {
fetchTunnelStatus();
}
}, [activeTab, isRemote, fetchTunnelStatus]);
const handleRemoteTunnelSetup = async (values: { subdomainPrefix?: string }) => {
setTunnelSetupRunning(true);
try {
await api.post(`/instances/${id}/tunnel/setup`, {
subdomainPrefix: values.subdomainPrefix || instance.slug,
subdomainPrefix: values.subdomainPrefix || '',
});
message.success('Tunnel setup complete — Newt credentials pushed to remote instance');
fetchInstance();
@ -1199,6 +1200,23 @@ export default function InstanceDetailPage() {
}
};
const handleTunnelImport = async () => {
setTunnelImporting(true);
try {
const { data } = await api.post(`/instances/${id}/tunnel/import`);
message.success(
`Tunnel imported — site ${data.data.siteId} (${data.data.online ? 'online' : 'offline'})`
);
fetchInstance();
fetchTunnelStatus();
} catch (err: unknown) {
const e = err as { response?: { data?: { error?: { message?: string } } } };
message.error(e?.response?.data?.error?.message || 'Import failed');
} finally {
setTunnelImporting(false);
}
};
const handleTunnelSync = async () => {
setTunnelSyncing(true);
try {
@ -1344,16 +1362,34 @@ export default function InstanceDetailPage() {
showIcon
/>
<Card title="Import Existing Tunnel" size="small">
<p style={{ marginTop: 0 }}>
If this instance already has a Pangolin tunnel set up (e.g. by
<code> config.sh --pangolin-site new</code> during install), the CCP can
adopt it by reading the remote <code>.env</code> and verifying the site
exists in the CCP&apos;s Pangolin org. No resources are modified.
</p>
<Popconfirm
title="Import existing tunnel?"
description="The CCP will read Pangolin credentials from the remote .env and persist them on this instance."
onConfirm={handleTunnelImport}
okText="Import"
>
<Button icon={<CloudOutlined />} loading={tunnelImporting}>
Import Existing Tunnel
</Button>
</Popconfirm>
</Card>
<Card title="Setup Tunnel" size="small">
<Form layout="vertical" onFinish={handleRemoteTunnelSetup}>
<Form.Item
name="subdomainPrefix"
label="Subdomain Prefix"
initialValue={instance.slug}
extra={`Resources will be created as <prefix>-app.${instance.domain}, <prefix>-api.${instance.domain}, etc.`}
rules={[{ required: true }, { pattern: /^[a-z0-9-]+$/, message: 'Lowercase alphanumeric + hyphens only' }]}
label="Subdomain Prefix (optional)"
extra={`Leave empty for standard subdomains (app.${instance.domain}, api.${instance.domain}). Set a prefix for multi-tenant domains (e.g. "ck" creates ck-app.${instance.domain}).`}
rules={[{ pattern: /^[a-z0-9-]*$/, message: 'Lowercase alphanumeric + hyphens only' }]}
>
<Input placeholder={instance.slug} />
<Input placeholder="(none — uses standard subdomains)" />
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<Button type="primary" htmlType="submit" icon={<CloudOutlined />} loading={tunnelSetupRunning}>

View File

@ -122,20 +122,39 @@ async function startPhoneHome() {
const result = await response.json() as { registrationId: string };
logger.info(`[phone-home] Registration submitted (id: ${result.registrationId}). Waiting for approval...`);
// Step 2: Poll for approval
// Step 2: Poll for approval. Every path inside the callback is wrapped in
// try/catch so an unexpected throw never kills the interval silently.
// On every poll we log either the status transition or a heartbeat every
// 10th attempt, so admins can see the loop is alive.
let pollCount = 0;
let lastLoggedStatus: string | null = null;
const pollInterval = setInterval(async () => {
pollCount += 1;
try {
const pollResp = await fetch(
`${env.CCP_URL}/api/agents/poll?registrationId=${result.registrationId}&slug=${env.INSTANCE_SLUG}`
);
if (!pollResp.ok) return;
if (!pollResp.ok) {
logger.warn(`[phone-home] Poll #${pollCount} HTTP ${pollResp.status} ${pollResp.statusText}`);
return;
}
const pollData = await pollResp.json() as {
status: string;
certBundle?: { caCertPem: string; agentCertPem: string; agentKeyPem: string; ccpFingerprint: string };
message?: string;
};
// Log status transitions and periodic heartbeats so the loop is never
// invisible. Previously a stuck loop left no trace in logs.
if (pollData.status !== lastLoggedStatus) {
logger.info(`[phone-home] Poll #${pollCount}: status=${pollData.status}${pollData.message ? `${pollData.message}` : ''}`);
lastLoggedStatus = pollData.status;
} else if (pollCount % 10 === 0) {
logger.debug(`[phone-home] Poll #${pollCount}: still ${pollData.status}`);
}
if (pollData.status === 'APPROVED' && pollData.certBundle) {
clearInterval(pollInterval);
logger.info('[phone-home] Approved! Saving certificates...');
@ -161,14 +180,28 @@ async function startPhoneHome() {
// Exit so Docker restart policy brings us back with certs
process.exit(0);
} else if (pollData.status === 'APPROVED' && !pollData.certBundle) {
// Admin approved but cert bundle was consumed (e.g. by debug curl).
// Keep polling — admin can re-issue certs via the new endpoint and we'll
// pick them up on the next poll.
// (No action needed; the status-transition log above covers visibility.)
} else if (pollData.status === 'REJECTED') {
clearInterval(pollInterval);
logger.error('[phone-home] Registration was rejected by CCP admin');
}
} catch (err) {
logger.warn(`[phone-home] Poll failed: ${(err as Error).message}`);
// CRITICAL: this catch MUST swallow every error — if it rethrows the
// setInterval callback becomes an unhandled rejection and Node may kill
// the interval depending on the runtime config. We saw this in prod.
logger.warn(`[phone-home] Poll #${pollCount} failed: ${(err as Error).message}`);
}
}, 30_000);
// Defensive: if the Node process receives an unhandled rejection that
// somehow originates from the poll path, log it instead of dying quietly.
process.on('unhandledRejection', (reason) => {
logger.error(`[phone-home] Unhandled rejection in poll loop: ${reason}`);
});
} catch (err) {
logger.error(`[phone-home] Registration request failed: ${(err as Error).message}`);
}

View File

@ -0,0 +1,2 @@
-- DropIndex
DROP INDEX "instances_compose_project_key";

View File

@ -66,7 +66,7 @@ model Instance {
statusMessage String? @map("status_message")
basePath String @map("base_path")
composeProject String @unique @map("compose_project")
composeProject String @map("compose_project")
gitBranch String @default("v2") @map("git_branch")
gitCommit String? @map("git_commit")

View File

@ -245,4 +245,49 @@ router.post('/registrations/:id/reject', authenticate, requireRole('SUPER_ADMIN'
res.json({ message: 'Registration rejected' });
});
/**
* POST /api/agents/registrations/:id/reissue-certs
* Re-issue certificates for an approved registration whose cert bundle was already
* delivered and wiped (e.g. agent missed the one-shot delivery).
*/
router.post('/registrations/:id/reissue-certs', authenticate, requireRole('SUPER_ADMIN'), async (req: Request, res: Response) => {
const { id } = req.params;
const registration = await prisma.agentRegistration.findUnique({ where: { id: id as string } });
if (!registration) throw new AppError(404, 'Registration not found');
if (registration.status !== AgentRegistrationStatus.APPROVED) {
throw new AppError(400, `Registration is ${registration.status}, not APPROVED`);
}
if (!registration.instanceId) {
throw new AppError(400, 'Registration has no linked instance');
}
// Re-issue certs and write back to registration for agent to pick up
const certMaterials = await issueAgentCert(registration.instanceId, registration.slug, registration.agentUrl);
await prisma.agentRegistration.update({
where: { id: id as string },
data: {
certBundle: {
caCertPem: certMaterials.caCertPem,
agentCertPem: certMaterials.agentCertPem,
agentKeyPem: certMaterials.agentKeyPem,
ccpFingerprint: certMaterials.fingerprint,
},
},
});
await prisma.auditLog.create({
data: {
userId: (req as unknown as { user: { id: string } }).user.id,
instanceId: registration.instanceId,
action: AuditAction.AGENT_APPROVE,
details: { slug: registration.slug, reason: 'cert-reissue' },
ipAddress: req.ip || null,
},
});
logger.info(`[agents] Certificates re-issued for ${registration.slug}`);
res.json({ message: 'Certificates re-issued — agent will receive them on next poll' });
});
export default router;

View File

@ -250,6 +250,20 @@ router.post(
}
);
// Adopt a tunnel that was set up outside CCP (e.g. by config.sh --pangolin-site new)
router.post(
'/:id/tunnel/import',
requireRole('SUPER_ADMIN'),
async (req: Request, res: Response) => {
const result = await tunnelService.importTunnel(
req.params.id as string,
req.user!.id,
req.ip
);
res.json({ data: result });
}
);
// ─── Lifecycle Endpoints ─────────────────────────────────────────────
router.post(

View File

@ -122,11 +122,12 @@ export const startUpgradeSchema = z.object({
});
export const setupRemoteTunnelSchema = z.object({
// Empty string or omitted → resources use standard subdomains (app., api., etc.)
// A value like "ck" → creates ck-app., ck-api., etc. for multi-tenant domains
subdomainPrefix: z
.string()
.min(1)
.max(50)
.regex(/^[a-z0-9-]+$/, 'Prefix must be lowercase alphanumeric with hyphens')
.regex(/^[a-z0-9-]*$/, 'Prefix must be lowercase alphanumeric with hyphens')
.optional(),
});

View File

@ -13,6 +13,18 @@ const exec = promisify(execCb);
const CA_VALIDITY_DAYS = 3650; // ~10 years
const AGENT_CERT_VALIDITY_DAYS = 730; // ~2 years
/**
* Shell/cert-injection guard. Slug flows into the OpenSSL -subj `/CN=...` string
* and into SAN DNS entries. An unvalidated slug like `foo/O=EvilOrg/CN=` would
* let callers forge arbitrary DN components. Added 2026-04-12.
*/
const SAFE_SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/;
function assertSafeSlug(slug: string): void {
if (!SAFE_SLUG.test(slug)) {
throw new Error(`Invalid agent slug: must match ${SAFE_SLUG}`);
}
}
function computeFingerprint(certPem: string): string {
const der = Buffer.from(
certPem
@ -91,6 +103,7 @@ export async function ensureCA() {
* Returns the certificate materials (plaintext) for one-time display.
*/
export async function issueAgentCert(instanceId: string, slug: string, agentUrl?: string) {
assertSafeSlug(slug);
const ca = await ensureCA();
const caKeyPem = decrypt(ca.encryptedKey);
@ -115,6 +128,11 @@ export async function issueAgentCert(instanceId: string, slug: string, agentUrl?
if (agentUrl) {
try {
const hostname = new URL(agentUrl).hostname;
// Guard against SAN injection via crafted hostname (commas/newlines would
// inject extra SAN entries into the extfile). Added 2026-04-12.
if (/[,\n\r\0]/.test(hostname)) {
throw new Error('Invalid hostname');
}
// Detect IP vs DNS name
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(hostname) || hostname.includes(':')) {
sanEntries.push(`IP:${hostname}`);

View File

@ -152,15 +152,22 @@ export async function checkInstanceHealth(instanceId: string) {
if (instance.status === InstanceStatus.RUNNING && !hasRunningContainers) {
await prisma.instance.update({
where: { id: instanceId },
data: { status: InstanceStatus.STOPPED },
data: {
status: InstanceStatus.STOPPED,
statusMessage: `No running containers detected at ${new Date().toISOString()}`,
},
});
logger.info(`[health] ${instance.slug}: auto-corrected status RUNNING → STOPPED (0 running containers)`);
} else if (instance.status === InstanceStatus.STOPPED && hasRunningContainers) {
const runningCount = containers.filter((c) => c.state === 'running').length;
await prisma.instance.update({
where: { id: instanceId },
data: { status: InstanceStatus.RUNNING },
data: {
status: InstanceStatus.RUNNING,
statusMessage: `${runningCount} container(s) running — detected at ${new Date().toISOString()}`,
},
});
logger.info(`[health] ${instance.slug}: auto-corrected status STOPPED → RUNNING (${containers.filter((c) => c.state === 'running').length} running containers detected)`);
logger.info(`[health] ${instance.slug}: auto-corrected status STOPPED → RUNNING (${runningCount} running containers detected)`);
}
// Sync domain and feature flags from .env if they have drifted

View File

@ -44,6 +44,12 @@ export interface InstanceSecrets {
jwtAccessSecret: string;
jwtRefreshSecret: string;
jwtInviteSecret: string;
// Added 2026-04-12 (P2-2): Changemaker Lite now requires distinct secrets for
// Gitea SSO cookies and service-account password derivation — the old
// JWT_ACCESS_SECRET fallback was removed. New instances provisioned by CCP
// must receive both to boot.
giteaSsoSecret: string;
servicePasswordSalt: string;
encryptionKey: string;
initialAdminPassword: string;
nocodbAdminPassword: string;
@ -69,6 +75,8 @@ export function generateSecrets(adminEmail: string): InstanceSecrets & { adminEm
jwtAccessSecret: randomHex(32),
jwtRefreshSecret: randomHex(32),
jwtInviteSecret: randomHex(32),
giteaSsoSecret: randomHex(32),
servicePasswordSalt: randomHex(32),
encryptionKey: randomHex(32),
initialAdminPassword: randomPassword(16),
nocodbAdminPassword: randomPassword(16),

View File

@ -62,7 +62,8 @@ function getPangolinClient(): CcpPangolinClient {
}
function fullSubdomain(prefix: string, sub: string): string {
if (!sub) return prefix; // root domain → prefix alone (e.g., "ck")
if (!prefix) return sub; // no prefix → use subdomain as-is (e.g., "app")
if (!sub) return prefix; // root domain → prefix alone (e.g., "ck")
return `${prefix}-${sub}`; // e.g., "ck-app", "ck-api"
}
@ -130,7 +131,10 @@ export async function setupTunnel(
throw new AppError(400, 'Tunnel is already configured. Use sync to update resources, or teardown first.', 'ALREADY_CONFIGURED');
}
const prefix = options.subdomainPrefix || instance.slug;
// Empty prefix means resources use standard subdomains (app., api., etc.)
// matching the instance's nginx config. A prefix like "ck" creates
// ck-app., ck-api., etc. for multi-tenant Pangolin domains.
const prefix = options.subdomainPrefix ?? '';
const driver = await getRemoteDriverForInstance({
id: instance.id,
@ -318,7 +322,7 @@ export async function syncResources(
if (!instance) throw new AppError(404, 'Instance not found', 'NOT_FOUND');
if (!instance.pangolinSiteId) throw new AppError(400, 'No tunnel configured', 'NO_TUNNEL');
const prefix = instance.pangolinSubdomainPrefix || instance.slug;
const prefix = instance.pangolinSubdomainPrefix ?? '';
const domain = await findDomainForInstance(client, instance.domain);
const existingResources = await client.listResources();
const siteId = instance.pangolinSiteId;
@ -387,7 +391,26 @@ export async function teardownTunnel(
const siteId = instance.pangolinSiteId;
// Delete site from Pangolin (cascades resources + targets)
// Pangolin does NOT cascade-delete resources when a site is deleted.
// We must delete resources first to avoid orphaned entries.
try {
const allResources = await client.listResources();
const siteIdNum = Number(siteId);
for (const res of allResources) {
try {
const targets = await client.listTargets(String(res.resourceId));
const ours = targets.some((t) => Number(t.siteId) === siteIdNum);
if (ours) {
await client.deleteResource(String(res.resourceId));
logger.info(`[tunnel] ${instance.slug}: deleted resource ${res.name} (${res.fullDomain})`);
}
} catch { /* target lookup failed — skip */ }
}
} catch (err) {
logger.warn(`[tunnel] ${instance.slug}: resource cleanup failed: ${(err as Error).message}`);
}
// Now delete the site itself
try {
await client.deleteSite(siteId);
logger.info(`[tunnel] ${instance.slug}: deleted Pangolin site ${siteId}`);
@ -549,6 +572,120 @@ export async function getTunnelStatus(instanceId: string): Promise<TunnelStatus>
};
}
// ─── Import existing tunnel ────────────────────────────────────────
/**
* Adopt a tunnel that was set up outside the CCP (e.g. by `config.sh --pangolin-site new`
* on the remote instance itself). Reads the remote `.env` via the agent, pulls the
* Pangolin vars, verifies the site exists in the CCP's Pangolin org, and persists the
* values on the Instance record so subsequent sync/teardown/status calls work.
*
* We DO NOT fetch the Newt secret from Pangolin it's only in the remote .env, and
* Pangolin's API does not expose it after site creation. If the remote .env is missing
* it, the user has to teardown + setup via CCP instead.
*/
export async function importTunnel(
instanceId: string,
userId?: string,
ipAddress?: string | null
) {
const client = getPangolinClient();
const instance = await prisma.instance.findUnique({ where: { id: instanceId } });
if (!instance) throw new AppError(404, 'Instance not found', 'NOT_FOUND');
if (!instance.isRemote) throw new AppError(400, 'Import only applies to remote instances', 'NOT_REMOTE');
if (instance.pangolinSiteId) {
throw new AppError(400, 'Tunnel already imported/configured — use sync or teardown instead', 'ALREADY_CONFIGURED');
}
// 1. Read the remote .env via mTLS agent
const driver = await getRemoteDriverForInstance({
id: instance.id,
slug: instance.slug,
isRemote: instance.isRemote,
agentUrl: instance.agentUrl,
});
const envVars = await driver.readEnvFile('');
if (!envVars) {
throw new AppError(502, 'Could not read .env from remote agent', 'AGENT_READ_FAILED');
}
// 2. Extract Pangolin vars
const siteId = envVars.PANGOLIN_SITE_ID?.trim();
const newtId = envVars.PANGOLIN_NEWT_ID?.trim();
const newtSecret = envVars.PANGOLIN_NEWT_SECRET?.trim();
const endpoint = envVars.PANGOLIN_ENDPOINT?.trim() || env.PANGOLIN_API_URL?.replace(/\/v1$/, '') || '';
const missing: string[] = [];
if (!siteId) missing.push('PANGOLIN_SITE_ID');
if (!newtId) missing.push('PANGOLIN_NEWT_ID');
if (!newtSecret) missing.push('PANGOLIN_NEWT_SECRET');
if (missing.length > 0) {
throw new AppError(
400,
`Remote .env missing Pangolin credentials: ${missing.join(', ')}. ` +
`Either the tunnel was never set up, or it was torn down. ` +
`Use "Setup Tunnel" to create a fresh one via CCP.`,
'REMOTE_ENV_MISSING_TUNNEL'
);
}
// 3. Verify the site exists in the CCP's Pangolin org (and that our API key can see it)
let onlineNow = false;
try {
const site = await client.getSite(siteId!);
onlineNow = site.online ?? false;
} catch (err) {
throw new AppError(
400,
`Site ${siteId} not found in CCP's Pangolin org (${env.PANGOLIN_ORG_ID}). ` +
`The remote instance may be using a different Pangolin org than the CCP is configured for. ` +
`Error: ${(err as Error).message}`,
'SITE_NOT_IN_CCP_ORG'
);
}
// 4. Persist on Instance record
await prisma.instance.update({
where: { id: instanceId },
data: {
pangolinEndpoint: endpoint || null,
pangolinSiteId: siteId,
pangolinNewtId: newtId,
pangolinNewtSecret: newtSecret,
// Note: we do NOT infer pangolinSubdomainPrefix — import assumes standard
// subdomains (app., api., etc.). If the user was using a prefix, they should
// teardown + setup via CCP instead.
},
});
// 5. Audit log
if (userId) {
await prisma.auditLog.create({
data: {
userId,
instanceId,
action: AuditAction.PANGOLIN_SETUP,
details: {
source: 'import',
siteId,
endpoint,
onlineAtImport: onlineNow,
} as unknown as Prisma.InputJsonValue,
ipAddress: ipAddress ?? null,
},
});
}
logger.info(`[tunnel] ${instance.slug}: imported existing tunnel (site ${siteId}, online=${onlineNow})`);
return {
imported: true,
siteId,
endpoint,
online: onlineNow,
};
}
// ─── .env Helpers ──────────────────────────────────────────────────
/**

View File

@ -9,6 +9,29 @@ import { createEvent } from './event.service';
import { getRemoteDriverForInstance } from './execution-driver';
import type { AgentUpdateStatus } from './remote-driver';
/**
* Shell-injection guards. Any user- or DB-controlled value that flows into
* `bash`/`git` via `exec()` must be validated against these regexes first.
* Added 2026-04-12 after red-team audit found unvalidated `branch` and `basePath`
* values reaching the shell.
*/
const SAFE_BRANCH = /^[a-zA-Z0-9][a-zA-Z0-9_.\/-]{0,99}$/;
const SAFE_PATH = /^\/[a-zA-Z0-9/_.-]{1,255}$/;
function assertSafeBranch(branch: string | null | undefined, ctx: string): void {
if (!branch) return;
if (!SAFE_BRANCH.test(branch)) {
throw new Error(`Invalid git branch name (${ctx}): must match ${SAFE_BRANCH}`);
}
}
function assertSafePath(p: string | null | undefined, ctx: string): void {
if (!p) return;
if (!SAFE_PATH.test(p)) {
throw new Error(`Invalid path (${ctx}): must match ${SAFE_PATH}`);
}
}
/**
* Write an INSTANCE_UPGRADE audit log entry capturing a terminal outcome.
* Wrapped in try/catch so that an audit-log DB failure cannot mask the
@ -227,6 +250,10 @@ export async function startUpgrade(
}
}
// Guard against shell injection via branch name (flows into bash exec).
assertSafeBranch(options?.branch, 'options.branch');
assertSafeBranch(instance.gitBranch, 'instance.gitBranch');
assertSafePath(instance.basePath, 'instance.basePath');
const branch = options?.branch || instance.gitBranch;
// Create upgrade record

View File

@ -26,7 +26,15 @@ JWT_ACCESS_SECRET={{secrets.jwtAccessSecret}}
JWT_REFRESH_SECRET={{secrets.jwtRefreshSecret}}
JWT_INVITE_SECRET={{secrets.jwtInviteSecret}}
JWT_ACCESS_EXPIRY=15m
JWT_REFRESH_EXPIRY=7d
# Reduced 2026-04-12 from 7d → 24h (P2-3). Combined with device-fingerprint
# binding in the refresh JWT payload, this tightens the exploitation window
# for stolen refresh tokens.
JWT_REFRESH_EXPIRY=24h
# Gitea SSO cookie signing + service password salt — REQUIRED 2026-04-12 (P2-2).
# Distinct from JWT secrets; empty values will now fail Zod validation on boot.
GITEA_SSO_SECRET={{secrets.giteaSsoSecret}}
SERVICE_PASSWORD_SALT={{secrets.servicePasswordSalt}}
# Encryption
ENCRYPTION_KEY={{secrets.encryptionKey}}

View File

@ -40,10 +40,12 @@ services:
- JWT_ACCESS_SECRET=${JWT_ACCESS_SECRET}
- JWT_REFRESH_SECRET=${JWT_REFRESH_SECRET}
- JWT_INVITE_SECRET=${JWT_INVITE_SECRET}
- GITEA_SSO_SECRET=${GITEA_SSO_SECRET:-}
- SERVICE_PASSWORD_SALT=${SERVICE_PASSWORD_SALT:-}
# Updated 2026-04-12 (P2-2, P2-3): these secrets are now REQUIRED (Zod
# .min(32)) — empty fallback removed. Refresh expiry default 7d → 24h.
- GITEA_SSO_SECRET=${GITEA_SSO_SECRET}
- SERVICE_PASSWORD_SALT=${SERVICE_PASSWORD_SALT}
- JWT_ACCESS_EXPIRY=${JWT_ACCESS_EXPIRY:-15m}
- JWT_REFRESH_EXPIRY=${JWT_REFRESH_EXPIRY:-7d}
- JWT_REFRESH_EXPIRY=${JWT_REFRESH_EXPIRY:-24h}
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
- INITIAL_ADMIN_EMAIL=${INITIAL_ADMIN_EMAIL:-admin@cmlite.org}
- INITIAL_ADMIN_PASSWORD=${INITIAL_ADMIN_PASSWORD:?INITIAL_ADMIN_PASSWORD must be set in .env}
@ -185,6 +187,12 @@ services:
- JWT_ACCESS_SECRET=${JWT_ACCESS_SECRET}
- JWT_REFRESH_SECRET=${JWT_REFRESH_SECRET}
- JWT_INVITE_SECRET=${JWT_INVITE_SECRET}
# Added 2026-04-12 (P2-2): media-api shares the api's env schema; both
# require these secrets to boot.
- GITEA_SSO_SECRET=${GITEA_SSO_SECRET}
- SERVICE_PASSWORD_SALT=${SERVICE_PASSWORD_SALT}
- JWT_ACCESS_EXPIRY=${JWT_ACCESS_EXPIRY:-15m}
- JWT_REFRESH_EXPIRY=${JWT_REFRESH_EXPIRY:-24h}
- CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:3000,http://localhost:3100}
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
- ENABLE_MEDIA_FEATURES=${ENABLE_MEDIA_FEATURES:-true}
@ -1381,6 +1389,9 @@ services:
- /var/run/docker.sock:/var/run/docker.sock
- ccp-agent-data:/var/lib/ccp-agent
- ccp-agent-certs:/etc/ccp-agent
# Mount the instance directory so the agent can read compose files and run
# `docker compose -p <project>` commands against the real project on disk.
- .:/app/instance:ro
environment:
- AGENT_PORT=7443
- AGENT_DATA_DIR=/var/lib/ccp-agent
@ -1390,6 +1401,9 @@ services:
- INSTANCE_SLUG=${COMPOSE_PROJECT_NAME:-changemaker-lite}
- INSTANCE_DOMAIN=${DOMAIN:-localhost}
- INSTANCE_BASE_PATH=/app/instance
# Pass the host's compose project name so the agent runs `docker compose -p <project>`
# against the right project (not basename of INSTANCE_BASE_PATH, which is "instance").
- COMPOSE_PROJECT=${COMPOSE_PROJECT_NAME:-changemaker-lite}
logging: *default-logging
networks:
- changemaker-lite

View File

@ -39,10 +39,12 @@ services:
- JWT_ACCESS_SECRET=${JWT_ACCESS_SECRET}
- JWT_REFRESH_SECRET=${JWT_REFRESH_SECRET}
- JWT_INVITE_SECRET=${JWT_INVITE_SECRET}
- GITEA_SSO_SECRET=${GITEA_SSO_SECRET:-}
- SERVICE_PASSWORD_SALT=${SERVICE_PASSWORD_SALT:-}
# Updated 2026-04-12 (P2-2, P2-3): removed `:-` fallback (empty default)
# now that these are required, and shortened refresh expiry default 7d→24h.
- GITEA_SSO_SECRET=${GITEA_SSO_SECRET}
- SERVICE_PASSWORD_SALT=${SERVICE_PASSWORD_SALT}
- JWT_ACCESS_EXPIRY=${JWT_ACCESS_EXPIRY:-15m}
- JWT_REFRESH_EXPIRY=${JWT_REFRESH_EXPIRY:-7d}
- JWT_REFRESH_EXPIRY=${JWT_REFRESH_EXPIRY:-24h}
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
- INITIAL_ADMIN_EMAIL=${INITIAL_ADMIN_EMAIL:-admin@cmlite.org}
- INITIAL_ADMIN_PASSWORD=${INITIAL_ADMIN_PASSWORD:?INITIAL_ADMIN_PASSWORD must be set in .env}
@ -192,6 +194,12 @@ services:
- JWT_ACCESS_SECRET=${JWT_ACCESS_SECRET}
- JWT_REFRESH_SECRET=${JWT_REFRESH_SECRET}
- JWT_INVITE_SECRET=${JWT_INVITE_SECRET}
# Added 2026-04-12 (P2-2): media-api shares the same env schema as api;
# both require these secrets to boot.
- GITEA_SSO_SECRET=${GITEA_SSO_SECRET}
- SERVICE_PASSWORD_SALT=${SERVICE_PASSWORD_SALT}
- JWT_ACCESS_EXPIRY=${JWT_ACCESS_EXPIRY:-15m}
- JWT_REFRESH_EXPIRY=${JWT_REFRESH_EXPIRY:-24h}
- CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:3000,http://localhost:3100}
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
- ENABLE_MEDIA_FEATURES=${ENABLE_MEDIA_FEATURES:-true}
@ -1411,6 +1419,9 @@ services:
- INSTANCE_SLUG=${COMPOSE_PROJECT_NAME:-changemaker-lite}
- INSTANCE_DOMAIN=${DOMAIN:-localhost}
- INSTANCE_BASE_PATH=/app/instance
# Pass the host's compose project name so the agent runs `docker compose -p <project>`
# against the right project (not basename of INSTANCE_BASE_PATH, which is "instance").
- COMPOSE_PROJECT=${COMPOSE_PROJECT_NAME:-changemaker-lite}
logging: *default-logging
networks:
- changemaker-lite