diff --git a/api/src/config/env.ts b/api/src/config/env.ts index 0617375c..d8df2633 100644 --- a/api/src/config/env.ts +++ b/api/src/config/env.ts @@ -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'), diff --git a/api/src/modules/calendar/calendar.schemas.ts b/api/src/modules/calendar/calendar.schemas.ts index f43a8401..3822cd9a 100644 --- a/api/src/modules/calendar/calendar.schemas.ts +++ b/api/src/modules/calendar/calendar.schemas.ts @@ -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(), diff --git a/api/src/modules/calendar/feed.service.ts b/api/src/modules/calendar/feed.service.ts index 457b91f5..2f474575 100644 --- a/api/src/modules/calendar/feed.service.ts +++ b/api/src/modules/calendar/feed.service.ts @@ -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); diff --git a/api/src/modules/docs/docs-files.service.ts b/api/src/modules/docs/docs-files.service.ts index c608dfd4..c244efbd 100644 --- a/api/src/modules/docs/docs-files.service.ts +++ b/api/src/modules/docs/docs-files.service.ts @@ -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; diff --git a/api/src/modules/docs/docs.routes.ts b/api/src/modules/docs/docs.routes.ts index c3f75d16..c3984ad1 100644 --- a/api/src/modules/docs/docs.routes.ts +++ b/api/src/modules/docs/docs.routes.ts @@ -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' }); diff --git a/api/src/modules/docs/header-builder.schemas.ts b/api/src/modules/docs/header-builder.schemas.ts index 9ace5cce..955e9969 100644 --- a/api/src/modules/docs/header-builder.schemas.ts +++ b/api/src/modules/docs/header-builder.schemas.ts @@ -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), diff --git a/api/src/modules/influence/campaigns/campaigns.service.ts b/api/src/modules/influence/campaigns/campaigns.service.ts index 30fb85cd..530f9ba5 100644 --- a/api/src/modules/influence/campaigns/campaigns.service.ts +++ b/api/src/modules/influence/campaigns/campaigns.service.ts @@ -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) { diff --git a/api/src/modules/listmonk/listmonk-webhook.routes.ts b/api/src/modules/listmonk/listmonk-webhook.routes.ts index f5939064..ae2a1d45 100644 --- a/api/src/modules/listmonk/listmonk-webhook.routes.ts +++ b/api/src/modules/listmonk/listmonk-webhook.routes.ts @@ -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; diff --git a/api/src/modules/media/routes/comments.routes.ts b/api/src/modules/media/routes/comments.routes.ts index fdac855c..122062fc 100644 --- a/api/src/modules/media/routes/comments.routes.ts +++ b/api/src/modules/media/routes/comments.routes.ts @@ -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(); diff --git a/api/src/modules/media/routes/video-tracking.routes.ts b/api/src/modules/media/routes/video-tracking.routes.ts index 4f4c1a21..d85aa174 100644 --- a/api/src/modules/media/routes/video-tracking.routes.ts +++ b/api/src/modules/media/routes/video-tracking.routes.ts @@ -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) { diff --git a/api/src/modules/sms/setup/sms-setup.routes.ts b/api/src/modules/sms/setup/sms-setup.routes.ts index bdf5f6a3..c20fc296 100644 --- a/api/src/modules/sms/setup/sms-setup.routes.ts +++ b/api/src/modules/sms/setup/sms-setup.routes.ts @@ -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; diff --git a/api/src/modules/social/group.routes.ts b/api/src/modules/social/group.routes.ts index a2dc3fa0..7e5ba6bc 100644 --- a/api/src/modules/social/group.routes.ts +++ b/api/src/modules/social/group.routes.ts @@ -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 } }); diff --git a/api/src/modules/social/profile.routes.ts b/api/src/modules/social/profile.routes.ts index 1c449837..a0326638 100644 --- a/api/src/modules/social/profile.routes.ts +++ b/api/src/modules/social/profile.routes.ts @@ -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: [ diff --git a/api/src/modules/social/social-admin.routes.ts b/api/src/modules/social/social-admin.routes.ts index 26fd847d..3473239e 100644 --- a/api/src/modules/social/social-admin.routes.ts +++ b/api/src/modules/social/social-admin.routes.ts @@ -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; diff --git a/api/src/modules/social/social.middleware.ts b/api/src/modules/social/social.middleware.ts index 8ae598b3..55f51f94 100644 --- a/api/src/modules/social/social.middleware.ts +++ b/api/src/modules/social/social.middleware.ts @@ -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' } }); } } diff --git a/api/src/modules/social/sse.routes.ts b/api/src/modules/social/sse.routes.ts index eef37bf2..5881210a 100644 --- a/api/src/modules/social/sse.routes.ts +++ b/api/src/modules/social/sse.routes.ts @@ -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, diff --git a/api/src/modules/social/sse.service.ts b/api/src/modules/social/sse.service.ts index d8e5ffba..c86e10cd 100644 --- a/api/src/modules/social/sse.service.ts +++ b/api/src/modules/social/sse.service.ts @@ -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(); diff --git a/api/src/modules/users/provisioning.routes.ts b/api/src/modules/users/provisioning.routes.ts index 84b0f2c0..5c72c31d 100644 --- a/api/src/modules/users/provisioning.routes.ts +++ b/api/src/modules/users/provisioning.routes.ts @@ -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( diff --git a/docker-compose.yml b/docker-compose.yml index f6198c78..300f5b87 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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} diff --git a/nginx/nginx.conf b/nginx/nginx.conf index 056abe32..4be3a9b7 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -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[^?]*)\?(?P.*token=[^&]*) "$path?"; + ~^(?P[^?]*)\?(?P.*secret=[^&]*) "$path?"; + 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