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:
parent
26ec925d9b
commit
e55bc07eb6
23
.env.example
23
.env.example
@ -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
|
||||
|
||||
@ -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──────────────────────────────┘
|
||||
│
|
||||
┌───────────┴───────────┐
|
||||
▼ ▼
|
||||
|
||||
134
SECURITY_REDTEAM_2026-04-12.md
Normal file
134
SECURITY_REDTEAM_2026-04-12.md
Normal 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.
|
||||
6
admin/package-lock.json
generated
6
admin/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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]);
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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 || '') }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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' && (
|
||||
|
||||
@ -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'}
|
||||
{' '}·{' '}
|
||||
{dayjs(response.createdAt).format('MMM D, YYYY')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
@ -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
26
admin/src/types/leaflet-heat.d.ts
vendored
Normal 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;
|
||||
}
|
||||
@ -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, '');
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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' });
|
||||
|
||||
@ -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:',
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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 };
|
||||
},
|
||||
};
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -16,7 +16,13 @@ function escapeHtml(unsafe: string): string {
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@ -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,
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -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, {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
44
api/src/modules/media/routes/sign.routes.ts
Normal file
44
api/src/modules/media/routes/sign.routes.ts
Normal 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(),
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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
|
||||
},
|
||||
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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
|
||||
},
|
||||
|
||||
|
||||
57
api/src/utils/device-fingerprint.ts
Normal file
57
api/src/utils/device-fingerprint.ts
Normal 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
105
api/src/utils/signed-url.ts
Normal 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 };
|
||||
}
|
||||
@ -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'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}>
|
||||
|
||||
@ -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}`);
|
||||
}
|
||||
|
||||
@ -0,0 +1,2 @@
|
||||
-- DropIndex
|
||||
DROP INDEX "instances_compose_project_key";
|
||||
@ -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")
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@ -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}`);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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 ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user