uploaded
This commit is contained in:
156
server/middleware/security.js
Normal file
156
server/middleware/security.js
Normal file
@@ -0,0 +1,156 @@
|
||||
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,
|
||||
};
|
||||
Reference in New Issue
Block a user