Security audit: fix 25 findings across API, nginx, and Docker

Addresses data exposure, access control, input validation, infrastructure
hardening, and supply chain security issues identified during audit.

Key changes:
- Strip internal fields from public campaign/profile/comment endpoints
- Restrict docs routes to CONTENT_ROLES, provisioning to SUPER_ADMIN
- Add SSE connection limits, social middleware fail-closed behavior
- Bind all non-nginx ports to 127.0.0.1, pin container image versions
- Add CSP header, conditional HSTS, token redaction in nginx logs
- Validate nav URLs, calendar schemas, video tracking batch events
- Reject default admin password placeholder, add SSRF protocol checks
- Exclude .env from Code Server, enforce RC admin password in compose
- Add Zod validation for achievement grant/revoke, webhook secret header
- Fix path traversal prefix attack, add calendar token expiry

Bunker Admin
This commit is contained in:
bunker-admin 2026-03-09 14:13:37 -06:00
parent bdb672c7ad
commit c192c04c79
20 changed files with 241 additions and 88 deletions

View File

@ -33,7 +33,11 @@ const envSchema = z.object({
// Initial Super Admin (auto-created during database seeding) // Initial Super Admin (auto-created during database seeding)
INITIAL_ADMIN_EMAIL: z.string().email().default('admin@cmlite.org'), INITIAL_ADMIN_EMAIL: z.string().email().default('admin@cmlite.org'),
INITIAL_ADMIN_PASSWORD: z.string().min(12).default('REQUIRED_STRONG_PASSWORD_CHANGE_THIS'), INITIAL_ADMIN_PASSWORD: z.string().min(12).default('REQUIRED_STRONG_PASSWORD_CHANGE_THIS')
.refine(
(val) => val !== 'REQUIRED_STRONG_PASSWORD_CHANGE_THIS',
{ message: 'INITIAL_ADMIN_PASSWORD must be changed from the default placeholder value' },
),
// SMTP // SMTP
SMTP_HOST: z.string().default('mailhog-changemaker'), SMTP_HOST: z.string().default('mailhog-changemaker'),

View File

@ -28,13 +28,13 @@ const recurrenceRuleSchema = z.object({
export const createItemSchema = z.object({ export const createItemSchema = z.object({
layerId: z.string().min(1), layerId: z.string().min(1),
title: z.string().min(1).max(200), title: z.string().min(1).max(200),
description: z.string().optional(), description: z.string().max(5000).optional(),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be YYYY-MM-DD'), date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be YYYY-MM-DD'),
startTime: z.string().regex(/^\d{2}:\d{2}$/, 'Start time must be HH:MM'), startTime: z.string().regex(/^\d{2}:\d{2}$/, 'Start time must be HH:MM'),
endTime: z.string().regex(/^\d{2}:\d{2}$/, 'End time must be HH:MM'), endTime: z.string().regex(/^\d{2}:\d{2}$/, 'End time must be HH:MM'),
isAllDay: z.boolean().optional(), isAllDay: z.boolean().optional(),
itemType: z.enum(['EVENT', 'TIME_BLOCK', 'REMINDER']), itemType: z.enum(['EVENT', 'TIME_BLOCK', 'REMINDER']),
location: z.string().optional(), location: z.string().max(500).optional(),
color: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional(), color: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional(),
visibility: z.enum(['PRIVATE', 'FRIENDS', 'PUBLIC']).optional(), visibility: z.enum(['PRIVATE', 'FRIENDS', 'PUBLIC']).optional(),
busyStatus: z.enum(['BUSY', 'TENTATIVE', 'FREE']).optional(), busyStatus: z.enum(['BUSY', 'TENTATIVE', 'FREE']).optional(),
@ -45,13 +45,13 @@ export const createItemSchema = z.object({
export const updateItemSchema = z.object({ export const updateItemSchema = z.object({
title: z.string().min(1).max(200).optional(), title: z.string().min(1).max(200).optional(),
description: z.string().nullable().optional(), description: z.string().max(5000).nullable().optional(),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be YYYY-MM-DD').optional(), date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be YYYY-MM-DD').optional(),
startTime: z.string().regex(/^\d{2}:\d{2}$/, 'Start time must be HH:MM').optional(), startTime: z.string().regex(/^\d{2}:\d{2}$/, 'Start time must be HH:MM').optional(),
endTime: z.string().regex(/^\d{2}:\d{2}$/, 'End time must be HH:MM').optional(), endTime: z.string().regex(/^\d{2}:\d{2}$/, 'End time must be HH:MM').optional(),
isAllDay: z.boolean().optional(), isAllDay: z.boolean().optional(),
itemType: z.enum(['EVENT', 'TIME_BLOCK', 'REMINDER']).optional(), itemType: z.enum(['EVENT', 'TIME_BLOCK', 'REMINDER']).optional(),
location: z.string().nullable().optional(), location: z.string().max(500).nullable().optional(),
color: z.string().regex(/^#[0-9a-fA-F]{6}$/).nullable().optional(), color: z.string().regex(/^#[0-9a-fA-F]{6}$/).nullable().optional(),
visibility: z.enum(['PRIVATE', 'FRIENDS', 'PUBLIC']).nullable().optional(), visibility: z.enum(['PRIVATE', 'FRIENDS', 'PUBLIC']).nullable().optional(),
busyStatus: z.enum(['BUSY', 'TENTATIVE', 'FREE']).optional(), busyStatus: z.enum(['BUSY', 'TENTATIVE', 'FREE']).optional(),

View File

@ -484,6 +484,14 @@ export const feedService = {
if (!exportToken) return null; if (!exportToken) return null;
// Enforce 1-year expiry on export tokens (soft check — no DB column needed)
const TOKEN_MAX_AGE_MS = 365 * 24 * 60 * 60 * 1000; // 1 year
if (Date.now() - exportToken.createdAt.getTime() > TOKEN_MAX_AGE_MS) {
// Token expired — clean it up and return null
await prisma.calendarExportToken.delete({ where: { id: exportToken.id } }).catch(() => {});
return null;
}
const now = new Date(); const now = new Date();
const pastLimit = new Date(now); const pastLimit = new Date(now);
pastLimit.setMonth(pastLimit.getMonth() - 1); pastLimit.setMonth(pastLimit.getMonth() - 1);

View File

@ -32,7 +32,8 @@ function hashFilePath(path: string): string {
function safeResolve(relativePath: string): string { function safeResolve(relativePath: string): string {
const normalized = normalize(relativePath).replace(/^(\.\.(\/|\\|$))+/, ''); const normalized = normalize(relativePath).replace(/^(\.\.(\/|\\|$))+/, '');
const resolved = pathResolve(DOCS_ROOT, normalized); const resolved = pathResolve(DOCS_ROOT, normalized);
if (!resolved.startsWith(DOCS_ROOT)) { // Use DOCS_ROOT + sep to prevent prefix attacks (e.g., /mkdocs/docs-evil matching /mkdocs/docs)
if (resolved !== DOCS_ROOT && !resolved.startsWith(DOCS_ROOT + '/')) {
throw new PathTraversalError(); throw new PathTraversalError();
} }
return resolved; return resolved;

View File

@ -21,9 +21,10 @@ router.use(requireNonTemp);
// Removed duplicated isServiceOnline - now using shared utility from utils/health-check.ts // Removed duplicated isServiceOnline - now using shared utility from utils/health-check.ts
// GET /api/docs/status — check MkDocs and Code Server availability // GET /api/docs/status — check MkDocs and Code Server availability (content editors only)
router.get( router.get(
'/status', '/status',
requireRole(...CONTENT_ROLES),
async (_req: Request, res: Response, next: NextFunction) => { async (_req: Request, res: Response, next: NextFunction) => {
try { try {
const [mkdocsOnline, codeServerOnline, siteServerOnline] = await Promise.all([ const [mkdocsOnline, codeServerOnline, siteServerOnline] = await Promise.all([
@ -44,9 +45,10 @@ router.get(
}, },
); );
// GET /api/docs/config — return public-facing port numbers for iframe URLs // GET /api/docs/config — return public-facing port numbers for iframe URLs (content editors only)
router.get( router.get(
'/config', '/config',
requireRole(...CONTENT_ROLES),
async (_req: Request, res: Response, _next: NextFunction) => { async (_req: Request, res: Response, _next: NextFunction) => {
res.json({ res.json({
codeServerPort: env.CODE_SERVER_PORT, codeServerPort: env.CODE_SERVER_PORT,
@ -58,9 +60,10 @@ router.get(
// --- MkDocs Config Endpoints --- // --- MkDocs Config Endpoints ---
// GET /api/docs/mkdocs-config — read raw mkdocs.yml content // GET /api/docs/mkdocs-config — read raw mkdocs.yml content (content editors only)
router.get( router.get(
'/mkdocs-config', '/mkdocs-config',
requireRole(...CONTENT_ROLES),
async (_req: Request, res: Response, next: NextFunction) => { async (_req: Request, res: Response, next: NextFunction) => {
try { try {
const content = await mkdocsConfigService.readConfig(); const content = await mkdocsConfigService.readConfig();
@ -113,9 +116,10 @@ router.post(
// --- Header Builder --- // --- Header Builder ---
// GET /api/docs/header-config — read header nav bar config // GET /api/docs/header-config — read header nav bar config (content editors only)
router.get( router.get(
'/header-config', '/header-config',
requireRole(...CONTENT_ROLES),
async (_req: Request, res: Response, next: NextFunction) => { async (_req: Request, res: Response, next: NextFunction) => {
try { try {
const config = await headerBuilderService.readConfig(); const config = await headerBuilderService.readConfig();
@ -205,9 +209,10 @@ router.post(
// --- File Management Endpoints --- // --- File Management Endpoints ---
// GET /api/docs/files — list file tree // GET /api/docs/files — list file tree (content editors only)
router.get( router.get(
'/files', '/files',
requireRole(...CONTENT_ROLES),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {
cm_docs_operations.inc({ operation: 'list' }); cm_docs_operations.inc({ operation: 'list' });
@ -223,9 +228,10 @@ router.get(
}, },
); );
// GET /api/docs/files/search — search files by name/path (for command palette) // GET /api/docs/files/search — search files by name/path (content editors only)
router.get( router.get(
'/files/search', '/files/search',
requireRole(...CONTENT_ROLES),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {
const search = String(req.query['search'] ?? req.query['q'] ?? '').trim(); const search = String(req.query['search'] ?? req.query['q'] ?? '').trim();
@ -265,9 +271,10 @@ router.post(
}, },
); );
// GET /api/docs/files/* — read file content // GET /api/docs/files/* — read file content (content editors only)
router.get( router.get(
'/files/*', '/files/*',
requireRole(...CONTENT_ROLES),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {
cm_docs_operations.inc({ operation: 'read' }); cm_docs_operations.inc({ operation: 'read' });

View File

@ -3,7 +3,11 @@ import { z } from 'zod';
export const headerNavItemSchema = z.object({ export const headerNavItemSchema = z.object({
id: z.string().min(1), id: z.string().min(1),
label: z.string().min(1).max(50), label: z.string().min(1).max(50),
path: z.string().min(1).max(500), path: z.string().min(1).max(500)
.refine(
(v) => !/^(javascript|data|vbscript):/i.test(v),
'Dangerous URL scheme not allowed',
),
icon: z.string().max(50).optional(), icon: z.string().max(50).optional(),
enabled: z.boolean(), enabled: z.boolean(),
order: z.number().int().min(0), order: z.number().int().min(0),

View File

@ -56,6 +56,41 @@ const campaignSelect = {
}, },
} satisfies Prisma.CampaignSelect; } satisfies Prisma.CampaignSelect;
/** Public-facing select — strips admin-only fields (emails, internal IDs, moderation notes) */
const publicCampaignSelect = {
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,
createdByUserName: true,
isUserGenerated: true,
moderationStatus: true,
createdAt: true,
updatedAt: true,
_count: {
select: {
emails: true,
responses: true,
},
},
} satisfies Prisma.CampaignSelect;
function generateSlug(title: string): string { function generateSlug(title: string): string {
return title return title
.toLowerCase() .toLowerCase()
@ -224,7 +259,7 @@ export const campaignsService = {
async findActiveCampaigns() { async findActiveCampaigns() {
return prisma.campaign.findMany({ return prisma.campaign.findMany({
where: { status: 'ACTIVE' }, where: { status: 'ACTIVE' },
select: campaignSelect, select: publicCampaignSelect,
orderBy: [ orderBy: [
{ highlightCampaign: 'desc' }, { highlightCampaign: 'desc' },
{ createdAt: 'desc' }, { createdAt: 'desc' },
@ -235,7 +270,7 @@ export const campaignsService = {
async findBySlugPublic(slug: string) { async findBySlugPublic(slug: string) {
const campaign = await prisma.campaign.findUnique({ const campaign = await prisma.campaign.findUnique({
where: { slug }, where: { slug },
select: campaignSelect, select: publicCampaignSelect,
}); });
if (!campaign) { if (!campaign) {

View File

@ -15,7 +15,8 @@ router.post(
'/webhook', '/webhook',
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {
const secret = req.query.secret as string; // Accept secret from header (preferred) or query param (legacy fallback)
const secret = (req.headers['x-webhook-secret'] as string) || (req.query.secret as string);
if (!env.LISTMONK_WEBHOOK_SECRET || secret !== env.LISTMONK_WEBHOOK_SECRET) { if (!env.LISTMONK_WEBHOOK_SECRET || secret !== env.LISTMONK_WEBHOOK_SECRET) {
res.status(403).json({ error: 'Invalid webhook secret' }); res.status(403).json({ error: 'Invalid webhook secret' });
return; return;

View File

@ -75,7 +75,7 @@ export async function commentsRoutes(fastify: FastifyInstance) {
user: comment.user user: comment.user
? { ? {
id: comment.user.id, id: comment.user.id,
name: comment.user.name || comment.user.email, name: comment.user.name || 'Anonymous',
} }
: null, : null,
})); }));
@ -229,7 +229,7 @@ export async function commentsRoutes(fastify: FastifyInstance) {
user: newComment.user user: newComment.user
? { ? {
id: newComment.user.id, id: newComment.user.id,
name: newComment.user.name || newComment.user.email, name: newComment.user.name || 'Anonymous',
} }
: null, : null,
}; };
@ -253,7 +253,7 @@ export async function commentsRoutes(fastify: FastifyInstance) {
select: { filename: true }, select: { filename: true },
}); });
const commenterName = newComment.user?.name || newComment.user?.email || 'Someone'; const commenterName = newComment.user?.name || 'Someone';
const contentPreview = content.trim().length > 80 const contentPreview = content.trim().length > 80
? content.trim().substring(0, 80) + '...' ? content.trim().substring(0, 80) + '...'
: content.trim(); : content.trim();

View File

@ -186,6 +186,23 @@ export async function videoTrackingRoutes(fastify: FastifyInstance) {
} }
try { try {
// Validate each event's data against per-type schemas before processing
for (const event of events) {
switch (event.type) {
case 'view':
recordViewSchema.parse(event.data);
break;
case 'event':
recordEventSchema.parse(event.data);
break;
case 'heartbeat':
updateWatchTimeSchema.parse(event.data);
break;
default:
return reply.code(400).send({ message: `Unknown event type: ${event.type}` });
}
}
const results = await Promise.allSettled( const results = await Promise.allSettled(
events.map(async (event) => { events.map(async (event) => {
switch (event.type) { switch (event.type) {

View File

@ -130,14 +130,20 @@ router.post('/test-connection', async (req, res) => {
return; return;
} }
// Validate URL format // Validate URL format and protocol
let parsedUrl: URL;
try { try {
new URL(url); parsedUrl = new URL(url);
} catch { } catch {
res.status(400).json({ error: { message: 'Invalid URL format', code: 'VALIDATION_ERROR' } }); res.status(400).json({ error: { message: 'Invalid URL format', code: 'VALIDATION_ERROR' } });
return; return;
} }
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
res.status(400).json({ error: { message: 'Only http and https URLs are allowed', code: 'VALIDATION_ERROR' } });
return;
}
const result = await TermuxClient.testConnection(url, apiKey); const result = await TermuxClient.testConnection(url, apiKey);
res.json(result); res.json(result);
} catch (err) { } catch (err) {
@ -173,10 +179,14 @@ router.post('/save-config', async (req, res) => {
if (typeof smsTailscaleDeviceId === 'string') update.smsTailscaleDeviceId = smsTailscaleDeviceId; if (typeof smsTailscaleDeviceId === 'string') update.smsTailscaleDeviceId = smsTailscaleDeviceId;
if (typeof smsTailscaleDeviceName === 'string') update.smsTailscaleDeviceName = smsTailscaleDeviceName; if (typeof smsTailscaleDeviceName === 'string') update.smsTailscaleDeviceName = smsTailscaleDeviceName;
// Validate URL format if provided // Validate URL format and protocol if provided
if (typeof smsTermuxApiUrl === 'string' && smsTermuxApiUrl) { if (typeof smsTermuxApiUrl === 'string' && smsTermuxApiUrl) {
try { try {
new URL(smsTermuxApiUrl); const parsed = new URL(smsTermuxApiUrl);
if (!['http:', 'https:'].includes(parsed.protocol)) {
res.status(400).json({ error: { message: 'Only http and https URLs are allowed', code: 'VALIDATION_ERROR' } });
return;
}
} catch { } catch {
res.status(400).json({ error: { message: 'Invalid Termux API URL format', code: 'VALIDATION_ERROR' } }); res.status(400).json({ error: { message: 'Invalid Termux API URL format', code: 'VALIDATION_ERROR' } });
return; return;

View File

@ -22,11 +22,20 @@ router.get('/', async (req: Request, res: Response) => {
} }
}); });
/** GET /api/social/groups/:id — group detail with members */ /** GET /api/social/groups/:id — group detail with members (membership required) */
router.get('/:id', async (req: Request, res: Response) => { router.get('/:id', async (req: Request, res: Response) => {
try { try {
const groupId = req.params.id as string; const groupId = req.params.id as string;
const userId = req.user!.id;
const result = await groupService.getGroupDetail(groupId); const result = await groupService.getGroupDetail(groupId);
// Verify the requesting user is a member of this group
const isMember = result.members.some((m: any) => m.userId === userId);
if (!isMember) {
res.status(404).json({ error: { message: 'Group not found', code: 'NOT_FOUND' } });
return;
}
res.json(result); res.json(result);
} catch (err: any) { } catch (err: any) {
res.status(err.statusCode || 500).json({ error: { message: err.message } }); res.status(err.statusCode || 500).json({ error: { message: err.message } });

View File

@ -7,7 +7,8 @@ import { blockService } from './block.service';
import { messagingService } from './messaging.service'; import { messagingService } from './messaging.service';
import { checkSocialEnabled } from './social.middleware'; import { checkSocialEnabled } from './social.middleware';
const PROFILE_SELECT = { /** Own profile — includes email */
const OWN_PROFILE_SELECT = {
id: true, id: true,
name: true, name: true,
email: true, email: true,
@ -15,6 +16,14 @@ const PROFILE_SELECT = {
createdAt: true, createdAt: true,
} as const; } as const;
/** Other users' profiles — email stripped to prevent harvesting */
const PROFILE_SELECT = {
id: true,
name: true,
role: true,
createdAt: true,
} as const;
const router = Router(); const router = Router();
router.use(authenticate); router.use(authenticate);
router.use(checkSocialEnabled); router.use(checkSocialEnabled);
@ -25,7 +34,7 @@ router.get('/me', async (req: Request, res: Response) => {
const userId = req.user!.id; const userId = req.user!.id;
const [user, friendCount, pendingCount, privacy] = await Promise.all([ const [user, friendCount, pendingCount, privacy] = await Promise.all([
prisma.user.findUnique({ where: { id: userId }, select: PROFILE_SELECT }), prisma.user.findUnique({ where: { id: userId }, select: OWN_PROFILE_SELECT }),
prisma.friendship.count({ prisma.friendship.count({
where: { where: {
OR: [ OR: [

View File

@ -1,9 +1,15 @@
import { Router } from 'express'; import { Router } from 'express';
import { z } from 'zod';
import { socialAdminService } from './social-admin.service'; import { socialAdminService } from './social-admin.service';
import { requireRole } from '../../middleware/rbac.middleware';
import { SOCIAL_ROLES } from '../../utils/roles';
import { logger } from '../../utils/logger'; import { logger } from '../../utils/logger';
const router = Router(); const router = Router();
// Self-contained auth guard (defense-in-depth — parent also applies requireRole)
router.use(requireRole(...SOCIAL_ROLES));
/** GET /api/social/admin/stats — Dashboard overview */ /** GET /api/social/admin/stats — Dashboard overview */
router.get('/stats', async (_req, res) => { router.get('/stats', async (_req, res) => {
try { try {
@ -68,14 +74,19 @@ router.post('/blocks/:id/remove', async (req, res) => {
} }
}); });
const achievementSchema = z.object({
userId: z.string().min(1).max(100),
achievementId: z.string().min(1).max(100),
});
/** POST /api/social/admin/achievements/grant — Grant achievement */ /** POST /api/social/admin/achievements/grant — Grant achievement */
router.post('/achievements/grant', async (req, res) => { router.post('/achievements/grant', async (req, res) => {
try { try {
const { userId, achievementId } = req.body; const parsed = achievementSchema.safeParse(req.body);
if (!userId || !achievementId) { if (!parsed.success) {
return res.status(400).json({ error: 'userId and achievementId are required' }); return res.status(400).json({ error: 'Valid userId and achievementId are required' });
} }
const result = await socialAdminService.grantAchievement(userId, achievementId); const result = await socialAdminService.grantAchievement(parsed.data.userId, parsed.data.achievementId);
res.json(result); res.json(result);
} catch (err: unknown) { } catch (err: unknown) {
const statusCode = (err as { statusCode?: number }).statusCode ?? 500; const statusCode = (err as { statusCode?: number }).statusCode ?? 500;
@ -87,11 +98,11 @@ router.post('/achievements/grant', async (req, res) => {
/** POST /api/social/admin/achievements/revoke — Revoke achievement */ /** POST /api/social/admin/achievements/revoke — Revoke achievement */
router.post('/achievements/revoke', async (req, res) => { router.post('/achievements/revoke', async (req, res) => {
try { try {
const { userId, achievementId } = req.body; const parsed = achievementSchema.safeParse(req.body);
if (!userId || !achievementId) { if (!parsed.success) {
return res.status(400).json({ error: 'userId and achievementId are required' }); return res.status(400).json({ error: 'Valid userId and achievementId are required' });
} }
await socialAdminService.revokeAchievement(userId, achievementId); await socialAdminService.revokeAchievement(parsed.data.userId, parsed.data.achievementId);
res.json({ success: true }); res.json({ success: true });
} catch (err: unknown) { } catch (err: unknown) {
const statusCode = (err as { statusCode?: number }).statusCode ?? 500; const statusCode = (err as { statusCode?: number }).statusCode ?? 500;

View File

@ -11,6 +11,7 @@ export async function checkSocialEnabled(req: Request, res: Response, next: Next
} }
next(); next();
} catch { } catch {
next(); // Fail closed — if we can't check the feature flag, deny access
res.status(503).json({ error: { message: 'Service temporarily unavailable', code: 'SERVICE_UNAVAILABLE' } });
} }
} }

View File

@ -1,8 +1,12 @@
import { Router } from 'express'; import { Router } from 'express';
import { checkSocialEnabled } from './social.middleware'; import { checkSocialEnabled } from './social.middleware';
import { requireRole } from '../../middleware/rbac.middleware';
import { SOCIAL_ROLES } from '../../utils/roles';
import { sseService } from './sse.service'; import { sseService } from './sse.service';
import { presenceService } from './presence.service'; import { presenceService } from './presence.service';
const MAX_SSE_CONNECTIONS_PER_USER = 5;
const router = Router(); const router = Router();
router.use(checkSocialEnabled); router.use(checkSocialEnabled);
@ -11,6 +15,13 @@ router.use(checkSocialEnabled);
router.get('/', (req, res) => { router.get('/', (req, res) => {
const userId = req.user!.id; const userId = req.user!.id;
// Enforce per-user connection limit to prevent resource exhaustion
const existingCount = sseService.getConnectionCountForUser?.(userId) ?? 0;
if (existingCount >= MAX_SSE_CONNECTIONS_PER_USER) {
res.status(429).json({ error: { message: 'Too many SSE connections', code: 'TOO_MANY_CONNECTIONS' } });
return;
}
// Set SSE headers // Set SSE headers
res.writeHead(200, { res.writeHead(200, {
'Content-Type': 'text/event-stream', 'Content-Type': 'text/event-stream',
@ -48,8 +59,8 @@ router.get('/online-friends', async (req, res, next) => {
} }
}); });
/** GET /api/social/sse/status — SSE service status */ /** GET /api/social/sse/status — SSE service status (admin only) */
router.get('/status', (_req, res) => { router.get('/status', requireRole(...SOCIAL_ROLES), (_req, res) => {
res.json({ res.json({
connections: sseService.getConnectionCount(), connections: sseService.getConnectionCount(),
connectedUsers: sseService.getConnectedUserIds().length, connectedUsers: sseService.getConnectedUserIds().length,

View File

@ -117,6 +117,11 @@ class SSEService {
return count; return count;
} }
/** Get connection count for a specific user */
getConnectionCountForUser(userId: string): number {
return this.clients.get(userId)?.length ?? 0;
}
/** Close all connections (graceful shutdown) */ /** Close all connections (graceful shutdown) */
closeAll() { closeAll() {
this.stopHeartbeat(); this.stopHeartbeat();

View File

@ -1,13 +1,12 @@
import { Router, Request, Response, NextFunction } from 'express'; import { Router, Request, Response, NextFunction } from 'express';
import { authenticate } from '../../middleware/auth.middleware'; import { authenticate } from '../../middleware/auth.middleware';
import { requireRole } from '../../middleware/rbac.middleware'; import { requireRole } from '../../middleware/rbac.middleware';
import { ADMIN_ROLES } from '../../utils/roles';
import { userProvisioningService } from '../../services/user-provisioning/provisioning.service'; import { userProvisioningService } from '../../services/user-provisioning/provisioning.service';
const router = Router(); const router = Router();
router.use(authenticate); router.use(authenticate);
router.use(requireRole(...ADMIN_ROLES)); router.use(requireRole('SUPER_ADMIN'));
// POST /api/users/provisioning/sync — bulk sync all users (static route BEFORE :id params) // POST /api/users/provisioning/sync — bulk sync all users (static route BEFORE :id params)
router.post( router.post(

View File

@ -15,8 +15,8 @@ services:
container_name: changemaker-v2-api container_name: changemaker-v2-api
restart: unless-stopped restart: unless-stopped
ports: ports:
- "${API_PORT:-4000}:4000" - "127.0.0.1:${API_PORT:-4000}:4000"
- "${LISTMONK_PROXY_PORT:-9002}:9002" - "127.0.0.1:${LISTMONK_PROXY_PORT:-9002}:9002"
healthcheck: healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:4000/api/health"] test: ["CMD", "wget", "-q", "--spider", "http://localhost:4000/api/health"]
interval: 15s interval: 15s
@ -69,7 +69,7 @@ services:
- VAULTWARDEN_EMBED_PORT=${VAULTWARDEN_EMBED_PORT:-8890} - VAULTWARDEN_EMBED_PORT=${VAULTWARDEN_EMBED_PORT:-8890}
- ROCKETCHAT_URL=${ROCKETCHAT_URL:-http://rocketchat-changemaker:3000} - ROCKETCHAT_URL=${ROCKETCHAT_URL:-http://rocketchat-changemaker:3000}
- ROCKETCHAT_ADMIN_USER=${ROCKETCHAT_ADMIN_USER:-rcadmin} - ROCKETCHAT_ADMIN_USER=${ROCKETCHAT_ADMIN_USER:-rcadmin}
- ROCKETCHAT_ADMIN_PASSWORD=${ROCKETCHAT_ADMIN_PASSWORD:-changeme} - ROCKETCHAT_ADMIN_PASSWORD=${ROCKETCHAT_ADMIN_PASSWORD:?ROCKETCHAT_ADMIN_PASSWORD must be set in .env}
- ROCKETCHAT_EMBED_PORT=${ROCKETCHAT_EMBED_PORT:-8891} - ROCKETCHAT_EMBED_PORT=${ROCKETCHAT_EMBED_PORT:-8891}
- ENABLE_CHAT=${ENABLE_CHAT:-false} - ENABLE_CHAT=${ENABLE_CHAT:-false}
- GANCIO_URL=${GANCIO_URL:-http://gancio-changemaker:13120} - GANCIO_URL=${GANCIO_URL:-http://gancio-changemaker:13120}
@ -129,7 +129,7 @@ services:
container_name: changemaker-media-api container_name: changemaker-media-api
restart: unless-stopped restart: unless-stopped
ports: ports:
- "${MEDIA_API_PORT:-4100}:4100" - "127.0.0.1:${MEDIA_API_PORT:-4100}:4100"
healthcheck: healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:4100/health"] test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:4100/health"]
interval: 15s interval: 15s
@ -178,7 +178,7 @@ services:
container_name: changemaker-v2-admin container_name: changemaker-v2-admin
restart: unless-stopped restart: unless-stopped
ports: ports:
- "${ADMIN_PORT:-3000}:3000" - "127.0.0.1:${ADMIN_PORT:-3000}:3000"
healthcheck: healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:3000/"] test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:3000/"]
interval: 30s interval: 30s
@ -266,11 +266,11 @@ services:
# NocoDB v2 — pointed at v2 PostgreSQL as read-only data browser # NocoDB v2 — pointed at v2 PostgreSQL as read-only data browser
nocodb-v2: nocodb-v2:
image: nocodb/nocodb:latest image: nocodb/nocodb:0.301.3
container_name: changemaker-v2-nocodb container_name: changemaker-v2-nocodb
restart: unless-stopped restart: unless-stopped
ports: ports:
- "${NOCODB_V2_PORT:-8091}:8080" - "127.0.0.1:${NOCODB_V2_PORT:-8091}:8080"
healthcheck: healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/api/v1/health"] test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/api/v1/health"]
interval: 30s interval: 30s
@ -352,11 +352,11 @@ services:
# Listmonk — Email marketing (kept as Docker image, controlled via REST API) # Listmonk — Email marketing (kept as Docker image, controlled via REST API)
listmonk-app: listmonk-app:
image: listmonk/listmonk:latest image: listmonk/listmonk:v6.0.0
container_name: listmonk-app container_name: listmonk-app
restart: unless-stopped restart: unless-stopped
ports: ports:
- "${LISTMONK_PORT:-9001}:9000" - "127.0.0.1:${LISTMONK_PORT:-9001}:9000"
healthcheck: healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:9000/"] test: ["CMD", "wget", "-q", "--spider", "http://localhost:9000/"]
interval: 30s interval: 30s
@ -487,9 +487,16 @@ services:
volumes: volumes:
- ./configs/code-server/.config:/home/coder/.config - ./configs/code-server/.config:/home/coder/.config
- ./configs/code-server/.local:/home/coder/.local - ./configs/code-server/.local:/home/coder/.local
- .:/home/coder/project - ./api:/home/coder/project/api
- ./admin:/home/coder/project/admin
- ./nginx:/home/coder/project/nginx
- ./configs:/home/coder/project/configs
- ./scripts:/home/coder/project/scripts
- ./mkdocs:/home/coder/project/mkdocs
- ./docker-compose.yml:/home/coder/project/docker-compose.yml
# NOTE: .env intentionally excluded — secrets must not be accessible via Code Server
ports: ports:
- "${CODE_SERVER_PORT:-8888}:8080" - "127.0.0.1:${CODE_SERVER_PORT:-8888}:8080"
restart: unless-stopped restart: unless-stopped
networks: networks:
- changemaker-lite - changemaker-lite
@ -505,7 +512,7 @@ services:
- ./scripts/mkdocs-entrypoint.sh:/scripts/mkdocs-entrypoint.sh:ro - ./scripts/mkdocs-entrypoint.sh:/scripts/mkdocs-entrypoint.sh:ro
user: "${USER_ID:-1000}:${GROUP_ID:-1000}" user: "${USER_ID:-1000}:${GROUP_ID:-1000}"
ports: ports:
- "${MKDOCS_PORT:-4003}:8000" - "127.0.0.1:${MKDOCS_PORT:-4003}:8000"
environment: environment:
- SITE_URL=${BASE_DOMAIN:-https://cmlite.org} - SITE_URL=${BASE_DOMAIN:-https://cmlite.org}
- ADMIN_PORT=${ADMIN_PORT:-3000} - ADMIN_PORT=${ADMIN_PORT:-3000}
@ -524,7 +531,7 @@ services:
# MkDocs built site — Nginx static server # MkDocs built site — Nginx static server
mkdocs-site-server: mkdocs-site-server:
image: lscr.io/linuxserver/nginx:latest image: lscr.io/linuxserver/nginx:1.28.2
container_name: mkdocs-site-server-changemaker container_name: mkdocs-site-server-changemaker
environment: environment:
- PUID=${USER_ID:-1000} - PUID=${USER_ID:-1000}
@ -534,7 +541,7 @@ services:
- ./mkdocs/site:/config/www - ./mkdocs/site:/config/www
- ./configs/mkdocs-site/default.conf:/config/nginx/site-confs/default.conf - ./configs/mkdocs-site/default.conf:/config/nginx/site-confs/default.conf
ports: ports:
- "${MKDOCS_SITE_SERVER_PORT:-4004}:80" - "127.0.0.1:${MKDOCS_SITE_SERVER_PORT:-4004}:80"
restart: unless-stopped restart: unless-stopped
networks: networks:
- changemaker-lite - changemaker-lite
@ -545,7 +552,7 @@ services:
container_name: n8n-changemaker container_name: n8n-changemaker
restart: unless-stopped restart: unless-stopped
ports: ports:
- "${N8N_PORT:-5678}:5678" - "127.0.0.1:${N8N_PORT:-5678}:5678"
healthcheck: healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:5678/healthz"] test: ["CMD", "wget", "-q", "--spider", "http://localhost:5678/healthz"]
interval: 30s interval: 30s
@ -571,10 +578,10 @@ services:
# Homepage dashboard # Homepage dashboard
homepage: homepage:
image: ghcr.io/gethomepage/homepage:latest image: ghcr.io/gethomepage/homepage:v0.7.2
container_name: homepage-changemaker container_name: homepage-changemaker
ports: ports:
- "${HOMEPAGE_PORT:-3010}:3000" - "127.0.0.1:${HOMEPAGE_PORT:-3010}:3000"
volumes: volumes:
- ./configs/homepage:/app/config - ./configs/homepage:/app/config
- ./assets/icons:/app/public/icons - ./assets/icons:/app/public/icons
@ -624,8 +631,8 @@ services:
- /etc/timezone:/etc/timezone:ro - /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro - /etc/localtime:/etc/localtime:ro
ports: ports:
- "${GITEA_WEB_PORT:-3030}:3000" - "127.0.0.1:${GITEA_WEB_PORT:-3030}:3000"
- "${GITEA_SSH_PORT:-2222}:22" - "127.0.0.1:${GITEA_SSH_PORT:-2222}:22"
depends_on: depends_on:
- gitea-db - gitea-db
networks: networks:
@ -652,21 +659,21 @@ services:
# Mini QR — QR code generator # Mini QR — QR code generator
mini-qr: mini-qr:
image: ghcr.io/lyqht/mini-qr:latest image: ghcr.io/lyqht/mini-qr:v0.26.0
container_name: mini-qr container_name: mini-qr
ports: ports:
- "${MINI_QR_PORT:-8089}:8080" - "127.0.0.1:${MINI_QR_PORT:-8089}:8080"
restart: unless-stopped restart: unless-stopped
networks: networks:
- changemaker-lite - changemaker-lite
# Excalidraw — Collaborative whiteboard # Excalidraw — Collaborative whiteboard
excalidraw: excalidraw:
image: kiliandeca/excalidraw:latest image: kiliandeca/excalidraw:sha-e42a510
container_name: excalidraw-changemaker container_name: excalidraw-changemaker
restart: unless-stopped restart: unless-stopped
ports: ports:
- "${EXCALIDRAW_PORT:-8090}:80" - "127.0.0.1:${EXCALIDRAW_PORT:-8090}:80"
healthcheck: healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:80/"] test: ["CMD", "wget", "-q", "--spider", "http://localhost:80/"]
interval: 30s interval: 30s
@ -680,11 +687,11 @@ services:
# Vaultwarden — Password manager (Bitwarden-compatible) # Vaultwarden — Password manager (Bitwarden-compatible)
vaultwarden: vaultwarden:
image: vaultwarden/server:latest image: vaultwarden/server:1.35.4
container_name: vaultwarden-changemaker container_name: vaultwarden-changemaker
restart: unless-stopped restart: unless-stopped
ports: ports:
- "${VAULTWARDEN_PORT:-8445}:80" - "127.0.0.1:${VAULTWARDEN_PORT:-8445}:80"
healthcheck: healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:80/alive"] test: ["CMD", "curl", "-sf", "http://localhost:80/alive"]
interval: 30s interval: 30s
@ -714,7 +721,7 @@ services:
# Uses the admin panel API to send an invitation email (lands in MailHog or real SMTP). # Uses the admin panel API to send an invitation email (lands in MailHog or real SMTP).
# Safe to re-run (Vaultwarden ignores duplicate invites for existing users). Exits 0 on success. # Safe to re-run (Vaultwarden ignores duplicate invites for existing users). Exits 0 on success.
vaultwarden-init: vaultwarden-init:
image: alpine/curl:latest image: alpine/curl:8.11.1
container_name: vaultwarden-init container_name: vaultwarden-init
depends_on: depends_on:
vaultwarden: vaultwarden:
@ -850,14 +857,14 @@ services:
# Gancio — Event management platform (uses shared PostgreSQL) # Gancio — Event management platform (uses shared PostgreSQL)
gancio: gancio:
image: cisti/gancio:latest image: cisti/gancio:1.28.2
container_name: gancio-changemaker container_name: gancio-changemaker
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
v2-postgres: v2-postgres:
condition: service_healthy condition: service_healthy
ports: ports:
- "${GANCIO_PORT:-8092}:13120" - "127.0.0.1:${GANCIO_PORT:-8092}:13120"
healthcheck: healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://localhost:13120/', r => process.exit(r.statusCode < 400 ? 0 : 1)).on('error', () => process.exit(1))"] test: ["CMD", "node", "-e", "require('http').get('http://localhost:13120/', r => process.exit(r.statusCode < 400 ? 0 : 1)).on('error', () => process.exit(1))"]
interval: 30s interval: 30s
@ -1023,7 +1030,7 @@ services:
jitsi-prosody: jitsi-prosody:
condition: service_healthy condition: service_healthy
ports: ports:
- "${JVB_PORT:-10000}:10000/udp" - "127.0.0.1:${JVB_PORT:-10000}:10000/udp"
environment: environment:
- XMPP_DOMAIN=meet.jitsi - XMPP_DOMAIN=meet.jitsi
- XMPP_AUTH_DOMAIN=auth.meet.jitsi - XMPP_AUTH_DOMAIN=auth.meet.jitsi
@ -1041,10 +1048,10 @@ services:
# MailHog — Email testing (dev) # MailHog — Email testing (dev)
mailhog: mailhog:
image: mailhog/mailhog:latest image: mailhog/mailhog:v1.0.1
container_name: mailhog-changemaker container_name: mailhog-changemaker
ports: ports:
- "${MAILHOG_WEB_PORT:-8025}:8025" - "127.0.0.1:${MAILHOG_WEB_PORT:-8025}:8025"
# SMTP port 1025 is only exposed on the Docker network (containers connect via mailhog-changemaker:1025) # SMTP port 1025 is only exposed on the Docker network (containers connect via mailhog-changemaker:1025)
restart: unless-stopped restart: unless-stopped
networks: networks:
@ -1075,7 +1082,7 @@ services:
# Docker socket proxy — read-only access for container status monitoring # Docker socket proxy — read-only access for container status monitoring
docker-socket-proxy: docker-socket-proxy:
image: tecnativa/docker-socket-proxy:latest image: tecnativa/docker-socket-proxy:0.4.2
container_name: docker-socket-proxy container_name: docker-socket-proxy
restart: unless-stopped restart: unless-stopped
environment: environment:
@ -1096,14 +1103,14 @@ services:
# ========================================================================= # =========================================================================
prometheus: prometheus:
image: prom/prometheus:latest image: prom/prometheus:v3.10.0
container_name: prometheus-changemaker container_name: prometheus-changemaker
command: command:
- '--config.file=/etc/prometheus/prometheus.yml' - '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.path=/prometheus' - '--storage.tsdb.path=/prometheus'
- '--storage.tsdb.retention.time=30d' - '--storage.tsdb.retention.time=30d'
ports: ports:
- "${PROMETHEUS_PORT:-9090}:9090" - "127.0.0.1:${PROMETHEUS_PORT:-9090}:9090"
volumes: volumes:
- ./configs/prometheus:/etc/prometheus - ./configs/prometheus:/etc/prometheus
- prometheus-data:/prometheus - prometheus-data:/prometheus
@ -1114,10 +1121,10 @@ services:
- monitoring - monitoring
grafana: grafana:
image: grafana/grafana:latest image: grafana/grafana:12.3.0
container_name: grafana-changemaker container_name: grafana-changemaker
ports: ports:
- "${GRAFANA_PORT:-3001}:3000" - "127.0.0.1:${GRAFANA_PORT:-3001}:3000"
environment: environment:
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD:?GRAFANA_ADMIN_PASSWORD must be set in .env} - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD:?GRAFANA_ADMIN_PASSWORD must be set in .env}
- GF_USERS_ALLOW_SIGN_UP=false - GF_USERS_ALLOW_SIGN_UP=false
@ -1137,10 +1144,10 @@ services:
- monitoring - monitoring
cadvisor: cadvisor:
image: gcr.io/cadvisor/cadvisor:latest image: gcr.io/cadvisor/cadvisor:v0.55.1
container_name: cadvisor-changemaker container_name: cadvisor-changemaker
ports: ports:
- "${CADVISOR_PORT:-8080}:8080" - "127.0.0.1:${CADVISOR_PORT:-8080}:8080"
volumes: volumes:
- /:/rootfs:ro - /:/rootfs:ro
- /var/run:/var/run:ro - /var/run:/var/run:ro
@ -1148,6 +1155,7 @@ services:
- /var/lib/docker/:/var/lib/docker:ro - /var/lib/docker/:/var/lib/docker:ro
- /dev/disk/:/dev/disk:ro - /dev/disk/:/dev/disk:ro
privileged: true privileged: true
read_only: true
devices: devices:
- /dev/kmsg - /dev/kmsg
restart: always restart: always
@ -1157,10 +1165,10 @@ services:
- monitoring - monitoring
node-exporter: node-exporter:
image: prom/node-exporter:latest image: prom/node-exporter:v1.10.2
container_name: node-exporter-changemaker container_name: node-exporter-changemaker
ports: ports:
- "${NODE_EXPORTER_PORT:-9100}:9100" - "127.0.0.1:${NODE_EXPORTER_PORT:-9100}:9100"
command: command:
- '--path.rootfs=/host' - '--path.rootfs=/host'
- '--path.procfs=/host/proc' - '--path.procfs=/host/proc'
@ -1177,10 +1185,10 @@ services:
- monitoring - monitoring
redis-exporter: redis-exporter:
image: oliver006/redis_exporter:latest image: oliver006/redis_exporter:v1.81.0
container_name: redis-exporter-changemaker container_name: redis-exporter-changemaker
ports: ports:
- "${REDIS_EXPORTER_PORT:-9121}:9121" - "127.0.0.1:${REDIS_EXPORTER_PORT:-9121}:9121"
environment: environment:
- REDIS_ADDR=redis://redis-changemaker:6379 - REDIS_ADDR=redis://redis-changemaker:6379
- REDIS_PASSWORD=${REDIS_PASSWORD} - REDIS_PASSWORD=${REDIS_PASSWORD}
@ -1193,10 +1201,10 @@ services:
- monitoring - monitoring
alertmanager: alertmanager:
image: prom/alertmanager:latest image: prom/alertmanager:v0.31.1
container_name: alertmanager-changemaker container_name: alertmanager-changemaker
ports: ports:
- "${ALERTMANAGER_PORT:-9093}:9093" - "127.0.0.1:${ALERTMANAGER_PORT:-9093}:9093"
volumes: volumes:
- ./configs/alertmanager:/etc/alertmanager - ./configs/alertmanager:/etc/alertmanager
- alertmanager-data:/alertmanager - alertmanager-data:/alertmanager
@ -1210,10 +1218,10 @@ services:
- monitoring - monitoring
gotify: gotify:
image: gotify/server:latest image: gotify/server:v2.9.0
container_name: gotify-changemaker container_name: gotify-changemaker
ports: ports:
- "${GOTIFY_PORT:-8889}:80" - "127.0.0.1:${GOTIFY_PORT:-8889}:80"
environment: environment:
- GOTIFY_DEFAULTUSER_NAME=${GOTIFY_ADMIN_USER:-admin} - GOTIFY_DEFAULTUSER_NAME=${GOTIFY_ADMIN_USER:-admin}
- GOTIFY_DEFAULTUSER_PASS=${GOTIFY_ADMIN_PASSWORD:?GOTIFY_ADMIN_PASSWORD must be set in .env} - GOTIFY_DEFAULTUSER_PASS=${GOTIFY_ADMIN_PASSWORD:?GOTIFY_ADMIN_PASSWORD must be set in .env}

View File

@ -10,7 +10,14 @@ http {
include /etc/nginx/mime.types; include /etc/nginx/mime.types;
default_type application/octet-stream; default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" ' # Redact sensitive query parameters (token, secret) from access logs
map $request_uri $redacted_request {
~^(?P<path>[^?]*)\?(?P<args>.*token=[^&]*) "$path?<token-redacted>";
~^(?P<path>[^?]*)\?(?P<args>.*secret=[^&]*) "$path?<secret-redacted>";
default $request_uri;
}
log_format main '$remote_addr - $remote_user [$time_local] "$request_method $redacted_request $server_protocol" '
'$status $body_bytes_sent "$http_referer" ' '$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"'; '"$http_user_agent" "$http_x_forwarded_for"';
@ -32,11 +39,17 @@ http {
gzip_comp_level 6; gzip_comp_level 6;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml; gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
# Only send HSTS when the request arrived over HTTPS (via Pangolin tunnel)
map $http_x_forwarded_proto $hsts_header {
https "max-age=31536000; includeSubDomains";
default "";
}
# Security headers (applied globally X-Frame-Options set per server block) # Security headers (applied globally X-Frame-Options set per server block)
add_header X-Content-Type-Options "nosniff" always; add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always; add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; add_header Strict-Transport-Security $hsts_header always;
add_header Permissions-Policy "geolocation=(self), microphone=(), camera=()" always; add_header Permissions-Policy "geolocation=(self), microphone=(), camera=()" always;
# Docker internal DNS enables runtime resolution so nginx starts # Docker internal DNS enables runtime resolution so nginx starts