feat: Mejorar la seguridad de las sesiones mediante la firma de cookies y la validación de SID

This commit is contained in:
Shni
2025-10-14 22:42:51 -05:00
parent 69653b38ad
commit f7c68edacc

View File

@@ -1,7 +1,7 @@
import { createServer, IncomingMessage, ServerResponse } from "node:http"; import { createServer, IncomingMessage, ServerResponse } from "node:http";
import { promises as fs } from "node:fs"; import { promises as fs } from "node:fs";
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { createHash } from "node:crypto"; import { createHash, createHmac, timingSafeEqual } from "node:crypto";
import { import {
gzipSync, gzipSync,
brotliCompressSync, brotliCompressSync,
@@ -73,14 +73,54 @@ function parseCookies(req: IncomingMessage) {
} }
function setSessionCookie(res: ServerResponse, sid: string) { function setSessionCookie(res: ServerResponse, sid: string) {
// Sign the SID to prevent tampering
const secret = getSessionSecret();
const sig = createHmac("sha256", secret).update(sid).digest("base64url");
const token = `${sid}.${sig}`;
const isProd = process.env.NODE_ENV === "production";
const maxAge = 60 * 60 * 24 * 7; // 7 days
const sameSite = isProd ? "Lax" : "Lax"; // Lax works for OAuth redirects in most cases
const secure = isProd ? "; Secure" : "";
const cookie = `amayo_sid=${encodeURIComponent( const cookie = `amayo_sid=${encodeURIComponent(
sid token
)}; HttpOnly; Path=/; Max-Age=${60 * 60 * 24 * 7}`; // 7 days )}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=${sameSite}${secure}`;
res.setHeader("Set-Cookie", cookie); res.setHeader("Set-Cookie", cookie);
} }
function clearSessionCookie(res: ServerResponse) { function clearSessionCookie(res: ServerResponse) {
res.setHeader("Set-Cookie", `amayo_sid=; HttpOnly; Path=/; Max-Age=0`); const isProd = process.env.NODE_ENV === "production";
const secure = isProd ? "; Secure" : "";
res.setHeader(
"Set-Cookie",
`amayo_sid=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax${secure}`
);
}
function getSessionSecret() {
return (
process.env.SESSION_SECRET ||
process.env.DISCORD_CLIENT_SECRET ||
"dev-session-secret"
);
}
function unsignSid(signed: string | undefined): string | null {
if (!signed) return null;
const decoded = decodeURIComponent(signed);
const parts = decoded.split(".");
if (parts.length !== 2) return null;
const [sid, sig] = parts;
try {
const secret = getSessionSecret();
const expected = createHmac("sha256", secret).update(sid).digest();
const got = Buffer.from(sig, "base64url");
// timing safe compare
if (expected.length !== got.length) return null;
if (!timingSafeEqual(expected, got)) return null;
return sid;
} catch {
return null;
}
} }
function storeState(state: string) { function storeState(state: string) {
@@ -626,7 +666,7 @@ export const server = createServer(
`http://${req.headers.host}/auth/callback`; `http://${req.headers.host}/auth/callback`;
const state = randomUUID(); const state = randomUUID();
// Store state in a temp session map // Store state in a temp session map
SESSIONS.set(state, { ts: Date.now() }); storeState(state);
const scopes = encodeURIComponent("identify guilds"); const scopes = encodeURIComponent("identify guilds");
const urlAuth = `https://discord.com/api/oauth2/authorize?response_type=code&client_id=${encodeURIComponent( const urlAuth = `https://discord.com/api/oauth2/authorize?response_type=code&client_id=${encodeURIComponent(
clientId clientId
@@ -644,7 +684,7 @@ export const server = createServer(
const qs = Object.fromEntries(url.searchParams.entries()); const qs = Object.fromEntries(url.searchParams.entries());
const { code, state } = qs as any; const { code, state } = qs as any;
// Validate state // Validate state
if (!state || !SESSIONS.has(state)) { if (!state || !hasState(state)) {
res.writeHead(400, applySecurityHeadersForRequest(req)); res.writeHead(400, applySecurityHeadersForRequest(req));
return res.end("Invalid OAuth state"); return res.end("Invalid OAuth state");
} }
@@ -723,16 +763,19 @@ export const server = createServer(
name: sanitizeString(g.name ?? g.id, { max: 100 }), name: sanitizeString(g.name ?? g.id, { max: 100 }),
})); }));
const sid = randomUUID(); // create session with tokens to allow refresh
const session = { const now = Date.now();
const sid = createSession({
user: { user: {
id: uid, id: uid,
username: uname, username: uname,
avatar: uavatar, avatar: uavatar,
}, },
guilds: safeGuilds, guilds: safeGuilds,
}; access_token: tokenJson.access_token,
SESSIONS.set(sid, session); refresh_token: tokenJson.refresh_token,
expires_at: now + Number(tokenJson.expires_in || 3600) * 1000,
});
setSessionCookie(res, sid); setSessionCookie(res, sid);
res.writeHead( res.writeHead(
302, 302,
@@ -748,7 +791,8 @@ export const server = createServer(
if (url.pathname === "/auth/logout") { if (url.pathname === "/auth/logout") {
const cookies = parseCookies(req); const cookies = parseCookies(req);
const sid = cookies["amayo_sid"]; const signed = cookies["amayo_sid"];
const sid = unsignSid(signed);
if (sid) SESSIONS.delete(sid); if (sid) SESSIONS.delete(sid);
clearSessionCookie(res); clearSessionCookie(res);
res.writeHead( res.writeHead(
@@ -764,7 +808,8 @@ export const server = createServer(
url.pathname.startsWith("/dashboard") url.pathname.startsWith("/dashboard")
) { ) {
const cookies = parseCookies(req); const cookies = parseCookies(req);
const sid = cookies["amayo_sid"]; const signed = cookies["amayo_sid"];
const sid = unsignSid(signed);
const session = sid ? SESSIONS.get(sid) : null; const session = sid ? SESSIONS.get(sid) : null;
const user = session?.user ?? null; const user = session?.user ?? null;
@@ -776,6 +821,14 @@ export const server = createServer(
return; return;
} }
// Touch and refresh session tokens as user is active
try {
await refreshAccessTokenIfNeeded(session);
} catch (err) {
console.warn("refreshAccessTokenIfNeeded failed", err);
}
touchSession(sid!);
// Simple guild list: for demo, fetch guilds where guild.staff contains user.id (not implemented fully) // Simple guild list: for demo, fetch guilds where guild.staff contains user.id (not implemented fully)
const guilds = await (async () => { const guilds = await (async () => {
try { try {