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_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_HOST: z.string().default('mailhog-changemaker'),

View File

@ -28,13 +28,13 @@ const recurrenceRuleSchema = z.object({
export const createItemSchema = z.object({
layerId: z.string().min(1),
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'),
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'),
isAllDay: z.boolean().optional(),
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(),
visibility: z.enum(['PRIVATE', 'FRIENDS', 'PUBLIC']).optional(),
busyStatus: z.enum(['BUSY', 'TENTATIVE', 'FREE']).optional(),
@ -45,13 +45,13 @@ export const createItemSchema = z.object({
export const updateItemSchema = z.object({
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(),
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(),
isAllDay: z.boolean().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(),
visibility: z.enum(['PRIVATE', 'FRIENDS', 'PUBLIC']).nullable().optional(),
busyStatus: z.enum(['BUSY', 'TENTATIVE', 'FREE']).optional(),

View File

@ -484,6 +484,14 @@ export const feedService = {
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 pastLimit = new Date(now);
pastLimit.setMonth(pastLimit.getMonth() - 1);

View File

@ -32,7 +32,8 @@ function hashFilePath(path: string): string {
function safeResolve(relativePath: string): string {
const normalized = normalize(relativePath).replace(/^(\.\.(\/|\\|$))+/, '');
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();
}
return resolved;

View File

@ -21,9 +21,10 @@ router.use(requireNonTemp);
// 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(
'/status',
requireRole(...CONTENT_ROLES),
async (_req: Request, res: Response, next: NextFunction) => {
try {
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(
'/config',
requireRole(...CONTENT_ROLES),
async (_req: Request, res: Response, _next: NextFunction) => {
res.json({
codeServerPort: env.CODE_SERVER_PORT,
@ -58,9 +60,10 @@ router.get(
// --- 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(
'/mkdocs-config',
requireRole(...CONTENT_ROLES),
async (_req: Request, res: Response, next: NextFunction) => {
try {
const content = await mkdocsConfigService.readConfig();
@ -113,9 +116,10 @@ router.post(
// --- 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(
'/header-config',
requireRole(...CONTENT_ROLES),
async (_req: Request, res: Response, next: NextFunction) => {
try {
const config = await headerBuilderService.readConfig();
@ -205,9 +209,10 @@ router.post(
// --- File Management Endpoints ---
// GET /api/docs/files — list file tree
// GET /api/docs/files — list file tree (content editors only)
router.get(
'/files',
requireRole(...CONTENT_ROLES),
async (req: Request, res: Response, next: NextFunction) => {
try {
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(
'/files/search',
requireRole(...CONTENT_ROLES),
async (req: Request, res: Response, next: NextFunction) => {
try {
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(
'/files/*',
requireRole(...CONTENT_ROLES),
async (req: Request, res: Response, next: NextFunction) => {
try {
cm_docs_operations.inc({ operation: 'read' });

View File

@ -3,7 +3,11 @@ import { z } from 'zod';
export const headerNavItemSchema = z.object({
id: z.string().min(1),
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(),
enabled: z.boolean(),
order: z.number().int().min(0),

View File

@ -56,6 +56,41 @@ const 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 {
return title
.toLowerCase()
@ -224,7 +259,7 @@ export const campaignsService = {
async findActiveCampaigns() {
return prisma.campaign.findMany({
where: { status: 'ACTIVE' },
select: campaignSelect,
select: publicCampaignSelect,
orderBy: [
{ highlightCampaign: 'desc' },
{ createdAt: 'desc' },
@ -235,7 +270,7 @@ export const campaignsService = {
async findBySlugPublic(slug: string) {
const campaign = await prisma.campaign.findUnique({
where: { slug },
select: campaignSelect,
select: publicCampaignSelect,
});
if (!campaign) {

View File

@ -15,7 +15,8 @@ router.post(
'/webhook',
async (req: Request, res: Response, next: NextFunction) => {
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) {
res.status(403).json({ error: 'Invalid webhook secret' });
return;

View File

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

View File

@ -186,6 +186,23 @@ export async function videoTrackingRoutes(fastify: FastifyInstance) {
}
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(
events.map(async (event) => {
switch (event.type) {

View File

@ -130,14 +130,20 @@ router.post('/test-connection', async (req, res) => {
return;
}
// Validate URL format
// Validate URL format and protocol
let parsedUrl: URL;
try {
new URL(url);
parsedUrl = new URL(url);
} catch {
res.status(400).json({ error: { message: 'Invalid URL format', code: 'VALIDATION_ERROR' } });
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);
res.json(result);
} catch (err) {
@ -173,10 +179,14 @@ router.post('/save-config', async (req, res) => {
if (typeof smsTailscaleDeviceId === 'string') update.smsTailscaleDeviceId = smsTailscaleDeviceId;
if (typeof smsTailscaleDeviceName === 'string') update.smsTailscaleDeviceName = smsTailscaleDeviceName;
// Validate URL format if provided
// Validate URL format and protocol if provided
if (typeof smsTermuxApiUrl === 'string' && smsTermuxApiUrl) {
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 {
res.status(400).json({ error: { message: 'Invalid Termux API URL format', code: 'VALIDATION_ERROR' } });
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) => {
try {
const groupId = req.params.id as string;
const userId = req.user!.id;
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);
} catch (err: any) {
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 { checkSocialEnabled } from './social.middleware';
const PROFILE_SELECT = {
/** Own profile — includes email */
const OWN_PROFILE_SELECT = {
id: true,
name: true,
email: true,
@ -15,6 +16,14 @@ const PROFILE_SELECT = {
createdAt: true,
} 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();
router.use(authenticate);
router.use(checkSocialEnabled);
@ -25,7 +34,7 @@ router.get('/me', async (req: Request, res: Response) => {
const userId = req.user!.id;
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({
where: {
OR: [

View File

@ -1,9 +1,15 @@
import { Router } from 'express';
import { z } from 'zod';
import { socialAdminService } from './social-admin.service';
import { requireRole } from '../../middleware/rbac.middleware';
import { SOCIAL_ROLES } from '../../utils/roles';
import { logger } from '../../utils/logger';
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 */
router.get('/stats', async (_req, res) => {
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 */
router.post('/achievements/grant', async (req, res) => {
try {
const { userId, achievementId } = req.body;
if (!userId || !achievementId) {
return res.status(400).json({ error: 'userId and achievementId are required' });
const parsed = achievementSchema.safeParse(req.body);
if (!parsed.success) {
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);
} catch (err: unknown) {
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 */
router.post('/achievements/revoke', async (req, res) => {
try {
const { userId, achievementId } = req.body;
if (!userId || !achievementId) {
return res.status(400).json({ error: 'userId and achievementId are required' });
const parsed = achievementSchema.safeParse(req.body);
if (!parsed.success) {
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 });
} catch (err: unknown) {
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();
} 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 { checkSocialEnabled } from './social.middleware';
import { requireRole } from '../../middleware/rbac.middleware';
import { SOCIAL_ROLES } from '../../utils/roles';
import { sseService } from './sse.service';
import { presenceService } from './presence.service';
const MAX_SSE_CONNECTIONS_PER_USER = 5;
const router = Router();
router.use(checkSocialEnabled);
@ -11,6 +15,13 @@ router.use(checkSocialEnabled);
router.get('/', (req, res) => {
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
res.writeHead(200, {
'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 */
router.get('/status', (_req, res) => {
/** GET /api/social/sse/status — SSE service status (admin only) */
router.get('/status', requireRole(...SOCIAL_ROLES), (_req, res) => {
res.json({
connections: sseService.getConnectionCount(),
connectedUsers: sseService.getConnectedUserIds().length,

View File

@ -117,6 +117,11 @@ class SSEService {
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) */
closeAll() {
this.stopHeartbeat();

View File

@ -1,13 +1,12 @@
import { Router, Request, Response, NextFunction } from 'express';
import { authenticate } from '../../middleware/auth.middleware';
import { requireRole } from '../../middleware/rbac.middleware';
import { ADMIN_ROLES } from '../../utils/roles';
import { userProvisioningService } from '../../services/user-provisioning/provisioning.service';
const router = Router();
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)
router.post(

View File

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