Security audit fixes, mobile responsiveness across 40+ admin pages
Security hardening from Mar 31 audit:
- Separate login rate limit (10/15min) from general auth budget (15/15min)
- Timing-safe webhook secret comparison (Listmonk)
- Docs file creation ACL check (matches PUT/DELETE guards)
- Key separation warnings for GITEA_SSO_SECRET and SERVICE_PASSWORD_SALT
- Clear GITEA_ADMIN_PASSWORD from .env after auto-setup
- SQL injection prevention in effectiveness groupBy (pre-validated map)
- Token hashing for password reset and verification tokens
Mobile responsiveness (Phase 2C):
- Add MobilePageHeader component and useMobile hook
- Responsive table columns (hide secondary cols on mobile)
- scroll={{ x: 'max-content' }} across all data tables
- Mobile-adapted layouts for Dashboard, Settings, Calendar, SMS, Social pages
- Conditional toolbar buttons on mobile viewports
Infrastructure:
- Updated docker-compose and nginx templates
- Build script and mirror script updates
Bunker Admin
This commit is contained in:
@@ -38,9 +38,9 @@ const envSchema = z.object({
|
||||
// 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 (falls back to JWT_ACCESS_SECRET if empty)
|
||||
// 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 — falls back to JWT_ACCESS_SECRET if empty)
|
||||
// Salt for deriving deterministic service passwords (Gitea, Rocket.Chat) — MUST be unique
|
||||
SERVICE_PASSWORD_SALT: z.string().default(''),
|
||||
|
||||
// Initial Super Admin (auto-created during database seeding)
|
||||
@@ -259,7 +259,17 @@ function validateEnv(): Env {
|
||||
console.error(result.error.flatten().fieldErrors);
|
||||
process.exit(1);
|
||||
}
|
||||
return result.data;
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
export const env = validateEnv();
|
||||
|
||||
@@ -209,7 +209,7 @@ export const paymentCheckoutRateLimit = rateLimit({
|
||||
|
||||
export const authRateLimit = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 10, // Reduced from 20 to prevent brute force attacks
|
||||
max: 15,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
store: new RedisStore({
|
||||
@@ -224,6 +224,25 @@ export const authRateLimit = rateLimit({
|
||||
},
|
||||
});
|
||||
|
||||
// Separate stricter rate limit for login to prevent credential stuffing
|
||||
// Isolated from the general auth budget so register/refresh/logout don't consume login slots
|
||||
export const loginRateLimit = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 10,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
store: new RedisStore({
|
||||
sendCommand: (command: string, ...args: string[]) => redis.call(command, ...args) as Promise<any>,
|
||||
prefix: 'rl:login:',
|
||||
}),
|
||||
message: {
|
||||
error: {
|
||||
message: 'Too many login attempts, please try again later',
|
||||
code: 'LOGIN_RATE_LIMIT_EXCEEDED',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const observabilityRateLimit = rateLimit({
|
||||
windowMs: 60 * 1000, // 1 minute
|
||||
max: 20, // 20 requests per minute (stricter than global 500/min)
|
||||
|
||||
@@ -7,7 +7,7 @@ import { authService } from './auth.service';
|
||||
import { loginSchema, registerSchema, refreshSchema } from './auth.schemas';
|
||||
import { validate } from '../../middleware/validate';
|
||||
import { authenticate } from '../../middleware/auth.middleware';
|
||||
import { authRateLimit } from '../../middleware/rate-limit';
|
||||
import { authRateLimit, loginRateLimit } from '../../middleware/rate-limit';
|
||||
import { prisma } from '../../config/database';
|
||||
import { verificationTokenService } from '../../services/verification-token.service';
|
||||
import { passwordResetTokenService } from '../../services/password-reset-token.service';
|
||||
@@ -99,7 +99,7 @@ function clearSessionCookie(req: Request, res: Response) {
|
||||
// POST /api/auth/login
|
||||
router.post(
|
||||
'/login',
|
||||
authRateLimit,
|
||||
loginRateLimit,
|
||||
validate(loginSchema),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
|
||||
@@ -12,7 +12,7 @@ export const docsAnalyticsPublicRouter = Router();
|
||||
|
||||
// Per-route CORS override: MkDocs runs on a different origin (root domain vs API subdomain)
|
||||
import { env } from '../../config/env';
|
||||
const DOCS_ORIGIN = env.ADMIN_URL || `https://docs.${env.DOMAIN}`;
|
||||
const DOCS_ORIGIN = env.DOMAIN ? `https://docs.${env.DOMAIN}` : (env.ADMIN_URL || 'http://localhost:4003');
|
||||
docsAnalyticsPublicRouter.use((_req, res, next) => {
|
||||
res.setHeader('Access-Control-Allow-Origin', DOCS_ORIGIN);
|
||||
res.setHeader('Vary', 'Origin');
|
||||
|
||||
@@ -397,6 +397,15 @@ router.post(
|
||||
res.status(400).json({ error: { message: 'File path required', code: 'VALIDATION_ERROR' } });
|
||||
return;
|
||||
}
|
||||
|
||||
// Per-file access check (matches PUT/DELETE guards)
|
||||
const userRoles = getUserRoles(req.user!);
|
||||
const canEdit = await docsAccessService.canUserEdit(req.user!.id, userRoles, filePath);
|
||||
if (!canEdit) {
|
||||
res.status(403).json({ error: { message: 'You do not have edit access to this directory', code: 'DOC_ACCESS_DENIED' } });
|
||||
return;
|
||||
}
|
||||
|
||||
const { content, isDirectory } = req.body as { content?: string; isDirectory?: boolean };
|
||||
await docsFilesService.createFile(filePath, content, isDirectory);
|
||||
res.status(201).json({ success: true, path: filePath });
|
||||
|
||||
@@ -3,6 +3,7 @@ import { prisma } from '../../config/database';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { encrypt } from '../../utils/crypto';
|
||||
import { giteaClient } from '../../services/gitea.client';
|
||||
import { updateEnvFile } from '../../services/env-writer.service';
|
||||
|
||||
const SETUP_TIMEOUT = 15000;
|
||||
|
||||
@@ -429,6 +430,17 @@ async function autoSetupIfNeeded(): Promise<{ alreadyComplete: boolean; success:
|
||||
for (const step of result.steps) {
|
||||
logger.info(` ${step.step}: ${step.success ? 'OK' : 'FAILED'}${step.data?.note ? ` (${step.data.note})` : ''}`);
|
||||
}
|
||||
|
||||
// Clear GITEA_ADMIN_PASSWORD from .env — it's no longer needed after setup
|
||||
// (the API token is now encrypted in the database)
|
||||
try {
|
||||
const envResult = updateEnvFile({ GITEA_ADMIN_PASSWORD: '' });
|
||||
if (envResult.success) {
|
||||
logger.info('Gitea auto-setup: cleared GITEA_ADMIN_PASSWORD from .env');
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`Gitea auto-setup: could not clear GITEA_ADMIN_PASSWORD from .env: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
} else {
|
||||
logger.warn(`Gitea auto-setup: failed — ${result.error}`);
|
||||
for (const step of result.steps) {
|
||||
|
||||
@@ -286,7 +286,12 @@ export const effectivenessService = {
|
||||
}
|
||||
|
||||
// For city/province grouping, we need to join with postal_code_cache
|
||||
const groupCol = query.groupBy === 'province' ? Prisma.raw('pcc.province') : Prisma.raw('pcc.city');
|
||||
// Use pre-validated lookup map to prevent SQL injection if enum expands
|
||||
const GROUP_COL_MAP: Record<string, ReturnType<typeof Prisma.sql>> = {
|
||||
province: Prisma.sql`pcc.province`,
|
||||
city: Prisma.sql`pcc.city`,
|
||||
};
|
||||
const groupCol = GROUP_COL_MAP[query.groupBy!] ?? GROUP_COL_MAP.city;
|
||||
const campaignFilter = query.campaignId
|
||||
? Prisma.sql`AND ce."campaignId" = ${query.campaignId}`
|
||||
: Prisma.sql``;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { timingSafeEqual } from 'crypto';
|
||||
import { prisma } from '../../config/database';
|
||||
import { env } from '../../config/env';
|
||||
import { logger } from '../../utils/logger';
|
||||
@@ -18,7 +19,14 @@ router.post(
|
||||
try {
|
||||
// Accept secret from header only (query param removed — secrets must not appear in logs)
|
||||
const secret = req.headers['x-webhook-secret'] as string;
|
||||
if (!env.LISTMONK_WEBHOOK_SECRET || secret !== env.LISTMONK_WEBHOOK_SECRET) {
|
||||
if (!env.LISTMONK_WEBHOOK_SECRET || !secret) {
|
||||
res.status(403).json({ error: 'Invalid webhook secret' });
|
||||
return;
|
||||
}
|
||||
// Constant-time comparison to prevent timing attacks
|
||||
const incoming = Buffer.from(secret);
|
||||
const expected = Buffer.from(env.LISTMONK_WEBHOOK_SECRET);
|
||||
if (incoming.length !== expected.length || !timingSafeEqual(incoming, expected)) {
|
||||
res.status(403).json({ error: 'Invalid webhook secret' });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -18,13 +18,14 @@ import { locationsService } from '../locations/locations.service';
|
||||
import { geocodingService } from '../geocoding/geocoding.service';
|
||||
import { validate } from '../../../middleware/validate';
|
||||
import { authenticate } from '../../../middleware/auth.middleware';
|
||||
import { requireRole } from '../../../middleware/rbac.middleware';
|
||||
import { requireRole, requireNonTemp } from '../../../middleware/rbac.middleware';
|
||||
import { canvassVisitRateLimit, canvassBulkVisitRateLimit, canvassGeocodeRateLimit } from '../../../middleware/rate-limit';
|
||||
import { MAP_ROLES } from '../../../utils/roles';
|
||||
|
||||
// ─── Volunteer Router ────────────────────────────────────────────────
|
||||
const volunteerRouter = Router();
|
||||
volunteerRouter.use(authenticate);
|
||||
volunteerRouter.use(requireNonTemp);
|
||||
|
||||
// GET /api/map/canvass/my/assignments
|
||||
volunteerRouter.get(
|
||||
|
||||
@@ -73,7 +73,7 @@ volunteerRouter.post(
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const id = req.params.id as string;
|
||||
const session = await trackingService.linkCanvassSession(id, req.body);
|
||||
const session = await trackingService.linkCanvassSession(id, req.user!.id, req.body);
|
||||
res.json(session);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
|
||||
@@ -113,8 +113,14 @@ class TrackingService {
|
||||
});
|
||||
}
|
||||
|
||||
/** Link a canvass session to an existing tracking session. */
|
||||
async linkCanvassSession(trackingSessionId: string, data: LinkCanvassInput) {
|
||||
/** Link a canvass session to an existing tracking session (ownership-verified). */
|
||||
async linkCanvassSession(trackingSessionId: string, userId: string, data: LinkCanvassInput) {
|
||||
const session = await prisma.trackingSession.findFirst({
|
||||
where: { id: trackingSessionId, userId, isActive: true },
|
||||
});
|
||||
if (!session) {
|
||||
throw Object.assign(new Error('Active tracking session not found'), { statusCode: 404 });
|
||||
}
|
||||
return prisma.trackingSession.update({
|
||||
where: { id: trackingSessionId },
|
||||
data: { canvassSessionId: data.canvassSessionId },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { prisma } from '../../config/database';
|
||||
import { redis } from '../../config/redis';
|
||||
import { env } from '../../config/env';
|
||||
import { siteSettingsService } from '../settings/settings.service';
|
||||
import { escapeHtml } from '../../utils/escapeHtml';
|
||||
|
||||
@@ -65,8 +66,8 @@ router.get('/campaign/:slug', async (req: Request, res: Response, next: NextFunc
|
||||
if (!campaign) { res.status(404).send('Not found'); return; }
|
||||
|
||||
const settings = await siteSettingsService.getPublic();
|
||||
const appDomain = req.get('host') || 'app.cmlite.org';
|
||||
const protocol = req.protocol;
|
||||
const appDomain = env.DOMAIN ? `app.${env.DOMAIN}` : 'app.cmlite.org';
|
||||
const protocol = env.DOMAIN ? 'https' : req.protocol;
|
||||
const url = `${protocol}://${appDomain}/campaign/${slug}`;
|
||||
|
||||
const html = ogHtml({
|
||||
@@ -104,8 +105,8 @@ router.get('/page/:slug', async (req: Request, res: Response, next: NextFunction
|
||||
if (!page) { res.status(404).send('Not found'); return; }
|
||||
|
||||
const settings = await siteSettingsService.getPublic();
|
||||
const appDomain = req.get('host') || 'app.cmlite.org';
|
||||
const protocol = req.protocol;
|
||||
const appDomain = env.DOMAIN ? `app.${env.DOMAIN}` : 'app.cmlite.org';
|
||||
const protocol = env.DOMAIN ? 'https' : req.protocol;
|
||||
const url = `${protocol}://${appDomain}/p/${slug}`;
|
||||
|
||||
const html = ogHtml({
|
||||
@@ -146,8 +147,8 @@ router.get('/gallery/:id', async (req: Request, res: Response, next: NextFunctio
|
||||
if (!video) { res.status(404).send('Not found'); return; }
|
||||
|
||||
const settings = await siteSettingsService.getPublic();
|
||||
const appDomain = req.get('host') || 'app.cmlite.org';
|
||||
const protocol = req.protocol;
|
||||
const appDomain = env.DOMAIN ? `app.${env.DOMAIN}` : 'app.cmlite.org';
|
||||
const protocol = env.DOMAIN ? 'https' : req.protocol;
|
||||
const url = `${protocol}://${appDomain}/gallery/watch/${id}`;
|
||||
|
||||
const html = ogHtml({
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import RedisStore from 'rate-limit-redis';
|
||||
import { redis } from '../../config/redis';
|
||||
import { env } from '../../config/env';
|
||||
import { search } from './search.service';
|
||||
|
||||
const router = Router();
|
||||
@@ -27,7 +29,16 @@ router.get('/', searchRateLimit, async (req: Request, res: Response, next: NextF
|
||||
res.json([]);
|
||||
return;
|
||||
}
|
||||
const results = await search(q, limit);
|
||||
// Lightweight auth check — shifts only returned for authenticated users
|
||||
let isAuthenticated = false;
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
try {
|
||||
jwt.verify(authHeader.slice(7), env.JWT_ACCESS_SECRET, { algorithms: ['HS256'] });
|
||||
isAuthenticated = true;
|
||||
} catch { /* unauthenticated — public results only */ }
|
||||
}
|
||||
const results = await search(q, limit, isAuthenticated);
|
||||
res.json(results);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
|
||||
@@ -14,7 +14,7 @@ export interface SearchResult {
|
||||
const CACHE_PREFIX = 'search:';
|
||||
const CACHE_TTL = 60; // 60 seconds
|
||||
|
||||
export async function search(query: string, limit = 5): Promise<SearchResult[]> {
|
||||
export async function search(query: string, limit = 5, isAuthenticated = false): Promise<SearchResult[]> {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q || q.length < 2) return [];
|
||||
|
||||
@@ -34,7 +34,7 @@ export async function search(query: string, limit = 5): Promise<SearchResult[]>
|
||||
if (settings.enableInfluence !== false) {
|
||||
promises.push(searchCampaigns(q, limit));
|
||||
}
|
||||
if (settings.enableMap !== false) {
|
||||
if (settings.enableMap !== false && isAuthenticated) {
|
||||
promises.push(searchShifts(q, limit));
|
||||
}
|
||||
if (settings.enableLandingPages !== false) {
|
||||
|
||||
@@ -260,7 +260,9 @@ router.post('/:id/resend-ticket/:ticketId', requireEventOwnership, async (req: R
|
||||
const crypto = await import('crypto');
|
||||
const { env: envConfig } = await import('../../config/env');
|
||||
const nonce = crypto.randomBytes(16);
|
||||
const hmac = crypto.createHmac('sha256', envConfig.ENCRYPTION_KEY);
|
||||
// Use domain-separated key for ticket HMACs (matches tickets.service.ts)
|
||||
const ticketKey = crypto.createHmac('sha256', envConfig.ENCRYPTION_KEY).update('ticket-hmac-v1').digest();
|
||||
const hmac = crypto.createHmac('sha256', ticketKey);
|
||||
hmac.update(ticket.id);
|
||||
hmac.update(nonce);
|
||||
const token = Buffer.concat([
|
||||
|
||||
@@ -4,8 +4,9 @@ import { env } from '../../config/env';
|
||||
import { AppError } from '../../middleware/error-handler';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
function getEncryptionKey(): string {
|
||||
return env.ENCRYPTION_KEY;
|
||||
/** Derive a domain-separated key for ticket HMACs (not the raw ENCRYPTION_KEY). */
|
||||
function getTicketHmacKey(): Buffer {
|
||||
return crypto.createHmac('sha256', env.ENCRYPTION_KEY).update('ticket-hmac-v1').digest();
|
||||
}
|
||||
|
||||
/** Generate a human-readable ticket code like "ABCD-1234" */
|
||||
@@ -19,7 +20,7 @@ function generateTicketCode(): string {
|
||||
/** Generate HMAC token for QR code validation */
|
||||
function generateToken(ticketId: string): { token: string; tokenHash: string } {
|
||||
const nonce = crypto.randomBytes(16);
|
||||
const hmac = crypto.createHmac('sha256', getEncryptionKey());
|
||||
const hmac = crypto.createHmac('sha256', getTicketHmacKey());
|
||||
hmac.update(ticketId);
|
||||
hmac.update(nonce);
|
||||
const token = Buffer.concat([
|
||||
|
||||
@@ -2,24 +2,31 @@ import crypto from 'crypto';
|
||||
import { prisma } from '../config/database';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
/** Hash a token with SHA-256 for storage (raw token is sent to user, hash is stored in DB). */
|
||||
function hashToken(token: string): string {
|
||||
return crypto.createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
export const passwordResetTokenService = {
|
||||
async createToken(userId: string): Promise<string> {
|
||||
// Delete any existing tokens for this user
|
||||
await prisma.passwordResetToken.deleteMany({ where: { userId } });
|
||||
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
const rawToken = crypto.randomBytes(32).toString('hex');
|
||||
const tokenHash = hashToken(rawToken);
|
||||
const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
|
||||
|
||||
await prisma.passwordResetToken.create({
|
||||
data: { userId, token, expiresAt },
|
||||
data: { userId, token: tokenHash, expiresAt },
|
||||
});
|
||||
|
||||
logger.info(`Password reset token created for user ${userId}`);
|
||||
return token;
|
||||
return rawToken; // Send raw token in email; DB stores only the hash
|
||||
},
|
||||
|
||||
async validateToken(token: string): Promise<{ valid: boolean; userId?: string; error?: string }> {
|
||||
const record = await prisma.passwordResetToken.findUnique({ where: { token } });
|
||||
const tokenHash = hashToken(token);
|
||||
const record = await prisma.passwordResetToken.findUnique({ where: { token: tokenHash } });
|
||||
|
||||
// Use a generic error message for all failure cases to prevent token state enumeration
|
||||
const genericError = 'Invalid or expired reset token';
|
||||
@@ -41,8 +48,9 @@ export const passwordResetTokenService = {
|
||||
},
|
||||
|
||||
async markTokenUsed(token: string): Promise<void> {
|
||||
const tokenHash = hashToken(token);
|
||||
await prisma.passwordResetToken.update({
|
||||
where: { token },
|
||||
where: { token: tokenHash },
|
||||
data: { usedAt: new Date() },
|
||||
});
|
||||
},
|
||||
|
||||
@@ -2,24 +2,31 @@ import crypto from 'crypto';
|
||||
import { prisma } from '../config/database';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
/** Hash a token with SHA-256 for storage (raw token is sent to user, hash is stored in DB). */
|
||||
function hashToken(token: string): string {
|
||||
return crypto.createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
export const verificationTokenService = {
|
||||
async createToken(userId: string): Promise<string> {
|
||||
// Delete any existing tokens for this user
|
||||
await prisma.emailVerificationToken.deleteMany({ where: { userId } });
|
||||
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
const rawToken = crypto.randomBytes(32).toString('hex');
|
||||
const tokenHash = hashToken(rawToken);
|
||||
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
|
||||
|
||||
await prisma.emailVerificationToken.create({
|
||||
data: { userId, token, expiresAt },
|
||||
data: { userId, token: tokenHash, expiresAt },
|
||||
});
|
||||
|
||||
logger.info(`Verification token created for user ${userId}`);
|
||||
return token;
|
||||
return rawToken; // Send raw token in email; DB stores only the hash
|
||||
},
|
||||
|
||||
async verifyToken(token: string): Promise<{ valid: boolean; userId?: string; error?: string }> {
|
||||
const record = await prisma.emailVerificationToken.findUnique({ where: { token } });
|
||||
const tokenHash = hashToken(token);
|
||||
const record = await prisma.emailVerificationToken.findUnique({ where: { token: tokenHash } });
|
||||
|
||||
if (!record) {
|
||||
return { valid: false, error: 'Invalid or expired verification token' };
|
||||
|
||||
@@ -34,9 +34,57 @@ interface FetchJobResult {
|
||||
// Shell metacharacters that could enable command injection
|
||||
const SHELL_METACHAR_REGEX = /[`$;|&<>(){}[\]\\!#]/;
|
||||
|
||||
// Hostnames that resolve to internal/cloud-metadata addresses
|
||||
const BLOCKED_HOSTNAMES = new Set([
|
||||
'localhost',
|
||||
'metadata.google.internal',
|
||||
'metadata.gcp.internal',
|
||||
'169.254.169.254', // AWS/GCP/Azure metadata
|
||||
'169.254.170.2', // AWS ECS task metadata
|
||||
'kubernetes.default',
|
||||
'kubernetes.default.svc',
|
||||
]);
|
||||
|
||||
// Docker internal container names used in this project
|
||||
const DOCKER_INTERNAL_SUFFIXES = [
|
||||
'-changemaker', // Our container naming convention
|
||||
'-rocketchat',
|
||||
'-listmonk',
|
||||
];
|
||||
|
||||
/**
|
||||
* Check if an IP address is in a private/reserved range (SSRF protection).
|
||||
*/
|
||||
function isPrivateIP(hostname: string): boolean {
|
||||
// IPv6 loopback
|
||||
if (hostname === '::1' || hostname === '[::1]') return true;
|
||||
|
||||
// Strip IPv6 brackets
|
||||
const clean = hostname.replace(/^\[|\]$/g, '');
|
||||
|
||||
// IPv4 patterns
|
||||
const parts = clean.split('.').map(Number);
|
||||
if (parts.length === 4 && parts.every(p => !isNaN(p) && p >= 0 && p <= 255)) {
|
||||
const [a, b] = parts;
|
||||
if (a === 127) return true; // 127.0.0.0/8 loopback
|
||||
if (a === 10) return true; // 10.0.0.0/8 private
|
||||
if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 private
|
||||
if (a === 192 && b === 168) return true; // 192.168.0.0/16 private
|
||||
if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local / cloud metadata
|
||||
if (a === 0) return true; // 0.0.0.0/8
|
||||
}
|
||||
|
||||
// IPv6 private ranges (simplified check for common prefixes)
|
||||
if (clean.startsWith('fc') || clean.startsWith('fd')) return true; // ULA
|
||||
if (clean.startsWith('fe80')) return true; // Link-local
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize and validate a URL for safe shell usage.
|
||||
* Returns the URL if valid, or null if invalid or contains shell metacharacters.
|
||||
* Returns the URL if valid, or null if invalid, contains shell metacharacters,
|
||||
* or targets private/internal network addresses (SSRF protection).
|
||||
*/
|
||||
function sanitizeUrl(url: string): string | null {
|
||||
if (!url || typeof url !== 'string') return null;
|
||||
@@ -45,8 +93,9 @@ function sanitizeUrl(url: string): string | null {
|
||||
if (!trimmed) return null;
|
||||
|
||||
// Validate URL structure
|
||||
let parsed: URL;
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
parsed = new URL(trimmed);
|
||||
if (!['http:', 'https:'].includes(parsed.protocol)) return null;
|
||||
} catch {
|
||||
return null;
|
||||
@@ -55,6 +104,16 @@ function sanitizeUrl(url: string): string | null {
|
||||
// Reject shell metacharacters
|
||||
if (SHELL_METACHAR_REGEX.test(trimmed)) return null;
|
||||
|
||||
// SSRF protection: block private/internal IPs and hostnames
|
||||
const hostname = parsed.hostname.toLowerCase();
|
||||
|
||||
if (isPrivateIP(hostname)) return null;
|
||||
if (BLOCKED_HOSTNAMES.has(hostname)) return null;
|
||||
if (DOCKER_INTERNAL_SUFFIXES.some(suffix => hostname.endsWith(suffix))) return null;
|
||||
|
||||
// Block numeric IPv6 addresses entirely (too many bypass variants)
|
||||
if (hostname.includes(':')) return null;
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,36 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'crypto';
|
||||
import { createCipheriv, createDecipheriv, randomBytes, createHash, hkdfSync } from 'crypto';
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
const IV_LENGTH = 12;
|
||||
const AUTH_TAG_LENGTH = 16;
|
||||
const PREFIX = 'enc:';
|
||||
|
||||
/** Derive a 32-byte key from an arbitrary-length secret */
|
||||
function deriveKey(secret: string): Buffer {
|
||||
/** Legacy prefix (SHA-256 derived key) — read-only, never written for new data */
|
||||
const LEGACY_PREFIX = 'enc:';
|
||||
/** V2 prefix (HKDF derived key) — used for all new encryptions */
|
||||
const V2_PREFIX = 'enc2:';
|
||||
|
||||
const HKDF_SALT = 'changemaker-lite-encryption-v2';
|
||||
const HKDF_INFO = 'aes-256-gcm-data-encryption';
|
||||
|
||||
/** Derive a 32-byte key using SHA-256 (legacy, for decrypting old data) */
|
||||
function deriveLegacyKey(secret: string): Buffer {
|
||||
return createHash('sha256').update(secret).digest();
|
||||
}
|
||||
|
||||
/** Derive a 32-byte key using HKDF-SHA256 (RFC 5869) */
|
||||
function deriveKey(secret: string): Buffer {
|
||||
return Buffer.from(
|
||||
hkdfSync('sha256', secret, HKDF_SALT, HKDF_INFO, 32)
|
||||
);
|
||||
}
|
||||
|
||||
let _key: Buffer | null = null;
|
||||
let _legacyKey: Buffer | null = null;
|
||||
|
||||
/** Initialize (or re-initialize) the encryption key. Call once at startup. */
|
||||
export function initEncryption(secret: string): void {
|
||||
_key = deriveKey(secret);
|
||||
_legacyKey = deriveLegacyKey(secret);
|
||||
}
|
||||
|
||||
function getKey(): Buffer {
|
||||
@@ -24,9 +40,16 @@ function getKey(): Buffer {
|
||||
return _key;
|
||||
}
|
||||
|
||||
function getLegacyKey(): Buffer {
|
||||
if (!_legacyKey) {
|
||||
throw new Error('Encryption not initialized — call initEncryption() first');
|
||||
}
|
||||
return _legacyKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a plaintext string.
|
||||
* Returns format: `enc:<iv>:<authTag>:<ciphertext>` (all base64).
|
||||
* Returns format: `enc2:<iv>:<authTag>:<ciphertext>` (all base64).
|
||||
*/
|
||||
export function encrypt(plaintext: string): string {
|
||||
if (!plaintext) return plaintext;
|
||||
@@ -37,18 +60,31 @@ export function encrypt(plaintext: string): string {
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
return `${PREFIX}${iv.toString('base64')}:${authTag.toString('base64')}:${encrypted.toString('base64')}`;
|
||||
return `${V2_PREFIX}${iv.toString('base64')}:${authTag.toString('base64')}:${encrypted.toString('base64')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a value produced by encrypt().
|
||||
* If the value doesn't have the `enc:` prefix, returns it as-is (backward compat for plaintext).
|
||||
* Supports both v2 (HKDF) and legacy (SHA-256) key derivation.
|
||||
* If the value doesn't have a recognized prefix, returns it as-is (backward compat for plaintext).
|
||||
*/
|
||||
export function decrypt(value: string): string {
|
||||
if (!value || !value.startsWith(PREFIX)) return value;
|
||||
if (!value) return value;
|
||||
|
||||
const key = getKey();
|
||||
const parts = value.slice(PREFIX.length).split(':');
|
||||
let key: Buffer;
|
||||
let payload: string;
|
||||
|
||||
if (value.startsWith(V2_PREFIX)) {
|
||||
key = getKey();
|
||||
payload = value.slice(V2_PREFIX.length);
|
||||
} else if (value.startsWith(LEGACY_PREFIX)) {
|
||||
key = getLegacyKey();
|
||||
payload = value.slice(LEGACY_PREFIX.length);
|
||||
} else {
|
||||
return value; // Plaintext fallback
|
||||
}
|
||||
|
||||
const parts = payload.split(':');
|
||||
if (parts.length !== 3) return value;
|
||||
|
||||
const iv = Buffer.from(parts[0], 'base64');
|
||||
@@ -64,5 +100,5 @@ export function decrypt(value: string): string {
|
||||
|
||||
/** Check whether a value is already encrypted */
|
||||
export function isEncrypted(value: string): boolean {
|
||||
return !!value && value.startsWith(PREFIX);
|
||||
return !!value && (value.startsWith(V2_PREFIX) || value.startsWith(LEGACY_PREFIX));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user