uploaded
This commit is contained in:
66
server/middleware/auth.js
Normal file
66
server/middleware/auth.js
Normal file
@@ -0,0 +1,66 @@
|
||||
function requireAdminAuth(req, res, next) {
|
||||
const adminKey = process.env.ADMIN_API_KEY;
|
||||
|
||||
if (!adminKey) {
|
||||
console.error('ADMIN_API_KEY is not configured');
|
||||
return res.status(500).json({
|
||||
error: 'Admin API key not configured',
|
||||
});
|
||||
}
|
||||
|
||||
// Get the API key from Authorization header or query param
|
||||
const authHeader = req.get('Authorization') || '';
|
||||
const queryKey = req.query.key;
|
||||
const providedKey = authHeader.replace(/^Bearer\s+/i, '') || queryKey;
|
||||
|
||||
if (!providedKey) {
|
||||
return res.status(401).json({
|
||||
error: 'Missing API key',
|
||||
message: 'Authorization header with Bearer token or ?key parameter is required',
|
||||
});
|
||||
}
|
||||
|
||||
// Never expose the actual key in logs
|
||||
if (providedKey !== adminKey) {
|
||||
console.warn('Failed admin authentication attempt from IP:', req.ip);
|
||||
return res.status(403).json({
|
||||
error: 'Invalid API key',
|
||||
message: 'The provided API key is invalid',
|
||||
});
|
||||
}
|
||||
|
||||
// Mark request as authenticated
|
||||
req.isAdmin = true;
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional admin auth middleware
|
||||
* Allows requests to proceed but marks if authenticated
|
||||
*/
|
||||
function optionalAdminAuth(req, res, next) {
|
||||
const adminKey = process.env.ADMIN_API_KEY;
|
||||
|
||||
if (!adminKey) {
|
||||
req.isAdmin = false;
|
||||
return next();
|
||||
}
|
||||
|
||||
const authHeader = req.get('Authorization') || '';
|
||||
const queryKey = req.query.key;
|
||||
const providedKey = authHeader.replace(/^Bearer\s+/i, '') || queryKey;
|
||||
|
||||
if (providedKey && providedKey === adminKey) {
|
||||
req.isAdmin = true;
|
||||
} else {
|
||||
req.isAdmin = false;
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
requireAdminAuth,
|
||||
optionalAdminAuth,
|
||||
};
|
||||
|
||||
111
server/middleware/rate-limit.js
Normal file
111
server/middleware/rate-limit.js
Normal file
@@ -0,0 +1,111 @@
|
||||
const crypto = require("crypto");
|
||||
const { sendError } = require("../utils/http");
|
||||
|
||||
function normalizeIp(req) {
|
||||
return String(req.socket?.remoteAddress || req.ip || "unknown").trim();
|
||||
}
|
||||
|
||||
function findUserIdentifier(req) {
|
||||
const candidates = [
|
||||
req.params?.username,
|
||||
req.params?.gamemode,
|
||||
req.body?.username,
|
||||
req.body?.ingame_name,
|
||||
req.query?.q,
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (candidate == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalized = String(candidate).trim().toLowerCase();
|
||||
if (normalized) {
|
||||
return normalized.slice(0, 64);
|
||||
}
|
||||
}
|
||||
|
||||
return "anonymous";
|
||||
}
|
||||
|
||||
function buildBucketKey(raw) {
|
||||
return crypto.createHash("sha256").update(String(raw)).digest("hex");
|
||||
}
|
||||
|
||||
function createRateLimiter({ name, windowMs, max, keyFn }) {
|
||||
const buckets = new Map();
|
||||
|
||||
function cleanup(now) {
|
||||
for (const [key, bucket] of buckets.entries()) {
|
||||
if (bucket.resetAt <= now) {
|
||||
buckets.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return function rateLimitMiddleware(req, res, next) {
|
||||
const now = Date.now();
|
||||
const rawKey = keyFn(req);
|
||||
const key = buildBucketKey(`${name}:${rawKey}`);
|
||||
const current = buckets.get(key);
|
||||
|
||||
if (!current || current.resetAt <= now) {
|
||||
buckets.set(key, { count: 1, resetAt: now + windowMs });
|
||||
|
||||
if (buckets.size > 5000) {
|
||||
cleanup(now);
|
||||
}
|
||||
|
||||
return next();
|
||||
}
|
||||
|
||||
if (current.count >= max) {
|
||||
const retryAfterSeconds = Math.max(Math.ceil((current.resetAt - now) / 1000), 1);
|
||||
res.setHeader("Retry-After", String(retryAfterSeconds));
|
||||
res.setHeader("X-RateLimit-Limit", String(max));
|
||||
res.setHeader("X-RateLimit-Remaining", "0");
|
||||
res.setHeader("X-RateLimit-Reset", String(Math.ceil(current.resetAt / 1000)));
|
||||
|
||||
return sendError(
|
||||
res,
|
||||
429,
|
||||
"RATE_LIMITED",
|
||||
"Too many requests. Please retry later.",
|
||||
{
|
||||
limiter: name,
|
||||
retryAfterSeconds,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
current.count += 1;
|
||||
const remaining = Math.max(max - current.count, 0);
|
||||
res.setHeader("X-RateLimit-Limit", String(max));
|
||||
res.setHeader("X-RateLimit-Remaining", String(remaining));
|
||||
res.setHeader("X-RateLimit-Reset", String(Math.ceil(current.resetAt / 1000)));
|
||||
|
||||
return next();
|
||||
};
|
||||
}
|
||||
|
||||
function createApiRateLimiters({ ipMax = 60, userMax = 60, windowMs = 60 * 1000 }) {
|
||||
const byIp = createRateLimiter({
|
||||
name: "ip",
|
||||
windowMs,
|
||||
max: ipMax,
|
||||
keyFn: (req) => normalizeIp(req),
|
||||
});
|
||||
|
||||
const byUser = createRateLimiter({
|
||||
name: "user",
|
||||
windowMs,
|
||||
max: userMax,
|
||||
keyFn: (req) => `${normalizeIp(req)}:${findUserIdentifier(req)}`,
|
||||
});
|
||||
|
||||
return [byIp, byUser];
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createApiRateLimiters,
|
||||
};
|
||||
11
server/middleware/rateLimiter.js
Normal file
11
server/middleware/rateLimiter.js
Normal file
@@ -0,0 +1,11 @@
|
||||
const { createApiRateLimiters } = require("./rate-limit");
|
||||
|
||||
function apiRateLimiter(options = {}) {
|
||||
const [byIp] = createApiRateLimiters(options);
|
||||
return byIp;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
apiRateLimiter,
|
||||
createApiRateLimiters,
|
||||
};
|
||||
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,
|
||||
};
|
||||
15
server/middleware/securityHeaders.js
Normal file
15
server/middleware/securityHeaders.js
Normal file
@@ -0,0 +1,15 @@
|
||||
const { securityHeaders, enforceHttps, createCorsMiddleware, requireWriteApiKey } = require("./security");
|
||||
|
||||
function configureSecurityHeaders(app, _isDevelopment = false) {
|
||||
if (!app || typeof app.use !== "function") return;
|
||||
app.use(securityHeaders);
|
||||
app.use(enforceHttps);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
configureSecurityHeaders,
|
||||
securityHeaders,
|
||||
enforceHttps,
|
||||
createCorsMiddleware,
|
||||
requireWriteApiKey,
|
||||
};
|
||||
137
server/middleware/validation.js
Normal file
137
server/middleware/validation.js
Normal file
@@ -0,0 +1,137 @@
|
||||
class ValidationError extends Error {
|
||||
constructor(message, details = []) {
|
||||
super(message);
|
||||
this.name = "ValidationError";
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
function isObject(value) {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function normalizeString(input, rule = {}) {
|
||||
let value = String(input);
|
||||
|
||||
if (rule.trim !== false) {
|
||||
value = value.trim();
|
||||
}
|
||||
|
||||
if (rule.toLowerCase) {
|
||||
value = value.toLowerCase();
|
||||
}
|
||||
|
||||
if (rule.toUpperCase) {
|
||||
value = value.toUpperCase();
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function validateField(value, key, rule, errors) {
|
||||
if (value == null) {
|
||||
if (rule.required) {
|
||||
errors.push({ field: key, issue: "required" });
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (rule.type === "string") {
|
||||
const normalized = normalizeString(value, rule);
|
||||
|
||||
if (rule.minLength != null && normalized.length < rule.minLength) {
|
||||
errors.push({ field: key, issue: "minLength", expected: rule.minLength });
|
||||
}
|
||||
|
||||
if (rule.maxLength != null && normalized.length > rule.maxLength) {
|
||||
errors.push({ field: key, issue: "maxLength", expected: rule.maxLength });
|
||||
}
|
||||
|
||||
if (rule.pattern && !rule.pattern.test(normalized)) {
|
||||
errors.push({ field: key, issue: "pattern" });
|
||||
}
|
||||
|
||||
if (rule.enum && !rule.enum.includes(normalized)) {
|
||||
errors.push({ field: key, issue: "enum", expected: rule.enum });
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
if (rule.type === "int") {
|
||||
const asString = String(value).trim();
|
||||
if (!/^-?\d+$/.test(asString)) {
|
||||
errors.push({ field: key, issue: "type", expected: "int" });
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(asString, 10);
|
||||
|
||||
if (!Number.isSafeInteger(parsed)) {
|
||||
errors.push({ field: key, issue: "type", expected: "safe-int" });
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (rule.min != null && parsed < rule.min) {
|
||||
errors.push({ field: key, issue: "min", expected: rule.min });
|
||||
}
|
||||
|
||||
if (rule.max != null && parsed > rule.max) {
|
||||
errors.push({ field: key, issue: "max", expected: rule.max });
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
errors.push({ field: key, issue: "unsupported-rule" });
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function validateObject(payload, schema, locationName) {
|
||||
const target = payload == null ? {} : payload;
|
||||
const errors = [];
|
||||
|
||||
if (!isObject(target)) {
|
||||
throw new ValidationError(`Invalid ${locationName}. Expected an object.`, [
|
||||
{ field: locationName, issue: "type", expected: "object" },
|
||||
]);
|
||||
}
|
||||
|
||||
const output = {};
|
||||
const fields = schema.fields || {};
|
||||
const allowUnknown = schema.allowUnknown === true;
|
||||
|
||||
if (!allowUnknown) {
|
||||
for (const key of Object.keys(target)) {
|
||||
if (!Object.prototype.hasOwnProperty.call(fields, key)) {
|
||||
errors.push({ field: key, issue: "unknown" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, rule] of Object.entries(fields)) {
|
||||
const validated = validateField(target[key], key, rule, errors);
|
||||
if (validated !== undefined) {
|
||||
output[key] = validated;
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new ValidationError(`Invalid ${locationName}.`, errors);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function validateRequest(req, spec = {}) {
|
||||
return {
|
||||
params: spec.params ? validateObject(req.params, spec.params, "path parameters") : {},
|
||||
query: spec.query ? validateObject(req.query, spec.query, "query parameters") : {},
|
||||
body: spec.body ? validateObject(req.body, spec.body, "request body") : {},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ValidationError,
|
||||
validateRequest,
|
||||
};
|
||||
Reference in New Issue
Block a user