## 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
66 lines
2.3 KiB
TypeScript
66 lines
2.3 KiB
TypeScript
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';
|
|
|
|
/**
|
|
* 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:',
|
|
}),
|
|
message: {
|
|
error: {
|
|
message: 'Too many verification email requests, please try again later',
|
|
code: 'VERIFICATION_RATE_LIMIT_EXCEEDED',
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
/** 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:',
|
|
}),
|
|
message: {
|
|
error: {
|
|
message: 'Too many password reset requests, please try again later',
|
|
code: 'RESET_RATE_LIMIT_EXCEEDED',
|
|
},
|
|
},
|
|
});
|
|
}
|