diff --git a/Overlord-Server/src/auth.ts b/Overlord-Server/src/auth.ts index c17dc0b..20f751d 100644 --- a/Overlord-Server/src/auth.ts +++ b/Overlord-Server/src/auth.ts @@ -7,12 +7,15 @@ import { type User, type UserRole, } from "./users"; +import { + persistRevokedToken, + isTokenRevoked, + pruneExpiredRevokedTokens, +} from "./db"; const JWT_ISSUER = "overlord-server"; const JWT_AUDIENCE = "overlord-client"; -const tokenBlacklist = new Set(); - const tokenCache = new Map< string, { payload: JWTPayload; timestamp: number } @@ -67,7 +70,7 @@ export async function generateToken(user: User): Promise { } export async function verifyToken(token: string): Promise { - if (tokenBlacklist.has(token)) { + if (isTokenRevoked(token)) { return null; } @@ -106,31 +109,22 @@ export async function verifyToken(token: string): Promise { } export function revokeToken(token: string): void { - tokenBlacklist.add(token); + let expiresAt = Math.floor(Date.now() / 1000) + getSessionTtlSeconds(); + try { + const parts = token.split("."); + if (parts.length === 3) { + const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString()); + if (typeof payload.exp === "number") expiresAt = payload.exp; + } + } catch { /* keep fallback */ } + + persistRevokedToken(token, expiresAt); tokenCache.delete(token); - console.log("[auth] Token revoked"); + console.log("[auth] Token revoked (persisted)"); } export async function cleanupBlacklist(): Promise { - const now = Math.floor(Date.now() / 1000); - let cleaned = 0; - - for (const token of tokenBlacklist) { - try { - const { payload } = await jwtVerify(token, getSecretKey(), { - issuer: JWT_ISSUER, - audience: JWT_AUDIENCE, - }); - - if (payload.exp && payload.exp < now) { - tokenBlacklist.delete(token); - cleaned++; - } - } catch { - tokenBlacklist.delete(token); - cleaned++; - } - } + const pruned = pruneExpiredRevokedTokens(); const cacheNow = Date.now(); let cacheCleared = 0; @@ -141,9 +135,9 @@ export async function cleanupBlacklist(): Promise { } } - if (cleaned > 0 || cacheCleared > 0) { + if (pruned > 0 || cacheCleared > 0) { console.log( - `[auth] Cleaned up ${cleaned} expired tokens from blacklist, ${cacheCleared} from cache`, + `[auth] Cleaned up ${pruned} expired tokens from blacklist, ${cacheCleared} from cache`, ); } } diff --git a/Overlord-Server/src/db.ts b/Overlord-Server/src/db.ts index 9585da2..44d90cc 100644 --- a/Overlord-Server/src/db.ts +++ b/Overlord-Server/src/db.ts @@ -97,6 +97,16 @@ db.run( `CREATE INDEX IF NOT EXISTS idx_banned_ips_created_at ON banned_ips(created_at DESC);`, ); +db.run(` + CREATE TABLE IF NOT EXISTS revoked_tokens ( + token_hash TEXT PRIMARY KEY, + expires_at INTEGER NOT NULL + ); +`); +db.run( + `CREATE INDEX IF NOT EXISTS idx_revoked_tokens_expires ON revoked_tokens(expires_at);`, +); + db.run(` CREATE TABLE IF NOT EXISTS builds ( id TEXT PRIMARY KEY, @@ -317,6 +327,41 @@ export function isIpBanned(ip: string): boolean { return !!row?.ip; } +// ── Revoked token persistence ── + +function hashToken(token: string): string { + return new Bun.CryptoHasher("sha256").update(token).digest("hex"); +} + +export function persistRevokedToken(token: string, expiresAt: number): void { + db.run( + `INSERT OR IGNORE INTO revoked_tokens (token_hash, expires_at) VALUES (?, ?)`, + hashToken(token), + expiresAt, + ); +} + +export function isTokenRevoked(token: string): boolean { + const row = db.query<{ token_hash: string }>( + `SELECT token_hash FROM revoked_tokens WHERE token_hash=?`, + ).get(hashToken(token)); + return !!row; +} + +export function loadAllRevokedTokenHashes(): Set { + const now = Math.floor(Date.now() / 1000); + const rows = db.query<{ token_hash: string }>( + `SELECT token_hash FROM revoked_tokens WHERE expires_at > ?`, + ).all(now); + return new Set(rows.map((r) => r.token_hash)); +} + +export function pruneExpiredRevokedTokens(): number { + const now = Math.floor(Date.now() / 1000); + const result = db.run(`DELETE FROM revoked_tokens WHERE expires_at <= ?`, now); + return result.changes; +} + export function markAllClientsOffline() { db.run(`UPDATE clients SET online=0`); console.log("[db] marked all clients as offline"); diff --git a/Overlord-Server/src/server/agent-auth.ts b/Overlord-Server/src/server/agent-auth.ts index 702fe64..3f9a639 100644 --- a/Overlord-Server/src/server/agent-auth.ts +++ b/Overlord-Server/src/server/agent-auth.ts @@ -1,4 +1,12 @@ import { logger } from "../logger"; +import { timingSafeEqual } from "crypto"; + +function safeCompare(a: string, b: string): boolean { + const bufA = Buffer.from(a); + const bufB = Buffer.from(b); + if (bufA.length !== bufB.length) return false; + return timingSafeEqual(bufA, bufB); +} export function isAuthorizedAgentRequest( req: Request, @@ -21,7 +29,9 @@ export function isAuthorizedAgentRequest( const headerToken = req.headers.get("x-agent-token"); const queryToken = url.searchParams.get("token"); - const isAuthed = headerToken === token || queryToken === token; + const isAuthed = + (headerToken !== null && safeCompare(headerToken, token)) || + (queryToken !== null && safeCompare(queryToken, token)); if (!isAuthed) { logger.info("[auth] Agent auth failed"); diff --git a/Overlord-Server/src/server/http-security.ts b/Overlord-Server/src/server/http-security.ts index b33b2ed..0e2f549 100644 --- a/Overlord-Server/src/server/http-security.ts +++ b/Overlord-Server/src/server/http-security.ts @@ -1,5 +1,5 @@ export const CORS_HEADERS = { - "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Origin": "null", "Access-Control-Allow-Methods": "GET,OPTIONS", "Access-Control-Allow-Headers": "Content-Type", }; @@ -10,4 +10,6 @@ export const SECURITY_HEADERS = { "X-Frame-Options": "DENY", "X-XSS-Protection": "1; mode=block", "Referrer-Policy": "strict-origin-when-cross-origin", + "Strict-Transport-Security": "max-age=63072000; includeSubDomains", + "Permissions-Policy": "camera=(), geolocation=(), payment=()", };