Comprehensive 6-domain security audit addressing 8 Critical, 17 Important,
and 5 Low findings. Key fixes:
Critical:
- Strip PII from unauthenticated ticket lookup (IDOR)
- Add role+permission checks to event check-in routes
- Validate tier-to-event ownership on update/delete (IDOR)
- Fix path traversal in video replace (resolve + prefix check)
- Enable MongoDB authentication for Rocket.Chat
- Disable Grafana anonymous access
- Sanitize CSV exports against formula injection (payments)
- Apply DOMPurify to richDescription on public event page (XSS)
Important:
- Require current password for self-service password changes
- Atomic password reset token consumption (race condition fix)
- Scope postMessage to specific origin (not wildcard)
- Validate redirect parameter against open redirect
- Replace weak temp passwords (5760 values → crypto.randomBytes)
- Move shift capacity check inside transaction (TOCTOU fix)
- Fix EVENTS_ADMIN privilege inversion in ticketed events
- Make ENCRYPTION_KEY required (remove optional fallback)
- Add internal Prometheus metrics endpoint for Docker scraping
- Add nginx-level rate limiting (limit_req_zone)
- Fix X-Forwarded-For to use $remote_addr (prevents spoofing)
- Replace CSP stripping with frame-ancestors in embed proxies
- Remove error.message from Fastify 500 responses
- Strip PII from volunteer canvass address data
- Wrap GrapesJS output in {% raw %} to prevent Jinja2 SSTI
- Scope SSE token query param to /sse path only
- Sanitize Listmonk email query against injection
Bunker Admin
252 lines
10 KiB
TypeScript
252 lines
10 KiB
TypeScript
import dotenv from 'dotenv';
|
|
import { z } from 'zod';
|
|
|
|
dotenv.config();
|
|
|
|
const envSchema = z.object({
|
|
// Server
|
|
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
|
PORT: z.coerce.number().default(4000),
|
|
API_URL: z.string().default('http://localhost:4000'),
|
|
ADMIN_URL: z.string().default('http://localhost:3000'),
|
|
DOMAIN: z.string().default('cmlite.org'),
|
|
|
|
// Logging
|
|
LOG_DIR: z.string().default('/app/logs'),
|
|
|
|
// Security
|
|
CSP_ENABLED: z.string().default('false'),
|
|
|
|
// Bunker Ops (Fleet Management)
|
|
INSTANCE_LABEL: z.string().default(''),
|
|
BUNKER_OPS_ENABLED: z.string().default('false'),
|
|
BUNKER_OPS_REMOTE_WRITE_URL: z.string().default(''),
|
|
|
|
// Database
|
|
DATABASE_URL: z.string(),
|
|
|
|
// Redis
|
|
REDIS_URL: z.string().default('redis://redis-changemaker:6379'),
|
|
|
|
// JWT
|
|
JWT_ACCESS_SECRET: z.string().min(32),
|
|
JWT_REFRESH_SECRET: z.string().min(32),
|
|
JWT_INVITE_SECRET: z.string().min(32),
|
|
JWT_ACCESS_EXPIRY: z.string().default('15m'),
|
|
JWT_REFRESH_EXPIRY: z.string().default('7d'),
|
|
|
|
// Encryption (for DB-stored secrets like SMTP password — required for all environments)
|
|
ENCRYPTION_KEY: z.string().min(32, 'ENCRYPTION_KEY must be at least 32 characters'),
|
|
|
|
// 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')
|
|
.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'),
|
|
SMTP_PORT: z.coerce.number().default(1025),
|
|
SMTP_USER: z.string().default(''),
|
|
SMTP_PASS: z.string().default(''),
|
|
SMTP_FROM: z.string().default('noreply@cmlite.org'),
|
|
SMTP_FROM_NAME: z.string().default('Changemaker Lite'),
|
|
EMAIL_TEST_MODE: z.string().default('true'),
|
|
TEST_EMAIL_RECIPIENT: z.string().default('admin@cmlite.org'),
|
|
|
|
// Listmonk
|
|
LISTMONK_URL: z.string().default('http://listmonk-app:9000'),
|
|
LISTMONK_ADMIN_USER: z.string().default('admin'),
|
|
LISTMONK_ADMIN_PASSWORD: z.string().default(''),
|
|
LISTMONK_SYNC_ENABLED: z.string().default('false'),
|
|
LISTMONK_WEBHOOK_SECRET: z.string().default(''),
|
|
LISTMONK_PROXY_PORT: z.coerce.number().default(9002),
|
|
|
|
// Represent API (Canadian electoral data)
|
|
REPRESENT_API_URL: z.string().default('https://represent.opennorth.ca'),
|
|
|
|
// CORS
|
|
CORS_ORIGINS: z.string().default('http://localhost:3000'),
|
|
|
|
// Rate Limiting
|
|
RATE_LIMIT_WINDOW_MS: z.coerce.number().default(15 * 60 * 1000),
|
|
RATE_LIMIT_MAX: z.coerce.number().default(500),
|
|
|
|
// Geocoding
|
|
MAPBOX_API_KEY: z.string().optional(),
|
|
GEOCODING_RATE_LIMIT_MS: z.coerce.number().default(1100),
|
|
GEOCODING_CACHE_ENABLED: z.string().default('true'),
|
|
GEOCODING_CACHE_TTL_HOURS: z.coerce.number().default(24),
|
|
// Phase 2: Performance & Accuracy
|
|
GOOGLE_MAPS_API_KEY: z.string().optional(),
|
|
GOOGLE_MAPS_ENABLED: z.string().default('false'),
|
|
GEOCODING_PARALLEL_ENABLED: z.string().default('true'),
|
|
GEOCODING_BATCH_SIZE: z.coerce.number().default(10),
|
|
|
|
// Bulk Re-Geocoding (Phase 3)
|
|
BULK_GEOCODE_ENABLED: z.string().default('true'),
|
|
BULK_GEOCODE_MAX_BATCH: z.coerce.number().default(5000),
|
|
|
|
// Platform Services (NocoDB, n8n, Gitea)
|
|
NOCODB_URL: z.string().default('http://changemaker-v2-nocodb:8080'),
|
|
NOCODB_PORT: z.coerce.number().default(8091),
|
|
NOCODB_EMBED_PORT: z.coerce.number().default(8881),
|
|
N8N_URL: z.string().default('http://n8n-changemaker:5678'),
|
|
N8N_PORT: z.coerce.number().default(5678),
|
|
N8N_EMBED_PORT: z.coerce.number().default(8882),
|
|
GITEA_URL: z.string().default('http://gitea-changemaker:3000'),
|
|
GITEA_PORT: z.coerce.number().default(3030),
|
|
GITEA_EMBED_PORT: z.coerce.number().default(8883),
|
|
|
|
// MailHog (email testing UI)
|
|
MAILHOG_URL: z.string().default('http://mailhog-changemaker:8025'),
|
|
MAILHOG_EMBED_PORT: z.coerce.number().default(8884),
|
|
|
|
// Mini QR (QR code generator)
|
|
MINI_QR_URL: z.string().default('http://mini-qr:8080'),
|
|
MINI_QR_PORT: z.coerce.number().default(8089),
|
|
MINI_QR_EMBED_PORT: z.coerce.number().default(8885),
|
|
|
|
// Excalidraw (collaborative whiteboard)
|
|
EXCALIDRAW_URL: z.string().default('http://excalidraw-changemaker:80'),
|
|
EXCALIDRAW_PORT: z.coerce.number().default(8090),
|
|
EXCALIDRAW_EMBED_PORT: z.coerce.number().default(8886),
|
|
|
|
// Homepage (service dashboard)
|
|
HOMEPAGE_URL: z.string().default('http://homepage-changemaker:3000'),
|
|
HOMEPAGE_EMBED_PORT: z.coerce.number().default(8887),
|
|
|
|
// Vaultwarden (password manager)
|
|
VAULTWARDEN_URL: z.string().default('http://vaultwarden-changemaker:80'),
|
|
VAULTWARDEN_ADMIN_TOKEN: z.string().default(''),
|
|
VAULTWARDEN_EMBED_PORT: z.coerce.number().default(8890),
|
|
|
|
// Rocket.Chat (team chat)
|
|
ROCKETCHAT_URL: z.string().default('http://rocketchat-changemaker:3000'),
|
|
ROCKETCHAT_ADMIN_USER: z.string().default(''),
|
|
ROCKETCHAT_ADMIN_PASSWORD: z.string().default(''),
|
|
ROCKETCHAT_EMBED_PORT: z.coerce.number().default(8891),
|
|
ENABLE_CHAT: z.string().default('false'),
|
|
|
|
// Gancio (event management)
|
|
GANCIO_URL: z.string().default('http://gancio-changemaker:13120'),
|
|
GANCIO_PORT: z.coerce.number().default(8092),
|
|
GANCIO_EMBED_PORT: z.coerce.number().default(8892),
|
|
GANCIO_ADMIN_USER: z.string().default('admin'),
|
|
GANCIO_ADMIN_PASSWORD: z.string().default(''),
|
|
GANCIO_SYNC_ENABLED: z.string().default('false'),
|
|
|
|
// Jitsi Meet (video conferencing)
|
|
ENABLE_MEET: z.string().default('false'),
|
|
JITSI_APP_ID: z.string().default('changemaker'),
|
|
JITSI_APP_SECRET: z.string().default(''),
|
|
JITSI_URL: z.string().default('http://jitsi-web-changemaker:80'),
|
|
JITSI_EMBED_PORT: z.coerce.number().default(8893),
|
|
|
|
// Pangolin (tunnel / reverse proxy)
|
|
PANGOLIN_API_URL: z.string()
|
|
.default('')
|
|
.refine(
|
|
(url) => !url || url.startsWith('https://'),
|
|
{ message: 'PANGOLIN_API_URL must use HTTPS for secure credential transmission' }
|
|
),
|
|
PANGOLIN_API_KEY: z.string().default(''),
|
|
PANGOLIN_ORG_ID: z.string().default(''),
|
|
PANGOLIN_SITE_ID: z.string().default(''),
|
|
PANGOLIN_ENDPOINT: z.string().default(''),
|
|
PANGOLIN_NEWT_ID: z.string().default(''),
|
|
PANGOLIN_NEWT_SECRET: z.string().default(''),
|
|
|
|
// NAR (National Address Register)
|
|
NAR_DATA_DIR: z.string().default('/data'),
|
|
|
|
// Overpass / Area Import
|
|
OVERPASS_API_URL: z.string().default('https://overpass-api.de/api/interpreter'),
|
|
OVERPASS_MIN_DELAY_MS: z.coerce.number().default(30000),
|
|
AREA_IMPORT_MAX_GRID_POINTS: z.coerce.number().default(500),
|
|
|
|
// Payments (Stripe)
|
|
ENABLE_PAYMENTS: z.string().default('false'),
|
|
|
|
// Media Management
|
|
ENABLE_MEDIA_FEATURES: z.string().default('false'),
|
|
MEDIA_API_PORT: z.coerce.number().default(4100),
|
|
MEDIA_API_PUBLIC_URL: z.string().default('http://media-api:4100'),
|
|
MEDIA_ROOT: z.string().default('/media/library'),
|
|
MEDIA_UPLOADS: z.string().default('/media/uploads'),
|
|
MAX_UPLOAD_SIZE_GB: z.coerce.number().default(10),
|
|
|
|
// Container Registry
|
|
GITEA_REGISTRY: z.string().default('gitea.bnkops.com/admin'),
|
|
GITEA_REGISTRY_USER: z.string().default(''),
|
|
GITEA_REGISTRY_PASS: z.string().default(''),
|
|
|
|
// Gitea Docs Comments
|
|
GITEA_COMMENTS_ENABLED: z.string().default('false'),
|
|
GITEA_API_TOKEN: z.string().default(''),
|
|
GITEA_COMMENTS_REPO_OWNER: z.string().default(''),
|
|
GITEA_COMMENTS_REPO_NAME: z.string().default('docs-comments'),
|
|
GITEA_OAUTH_CLIENT_ID: z.string().default(''),
|
|
GITEA_OAUTH_CLIENT_SECRET: z.string().default(''),
|
|
|
|
// SMS Campaigns (Termux Android bridge)
|
|
ENABLE_SMS: z.string().default('false'),
|
|
TERMUX_API_URL: z.string().default('http://10.0.0.193:5001'),
|
|
TERMUX_API_KEY: z.string().default(''),
|
|
SMS_DELAY_BETWEEN_MS: z.coerce.number().default(3000),
|
|
SMS_MAX_RETRIES: z.coerce.number().default(3),
|
|
SMS_RESPONSE_SYNC_INTERVAL_MS: z.coerce.number().default(30000),
|
|
SMS_DEVICE_MONITOR_INTERVAL_MS: z.coerce.number().default(30000),
|
|
|
|
// Docs / Code Server
|
|
CODE_SERVER_URL: z.string().default('http://code-server-changemaker:8443'),
|
|
CODE_SERVER_PORT: z.coerce.number().default(8888),
|
|
MKDOCS_PREVIEW_URL: z.string().default('http://mkdocs-changemaker:8000'),
|
|
MKDOCS_PORT: z.coerce.number().default(4003),
|
|
MKDOCS_DOCS_PATH: z.string().default('/mkdocs/docs'),
|
|
MKDOCS_CONFIG_PATH: z.string().default('/mkdocs/mkdocs.yml'),
|
|
MKDOCS_CONTAINER_NAME: z.string().default('mkdocs-changemaker'),
|
|
MKDOCS_SITE_SERVER_URL: z.string().default('http://mkdocs-site-server-changemaker:80'),
|
|
MKDOCS_SITE_SERVER_PORT: z.coerce.number().default(4004),
|
|
|
|
// Docker (container status dashboard + service management)
|
|
DOCKER_PROXY_URL: z.string().default('http://docker-socket-proxy:2375'),
|
|
DOCKER_NETWORK_NAME: z.string().default('changemaker-lite'),
|
|
NEWT_CONTAINER_NAME: z.string().default('newt-changemaker'),
|
|
NEWT_COMPOSE_SERVICE: z.string().default('newt'),
|
|
|
|
// Monitoring Services (behind 'monitoring' profile)
|
|
PROMETHEUS_URL: z.string().default('http://prometheus-changemaker:9090'),
|
|
PROMETHEUS_PORT: z.coerce.number().default(9090),
|
|
GRAFANA_URL: z.string().default('http://grafana-changemaker:3000'),
|
|
GRAFANA_PORT: z.coerce.number().default(3005),
|
|
GRAFANA_EMBED_PORT: z.coerce.number().default(8894),
|
|
ALERTMANAGER_URL: z.string().default('http://alertmanager-changemaker:9093'),
|
|
ALERTMANAGER_PORT: z.coerce.number().default(9093),
|
|
ALERTMANAGER_EMBED_PORT: z.coerce.number().default(8895),
|
|
CADVISOR_URL: z.string().default('http://cadvisor-changemaker:8080'),
|
|
CADVISOR_PORT: z.coerce.number().default(8086),
|
|
NODE_EXPORTER_URL: z.string().default('http://node-exporter-changemaker:9100'),
|
|
NODE_EXPORTER_PORT: z.coerce.number().default(9100),
|
|
REDIS_EXPORTER_URL: z.string().default('http://redis-exporter-changemaker:9121'),
|
|
REDIS_EXPORTER_PORT: z.coerce.number().default(9121),
|
|
GOTIFY_URL: z.string().default('http://gotify-changemaker:80'),
|
|
GOTIFY_PORT: z.coerce.number().default(8889),
|
|
});
|
|
|
|
export type Env = z.infer<typeof envSchema>;
|
|
|
|
function validateEnv(): Env {
|
|
const result = envSchema.safeParse(process.env);
|
|
if (!result.success) {
|
|
console.error('Invalid environment variables:');
|
|
console.error(result.error.flatten().fieldErrors);
|
|
process.exit(1);
|
|
}
|
|
return result.data;
|
|
}
|
|
|
|
export const env = validateEnv();
|