## 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
90 lines
2.9 KiB
TypeScript
90 lines
2.9 KiB
TypeScript
import { Router, Request, Response, NextFunction } from 'express';
|
|
import { campaignEmailsService } from './campaign-emails.service';
|
|
import { campaignsService } from '../campaigns/campaigns.service';
|
|
import {
|
|
sendCampaignEmailSchema,
|
|
trackMailtoSchema,
|
|
listCampaignEmailsSchema,
|
|
} from './campaign-emails.schemas';
|
|
import { validate } from '../../../middleware/validate';
|
|
import { authenticate } from '../../../middleware/auth.middleware';
|
|
import { requireRole } from '../../../middleware/rbac.middleware';
|
|
import { emailRateLimit } from '../../../middleware/rate-limit';
|
|
import { INFLUENCE_ROLES } from '../../../utils/roles';
|
|
|
|
// --- Public Routes (no auth) ---
|
|
const publicRouter = Router();
|
|
|
|
// POST /api/campaigns/:slug/send-email
|
|
publicRouter.post(
|
|
'/:slug/send-email',
|
|
emailRateLimit,
|
|
validate(sendCampaignEmailSchema),
|
|
async (req: Request, res: Response, next: NextFunction) => {
|
|
try {
|
|
const slug = req.params.slug as string;
|
|
const senderIp = req.ip || req.socket.remoteAddress;
|
|
const result = await campaignEmailsService.sendEmail(slug, req.body, senderIp);
|
|
res.status(201).json(result);
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
}
|
|
);
|
|
|
|
// POST /api/campaigns/:slug/track-mailto
|
|
publicRouter.post(
|
|
'/:slug/track-mailto',
|
|
emailRateLimit,
|
|
validate(trackMailtoSchema),
|
|
async (req: Request, res: Response, next: NextFunction) => {
|
|
try {
|
|
const slug = req.params.slug as string;
|
|
const senderIp = req.ip || req.socket.remoteAddress;
|
|
const result = await campaignEmailsService.trackMailto(slug, req.body, senderIp);
|
|
res.status(201).json(result);
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
}
|
|
);
|
|
|
|
// --- Admin Routes (auth required) ---
|
|
const adminRouter = Router();
|
|
adminRouter.use(authenticate);
|
|
adminRouter.use(requireRole(...INFLUENCE_ROLES));
|
|
|
|
// 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) {
|
|
next(err);
|
|
}
|
|
}
|
|
);
|
|
|
|
// 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) {
|
|
next(err);
|
|
}
|
|
}
|
|
);
|
|
|
|
export { publicRouter as campaignEmailsPublicRouter, adminRouter as campaignEmailsAdminRouter };
|