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:
@@ -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();
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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:',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -16,7 +16,13 @@ function escapeHtml(unsafe: string): string {
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
44
api/src/modules/media/routes/sign.routes.ts
Normal file
44
api/src/modules/media/routes/sign.routes.ts
Normal 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(),
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
|
||||
57
api/src/utils/device-fingerprint.ts
Normal file
57
api/src/utils/device-fingerprint.ts
Normal 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
105
api/src/utils/signed-url.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user