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:
@@ -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}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user