mirror of
https://github.com/iamlukethedev/Claw3D.git
synced 2026-07-30 11:12:32 +00:00
next.config.ts: - unsafe-eval removed from production script-src (Next.js dev/HMR needs it, but production build does not; React and Three.js make no use of eval) - connect-src intentionally kept broad with note: gateway URLs are user-configured at runtime, cannot be enumerated at build time server/access-gate.js: - Add resolveClientIp() helper: when TRUSTED_PROXY=1 env var is set, prefer the first value of X-Forwarded-For for rate-limiter keying (correct behavior behind nginx/Caddy/Vercel edge). Without the flag, remoteAddress is used (safe default for direct exposure — prevents X-Forwarded-For spoofing by untrusted clients). Authored-By: GSKNNFT
144 lines
4.4 KiB
JavaScript
144 lines
4.4 KiB
JavaScript
const crypto = require("node:crypto");
|
|
|
|
|
|
const parseCookies = (header) => {
|
|
const raw = typeof header === "string" ? header : "";
|
|
if (!raw.trim()) return {};
|
|
const out = {};
|
|
for (const part of raw.split(";")) {
|
|
const idx = part.indexOf("=");
|
|
if (idx === -1) continue;
|
|
const key = part.slice(0, idx).trim();
|
|
const value = part.slice(idx + 1).trim();
|
|
if (!key) continue;
|
|
out[key] = value;
|
|
}
|
|
return out;
|
|
};
|
|
|
|
/** Constant-time string comparison to prevent timing attacks. */
|
|
const safeCompare = (a, b) => {
|
|
if (typeof a !== "string" || typeof b !== "string") return false;
|
|
const bufA = Buffer.from(a, "utf8");
|
|
const bufB = Buffer.from(b, "utf8");
|
|
if (bufA.length !== bufB.length) {
|
|
// Compare against self to burn constant time, then return false
|
|
crypto.timingSafeEqual(bufA, bufA);
|
|
return false;
|
|
}
|
|
return crypto.timingSafeEqual(bufA, bufB);
|
|
};
|
|
|
|
/** Simple in-memory rate limiter for auth attempts. */
|
|
const createRateLimiter = (maxAttempts = 10, windowMs = 60_000) => {
|
|
const attempts = new Map();
|
|
const cleanup = setInterval(() => {
|
|
const now = Date.now();
|
|
for (const [key, entry] of attempts) {
|
|
if (now - entry.start > windowMs) attempts.delete(key);
|
|
}
|
|
}, windowMs);
|
|
cleanup.unref();
|
|
|
|
return {
|
|
isLimited(ip) {
|
|
const entry = attempts.get(ip);
|
|
if (!entry) return false;
|
|
return entry.count >= maxAttempts;
|
|
},
|
|
recordFailure(ip) {
|
|
const now = Date.now();
|
|
const entry = attempts.get(ip);
|
|
if (!entry || now - entry.start > windowMs) {
|
|
attempts.set(ip, { count: 1, start: now });
|
|
return;
|
|
}
|
|
entry.count++;
|
|
},
|
|
reset(ip) {
|
|
attempts.delete(ip);
|
|
},
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Resolve client IP for rate limiting.
|
|
* When TRUSTED_PROXY=1 is set, the first value of X-Forwarded-For is used.
|
|
* Only set TRUSTED_PROXY=1 when this server sits behind a reverse proxy that
|
|
* you control (nginx, Caddy, Vercel edge). Without it, X-Forwarded-For is
|
|
* ignored to prevent spoofing by direct clients.
|
|
*/
|
|
const resolveClientIp = (req) => {
|
|
if (process.env.TRUSTED_PROXY === "1") {
|
|
const forwarded = req.headers?.["x-forwarded-for"];
|
|
if (typeof forwarded === "string") {
|
|
const first = forwarded.split(",")[0]?.trim();
|
|
if (first) return first;
|
|
}
|
|
}
|
|
return req.socket?.remoteAddress || "unknown";
|
|
};
|
|
|
|
function createAccessGate(options) {
|
|
const token = String(options?.token ?? "").trim();
|
|
const cookieName = String(options?.cookieName ?? "studio_access").trim() || "studio_access";
|
|
|
|
const enabled = Boolean(token);
|
|
const rateLimiter = createRateLimiter(10, 60_000);
|
|
|
|
const getAuthState = (req) => {
|
|
if (!enabled) return { authorized: true, limited: false };
|
|
const ip = resolveClientIp(req);
|
|
const cookieHeader = req.headers?.cookie;
|
|
const cookies = parseCookies(cookieHeader);
|
|
const authorized = safeCompare(cookies[cookieName] || "", token);
|
|
if (authorized) {
|
|
rateLimiter.reset(ip);
|
|
return { authorized: true, limited: false };
|
|
}
|
|
if (rateLimiter.isLimited(ip)) {
|
|
return { authorized: false, limited: true };
|
|
}
|
|
rateLimiter.recordFailure(ip);
|
|
return { authorized: false, limited: rateLimiter.isLimited(ip) };
|
|
};
|
|
|
|
const handleHttp = (req, res) => {
|
|
if (!enabled) return false;
|
|
const auth = getAuthState(req);
|
|
if (!auth.authorized) {
|
|
const statusCode = auth.limited ? 429 : 401;
|
|
if (String(req.url || "/").startsWith("/api/")) {
|
|
res.statusCode = statusCode;
|
|
res.setHeader("Content-Type", "application/json");
|
|
res.end(
|
|
JSON.stringify({
|
|
error: auth.limited
|
|
? "Too many failed studio access attempts. Wait a minute and retry."
|
|
: "Studio access token required. Send the configured Studio access cookie and retry.",
|
|
})
|
|
);
|
|
} else {
|
|
res.statusCode = statusCode;
|
|
res.setHeader("Content-Type", "text/plain");
|
|
res.end(
|
|
auth.limited
|
|
? "Too many failed studio access attempts. Wait a minute and retry."
|
|
: "Studio access token required. Set the studio_access cookie to access this page."
|
|
);
|
|
}
|
|
return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const allowUpgrade = (req) => {
|
|
if (!enabled) return true;
|
|
return getAuthState(req).authorized;
|
|
};
|
|
|
|
return { enabled, handleHttp, allowUpgrade };
|
|
}
|
|
|
|
module.exports = { createAccessGate };
|