bunker-admin e55bc07eb6 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
2026-04-12 15:17:00 -06:00

502 lines
16 KiB
TypeScript

import { Router, Request, Response } from 'express';
import { AuditAction } from '@prisma/client';
import rateLimit from 'express-rate-limit';
import { prisma } from '../../lib/prisma';
import { authenticate, requireRole } from '../../middleware/auth';
import { validate } from '../../middleware/validate';
import { createInstanceSchema, updateInstanceSchema, registerInstanceSchema, reconfigureInstanceSchema, configureTunnelSchema, importInstancesSchema, startUpgradeSchema, setupRemoteTunnelSchema } from './instances.schemas';
import * as instancesService from './instances.service';
import * as healthService from '../../services/health.service';
import * as backupService from '../../services/backup.service';
import * as restoreService from '../../services/restore.service';
import * as upgradeService from '../../services/upgrade.service';
import * as tunnelService from '../../services/tunnel.service';
import { discoverInstances } from '../../services/discovery.service';
const secretsLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
standardHeaders: true,
legacyHeaders: false,
message: { error: { message: 'Too many secrets requests, please try again later', code: 'RATE_LIMITED' } },
});
const router = Router();
// All instance routes require authentication
router.use(authenticate);
// ─── Discovery Endpoints ────────────────────────────────────────────
router.post(
'/discover',
requireRole('SUPER_ADMIN', 'OPERATOR'),
async (_req: Request, res: Response) => {
const result = await discoverInstances();
res.json({ data: result });
}
);
router.post(
'/import',
requireRole('SUPER_ADMIN', 'OPERATOR'),
validate(importInstancesSchema),
async (req: Request, res: Response) => {
const { instances } = req.body as { instances: Array<Record<string, unknown>> };
const results: Array<{ slug: string; success: boolean; instanceId?: string; error?: string }> = [];
for (const inst of instances) {
try {
const registered = await instancesService.registerInstance(
inst as Parameters<typeof instancesService.registerInstance>[0],
req.user!.id,
req.ip
);
results.push({ slug: inst.slug as string, success: true, instanceId: registered?.id });
} catch (err) {
results.push({ slug: inst.slug as string, success: false, error: (err as Error).message });
}
}
const succeeded = results.filter((r) => r.success).length;
const failed = results.filter((r) => !r.success).length;
res.json({
data: {
results,
summary: { total: results.length, succeeded, failed },
},
});
}
);
// ─── CRUD Endpoints ──────────────────────────────────────────────────
router.get('/', requireRole('SUPER_ADMIN', 'OPERATOR'), async (_req: Request, res: Response) => {
const instances = await instancesService.listInstances();
res.json({ data: instances });
});
// Register an existing (externally-managed) instance for monitoring
router.post(
'/register',
requireRole('SUPER_ADMIN', 'OPERATOR'),
validate(registerInstanceSchema),
async (req: Request, res: Response) => {
const instance = await instancesService.registerInstance(req.body, req.user!.id, req.ip);
res.status(201).json({ data: instance });
}
);
router.get('/:id', requireRole('SUPER_ADMIN', 'OPERATOR'), async (req: Request, res: Response) => {
const instance = await instancesService.getInstance(req.params.id as string);
res.json({ data: instance });
});
router.post(
'/',
requireRole('SUPER_ADMIN', 'OPERATOR'),
validate(createInstanceSchema),
async (req: Request, res: Response) => {
const instance = await instancesService.createInstance(req.body, req.user!.id, req.ip);
res.status(201).json({ data: instance });
}
);
router.put(
'/:id',
requireRole('SUPER_ADMIN', 'OPERATOR'),
validate(updateInstanceSchema),
async (req: Request, res: Response) => {
const instance = await instancesService.updateInstance(
req.params.id as string,
req.body,
req.user!.id,
req.ip
);
res.json({ data: instance });
}
);
router.delete(
'/:id',
requireRole('SUPER_ADMIN'),
async (req: Request, res: Response) => {
const result = await instancesService.deleteInstance(req.params.id as string, req.user!.id, req.ip);
res.json(result);
}
);
// Get decrypted secrets (SUPER_ADMIN only, rate limited)
router.get(
'/:id/secrets',
secretsLimiter,
requireRole('SUPER_ADMIN'),
async (req: Request, res: Response) => {
const secrets = await instancesService.getInstanceSecrets(req.params.id as string);
// Audit log: someone viewed secrets
await prisma.auditLog.create({
data: {
userId: req.user!.id,
instanceId: req.params.id as string,
action: AuditAction.SECRETS_VIEWED,
details: { instanceId: req.params.id as string },
ipAddress: req.ip,
},
});
res.set('Cache-Control', 'no-store');
res.json({ data: secrets });
}
);
// ─── Reconfiguration ────────────────────────────────────────────────
router.post(
'/:id/reconfigure',
requireRole('SUPER_ADMIN', 'OPERATOR'),
validate(reconfigureInstanceSchema),
async (req: Request, res: Response) => {
const result = await instancesService.reconfigureInstance(
req.params.id as string,
req.body,
req.user!.id,
req.ip
);
res.json({ data: result });
}
);
// ─── Tunnel Management ──────────────────────────────────────────────
router.post(
'/:id/tunnel',
requireRole('SUPER_ADMIN', 'OPERATOR'),
validate(configureTunnelSchema),
async (req: Request, res: Response) => {
const result = await instancesService.configureTunnel(
req.params.id as string,
req.body,
req.user!.id,
req.ip
);
res.json({ data: result });
}
);
router.delete(
'/:id/tunnel',
requireRole('SUPER_ADMIN', 'OPERATOR'),
async (req: Request, res: Response) => {
// Branch: remote instances use the CCP's Pangolin API to teardown;
// local instances use the existing manual removal logic.
const instance = await prisma.instance.findUnique({ where: { id: req.params.id as string } });
if (instance?.isRemote && instance.pangolinSiteId) {
const result = await tunnelService.teardownTunnel(
req.params.id as string,
req.user!.id,
req.ip
);
res.json({ data: result });
return;
}
const result = await instancesService.removeTunnel(
req.params.id as string,
req.user!.id,
req.ip
);
res.json({ data: result });
}
);
// Remote tunnel setup via CCP's Pangolin API credentials
router.post(
'/:id/tunnel/setup',
requireRole('SUPER_ADMIN'),
validate(setupRemoteTunnelSchema),
async (req: Request, res: Response) => {
const { subdomainPrefix } = req.body || {};
const result = await tunnelService.setupTunnel(
req.params.id as string,
{ subdomainPrefix },
req.user!.id,
req.ip
);
res.status(201).json({ data: result });
}
);
// Get tunnel status (resource matrix) — works for both local and remote
router.get(
'/:id/tunnel/status',
requireRole('SUPER_ADMIN', 'OPERATOR'),
async (req: Request, res: Response) => {
const status = await tunnelService.getTunnelStatus(req.params.id as string);
res.json({ data: status });
}
);
// Re-sync resources (idempotent — creates missing, leaves existing)
router.post(
'/:id/tunnel/sync',
requireRole('SUPER_ADMIN'),
async (req: Request, res: Response) => {
const result = await tunnelService.syncResources(
req.params.id as string,
req.user!.id,
req.ip
);
res.json({ data: result });
}
);
// 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(
'/:id/provision',
requireRole('SUPER_ADMIN', 'OPERATOR'),
async (req: Request, res: Response) => {
const result = await instancesService.provisionInstance(req.params.id as string, req.user!.id, req.ip);
res.json(result);
}
);
router.post(
'/:id/start',
requireRole('SUPER_ADMIN', 'OPERATOR'),
async (req: Request, res: Response) => {
const result = await instancesService.startInstance(req.params.id as string, req.user!.id, req.ip);
res.json(result);
}
);
router.post(
'/:id/stop',
requireRole('SUPER_ADMIN', 'OPERATOR'),
async (req: Request, res: Response) => {
const result = await instancesService.stopInstance(req.params.id as string, req.user!.id, req.ip);
res.json(result);
}
);
router.post(
'/:id/restart',
requireRole('SUPER_ADMIN', 'OPERATOR'),
async (req: Request, res: Response) => {
const service = req.query.service as string | undefined;
const result = await instancesService.restartInstance(
req.params.id as string,
req.user!.id,
req.ip,
service
);
res.json(result);
}
);
// ─── Services & Logs ─────────────────────────────────────────────────
router.get(
'/:id/services',
requireRole('SUPER_ADMIN', 'OPERATOR'),
async (req: Request, res: Response) => {
const services = await instancesService.getInstanceServices(req.params.id as string);
res.json({ data: services });
}
);
router.get(
'/:id/logs',
requireRole('SUPER_ADMIN', 'OPERATOR'),
async (req: Request, res: Response) => {
const { service, tail, since } = req.query;
const tailNum = tail ? Math.min(Math.max(parseInt(tail as string, 10) || 200, 1), 2000) : 200;
const logs = await instancesService.getInstanceLogs(
req.params.id as string,
service as string | undefined,
tailNum,
since as string | undefined
);
res.json({ data: logs });
}
);
// ─── Upgrades ──────────────────────────────────────────────────────
router.post(
'/:id/check-update',
requireRole('SUPER_ADMIN', 'OPERATOR'),
async (req: Request, res: Response) => {
const status = await upgradeService.checkForUpdates(req.params.id as string);
res.json({ data: status });
}
);
router.post(
'/:id/upgrade',
requireRole('SUPER_ADMIN', 'OPERATOR'),
validate(startUpgradeSchema),
async (req: Request, res: Response) => {
const { skipBackup, useRegistry, branch } = req.body || {};
const upgrade = await upgradeService.startUpgrade(
req.params.id as string,
req.user!.id,
req.ip,
{ skipBackup, useRegistry, branch }
);
res.status(201).json({ data: upgrade });
}
);
router.get(
'/:id/upgrade-status',
requireRole('SUPER_ADMIN', 'OPERATOR'),
async (req: Request, res: Response) => {
const status = await upgradeService.getUpgradeStatus(req.params.id as string);
res.json({ data: status });
}
);
router.get(
'/:id/upgrade-history',
requireRole('SUPER_ADMIN', 'OPERATOR'),
async (req: Request, res: Response) => {
const page = Math.max(1, parseInt(req.query.page as string, 10) || 1);
const limit = Math.min(100, Math.max(1, parseInt(req.query.limit as string, 10) || 20));
const result = await upgradeService.getUpgradeHistory(req.params.id as string, page, limit);
res.json(result);
}
);
// ─── Health Checks ──────────────────────────────────────────────────
router.post(
'/:id/health-check',
requireRole('SUPER_ADMIN', 'OPERATOR'),
async (req: Request, res: Response) => {
const check = await healthService.checkInstanceHealth(req.params.id as string);
res.json({ data: check });
}
);
router.get(
'/:id/health-history',
requireRole('SUPER_ADMIN', 'OPERATOR'),
async (req: Request, res: Response) => {
const page = Math.max(1, parseInt(req.query.page as string, 10) || 1);
const limit = Math.min(100, Math.max(1, parseInt(req.query.limit as string, 10) || 20));
const result = await healthService.getHealthHistory(req.params.id as string, page, limit);
res.json(result);
}
);
// ─── Backups ────────────────────────────────────────────────────────
router.post(
'/:id/backup',
requireRole('SUPER_ADMIN', 'OPERATOR'),
async (req: Request, res: Response) => {
const backup = await backupService.createBackup(req.params.id as string, req.user!.id, req.ip);
res.status(201).json({ data: backup });
}
);
router.get(
'/:id/backups',
requireRole('SUPER_ADMIN', 'OPERATOR'),
async (req: Request, res: Response) => {
const page = Math.max(1, parseInt(req.query.page as string, 10) || 1);
const limit = Math.min(100, Math.max(1, parseInt(req.query.limit as string, 10) || 50));
const result = await backupService.listBackups(req.params.id as string, page, limit);
res.json(result);
}
);
// ─── Restores ──────────────────────────────────────────────────────
/**
* POST /:id/restore
* Body: { backupId, options? }
* Starts a restore of the given backup onto this instance. Returns the
* InstanceRestore row immediately; caller polls GET /:id/restores or
* GET /:id/restores/:restoreId for status.
*
* DESTRUCTIVE: overwrites databases and uploads. Requires SUPER_ADMIN.
*/
router.post(
'/:id/restore',
requireRole('SUPER_ADMIN'),
async (req: Request, res: Response) => {
const instanceId = req.params.id as string;
const { backupId, options } = req.body ?? {};
if (!backupId || typeof backupId !== 'string') {
res.status(400).json({ error: { message: 'backupId (string) is required', code: 'VALIDATION' } });
return;
}
// Defensive: ensure the backup belongs to this instance
const backup = await prisma.backup.findUnique({ where: { id: backupId } });
if (!backup) {
res.status(404).json({ error: { message: 'Backup not found', code: 'NOT_FOUND' } });
return;
}
if (backup.instanceId !== instanceId) {
res.status(400).json({
error: {
message: 'Backup does not belong to this instance (cross-instance restore is not supported)',
code: 'CROSS_INSTANCE_RESTORE',
},
});
return;
}
const restore = await restoreService.createRestore({
backupId,
triggeredById: req.user!.id,
ipAddress: req.ip,
options,
});
res.status(201).json({ data: restore });
}
);
router.get(
'/:id/restores',
requireRole('SUPER_ADMIN', 'OPERATOR'),
async (req: Request, res: Response) => {
const page = Math.max(1, parseInt(req.query.page as string, 10) || 1);
const limit = Math.min(100, Math.max(1, parseInt(req.query.limit as string, 10) || 50));
const result = await restoreService.listRestores(req.params.id as string, page, limit);
res.json(result);
}
);
router.get(
'/:id/restores/:restoreId',
requireRole('SUPER_ADMIN', 'OPERATOR'),
async (req: Request, res: Response) => {
const restore = await restoreService.getRestore(req.params.restoreId as string);
if (restore.instanceId !== req.params.id) {
res.status(404).json({ error: { message: 'Restore not found', code: 'NOT_FOUND' } });
return;
}
res.json({ data: restore });
}
);
export default router;