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:
2026-04-12 15:17:00 -06:00
parent 26ec925d9b
commit e55bc07eb6
59 changed files with 1387 additions and 510 deletions

View File

@@ -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();

View File

@@ -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' });

View File

@@ -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:',

View File

@@ -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);
}

View File

@@ -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 };
},
};

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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,

View File

@@ -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,

View File

@@ -16,7 +16,13 @@ function escapeHtml(unsafe: string): string {
.replace(/'/g, '&#039;');
}
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),
});
},
};

View File

@@ -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,
};
},

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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);
}

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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,
};
}
}

View File

@@ -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, {

View File

@@ -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;
}

View 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(),
});
}
);
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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();
});

View File

@@ -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',

View File

@@ -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
},

View File

@@ -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');

View File

@@ -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');

View File

@@ -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
},

View 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
View 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 };
}