const crypto = require("crypto"); const cors = require("cors"); const { sendError } = require("../utils/http"); const WRITE_KEY_REGEX = /^[A-Za-z0-9_-]{16,128}$/; let warnedAboutOpenWriteAccess = false; function parseAllowedOrigins(value) { const legacy = process.env.FRONTEND_ORIGIN || ""; const source = value || legacy; if (!source) { return ["http://localhost:3000", "http://127.0.0.1:3000"]; } return source .split(",") .map((item) => item.trim()) .filter(Boolean); } function createCorsMiddleware() { const allowedOrigins = new Set(parseAllowedOrigins(process.env.CORS_ALLOWED_ORIGINS)); return cors({ origin(origin, callback) { // Allow same-origin/browser requests with no Origin header. if (!origin) { callback(null, true); return; } if (allowedOrigins.has(origin)) { callback(null, true); return; } callback(new Error("CORS origin blocked")); }, credentials: false, methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], allowedHeaders: ["Content-Type", "X-API-Key"], maxAge: 600, }); } function securityHeaders(req, res, next) { const trustProxy = String(process.env.TRUST_PROXY || "false").toLowerCase() === "true"; res.setHeader( "Content-Security-Policy", [ "default-src 'self'", "script-src 'self'", "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", "img-src 'self' data: https://render.crafty.gg", "font-src 'self' https://fonts.gstatic.com", "connect-src 'self'", "object-src 'none'", "base-uri 'self'", "form-action 'self'", "frame-ancestors 'none'", "upgrade-insecure-requests", ].join("; ") ); res.setHeader("X-Content-Type-Options", "nosniff"); res.setHeader("X-Frame-Options", "DENY"); res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin"); res.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=()"); res.setHeader("Cross-Origin-Resource-Policy", "same-origin"); res.setHeader("Cross-Origin-Opener-Policy", "same-origin"); if (req.secure || (trustProxy && String(req.get("x-forwarded-proto") || "").toLowerCase() === "https")) { res.setHeader("Strict-Transport-Security", "max-age=31536000; includeSubDomains"); } next(); } function enforceHttps(req, res, next) { const enforce = String(process.env.ENFORCE_HTTPS || "false").toLowerCase() === "true"; const trustProxy = String(process.env.TRUST_PROXY || "false").toLowerCase() === "true"; if (!enforce) { next(); return; } const isSecure = req.secure || (trustProxy && String(req.get("x-forwarded-proto") || "").toLowerCase() === "https"); if (isSecure) { next(); return; } const host = req.get("host"); if (!host) { return sendError(res, 400, "HTTPS_REQUIRED", "HTTPS is required."); } return res.redirect(301, `https://${host}${req.originalUrl}`); } function getWriteKeys() { const raw = process.env.API_WRITE_KEYS || process.env.API_WRITE_KEY || ""; const keys = raw .split(",") .map((key) => key.trim()) .filter(Boolean) .filter((key) => WRITE_KEY_REGEX.test(key)); return keys; } function safeEqual(a, b) { const left = Buffer.from(a); const right = Buffer.from(b); if (left.length !== right.length) { return false; } return crypto.timingSafeEqual(left, right); } function requireWriteApiKey(req, res, next) { const writeKeys = getWriteKeys(); if (writeKeys.length === 0) { if (!warnedAboutOpenWriteAccess) { warnedAboutOpenWriteAccess = true; console.warn("Write API key protection is disabled. Set API_WRITE_KEYS to enforce write authentication."); } next(); return; } const apiKey = String(req.get("x-api-key") || "").trim(); if (!WRITE_KEY_REGEX.test(apiKey)) { return sendError(res, 401, "AUTH_REQUIRED", "Missing or invalid API key."); } const matched = writeKeys.some((key) => safeEqual(key, apiKey)); if (!matched) { return sendError(res, 401, "AUTH_REQUIRED", "Missing or invalid API key."); } next(); } module.exports = { createCorsMiddleware, securityHeaders, enforceHttps, requireWriteApiKey, };