UNPKG

@lock-sdk/csrf

Version:

CSRF protection module for Lock security framework

614 lines (605 loc) 19.1 kB
"use strict"; var __create = Object.create; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __getProtoOf = Object.getPrototypeOf; var __hasOwnProp = Object.prototype.hasOwnProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( // If the importer is in node compatibility mode or this is not an ESM // file that has been converted to a CommonJS file using a Babel- // compatible transform (i.e. "__esModule" has not been set), then set // "default" to the CommonJS "module.exports" for node compatibility. isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, mod )); var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // src/index.ts var index_exports = {}; __export(index_exports, { CSRFEventType: () => CSRFEventType, MemoryStorage: () => MemoryStorage, RedisStorage: () => RedisStorage, createStorage: () => createStorage, csrfProtection: () => csrfProtection, csrfToken: () => csrfToken, extractFromBody: () => extractFromBody, extractFromCookie: () => extractFromCookie, extractFromHeader: () => extractFromHeader, extractFromQuery: () => extractFromQuery, extractFromSession: () => extractFromSession, extractToken: () => extractToken, generateSecret: () => generateSecret, generateToken: () => generateToken, generateTokenSync: () => generateTokenSync, hashToken: () => hashToken, parseCookies: () => parseCookies, secureCompare: () => secureCompare, validateToken: () => validateToken }); module.exports = __toCommonJS(index_exports); var import_core = require("@lock-sdk/core"); var import_core2 = require("@lock-sdk/core"); // src/types.ts var CSRFEventType = /* @__PURE__ */ ((CSRFEventType2) => { CSRFEventType2["CSRF_TOKEN_MISSING"] = "csrf.token.missing"; CSRFEventType2["CSRF_TOKEN_INVALID"] = "csrf.token.invalid"; CSRFEventType2["CSRF_DOUBLE_SUBMIT_FAILURE"] = "csrf.double.submit.failure"; CSRFEventType2["CSRF_VALIDATED"] = "csrf.validated"; CSRFEventType2["CSRF_ERROR"] = "csrf.error"; return CSRFEventType2; })(CSRFEventType || {}); // src/utils/token.ts var crypto = __toESM(require("crypto")); async function generateToken(length, identifier, storage, config) { const randomBytes2 = crypto.randomBytes(length); const token = randomBytes2.toString("base64").replace(/[^a-zA-Z0-9]/g, ""); await storage.saveToken(token, identifier, config.tokenTtl); return token; } async function validateToken(token, identifier, storage, config) { if (!token) { return false; } return await storage.validateToken(token, identifier); } function hashToken(token, secret, algorithm = "sha256") { return crypto.createHmac(algorithm, secret).update(token).digest("base64"); } function generateSecret() { return crypto.randomBytes(32).toString("hex"); } function generateTokenSync(length) { return crypto.randomBytes(length).toString("base64").replace(/[^a-zA-Z0-9]/g, ""); } function secureCompare(a, b) { if (a.length !== b.length) { return false; } return crypto.timingSafeEqual(Buffer.from(a, "utf8"), Buffer.from(b, "utf8")); } // src/utils/extract-token.ts function extractToken(req, config) { switch (config.tokenLocation) { case "header": return extractFromHeader(req, config); case "cookie": return extractFromCookie(req, config); case "cookie-header": return extractFromHeader(req, config) || extractFromCookie(req, config); case "session": return extractFromSession(req, config); default: return extractFromHeader(req, config) || extractFromBody(req, config) || extractFromQuery(req, config) || extractFromCookie(req, config) || extractFromSession(req, config); } } function extractFromHeader(req, config) { const headerName = config.headerName.toLowerCase(); if (req.headers && req.headers[headerName]) { return req.headers[headerName]; } if (config.angularCompatible && req.headers && req.headers["x-xsrf-token"]) { return req.headers["x-xsrf-token"]; } return null; } function extractFromCookie(req, config) { if (req.cookies && req.cookies[config.cookieName]) { return req.cookies[config.cookieName]; } if (req.headers && req.headers.cookie) { const cookies = parseCookies(req.headers.cookie); if (cookies[config.cookieName]) { return cookies[config.cookieName]; } } return null; } function extractFromBody(req, config) { if (req.body) { if (req.body[config.tokenName]) { return req.body[config.tokenName]; } if (req.body._csrf) { return req.body._csrf; } } return null; } function extractFromQuery(req, config) { if (req.query && req.query[config.tokenName]) { return req.query[config.tokenName]; } if (req.query && req.query._csrf) { return req.query._csrf; } return null; } function extractFromSession(req, config) { if (req.session) { if (req.session[config.tokenName]) { return req.session[config.tokenName]; } if (req.session._csrf) { return req.session._csrf; } } console.log("[CSRF] No token found in session"); return null; } function parseCookies(cookieString) { const cookies = {}; if (!cookieString) { return cookies; } cookieString.split(";").forEach((cookie) => { const parts = cookie.split("="); if (parts.length >= 2) { const name = parts.shift()?.trim(); const value = parts.join("=").trim(); if (name) { cookies[name] = value; } } }); return cookies; } // src/storage/memory.ts var MemoryStorage = class { constructor() { this.cleanupInterval = null; this.store = /* @__PURE__ */ new Map(); this.scheduleCleanup(); } async init() { } async saveToken(token, identifier, ttl) { const now = Date.now(); this.store.set(identifier, { token, createdAt: now, expiresAt: now + ttl * 1e3 }); } async getToken(identifier) { const record = this.store.get(identifier); if (!record) { return null; } if (record.expiresAt < Date.now()) { this.store.delete(identifier); return null; } return record.token; } async validateToken(token, identifier) { const storedToken = await this.getToken(identifier); if (!storedToken) { return false; } return secureCompare(token, storedToken); } async deleteToken(identifier) { this.store.delete(identifier); } async deleteExpiredTokens() { const now = Date.now(); for (const [identifier, record] of this.store.entries()) { if (record.expiresAt < now) { this.store.delete(identifier); } } } scheduleCleanup() { this.cleanupInterval = setInterval( () => { this.deleteExpiredTokens().catch((err) => { console.error("Error cleaning up expired CSRF tokens:", err); }); }, 15 * 60 * 1e3 ); process.on("beforeExit", () => { if (this.cleanupInterval) { clearInterval(this.cleanupInterval); this.cleanupInterval = null; } }); } }; // src/storage/redis.ts var RedisStorage = class { constructor(options = {}) { this.client = null; this.options = options; this.keyPrefix = options.keyPrefix || "csrf:"; this.externalClient = !!options.client; } async init() { if (this.client) { return; } if (this.options.client) { this.client = this.options.client; return; } try { const redis = await import("redis"); if (this.options.url) { this.client = redis.createClient({ url: this.options.url }); } else { this.client = redis.createClient({ socket: { host: this.options.host || "localhost", port: this.options.port || 6379 }, password: this.options.password, database: this.options.db || 0, username: this.options.username }); } this.client.on("error", (err) => { console.error("Redis error:", err); }); if (typeof this.client.connect === "function") { await this.client.connect(); } } catch (err) { console.error("Failed to initialize Redis client:", err); throw new Error("Failed to initialize Redis client for CSRF token storage"); } } async saveToken(token, identifier, ttl) { await this.ensureInitialized(); const key = this.getKey(identifier); if (this.isRedisV4()) { await this.client.set(key, token, { EX: ttl }); } else { await new Promise((resolve, reject) => { this.client.set(key, token, "EX", ttl, (err) => { if (err) reject(err); else resolve(); }); }); } } async getToken(identifier) { await this.ensureInitialized(); const key = this.getKey(identifier); if (this.isRedisV4()) { return await this.client.get(key); } else { return await new Promise((resolve, reject) => { this.client.get(key, (err, reply) => { if (err) reject(err); else resolve(reply); }); }); } } async validateToken(token, identifier) { const storedToken = await this.getToken(identifier); if (!storedToken) { return false; } return secureCompare(token, storedToken); } async deleteToken(identifier) { await this.ensureInitialized(); const key = this.getKey(identifier); if (this.isRedisV4()) { await this.client.del(key); } else { await new Promise((resolve, reject) => { this.client.del(key, (err) => { if (err) reject(err); else resolve(); }); }); } } async deleteExpiredTokens() { } async ensureInitialized() { if (!this.client) { await this.init(); } } getKey(identifier) { return `${this.keyPrefix}${identifier}`; } isRedisV4() { return typeof this.client.get === "function" && this.client.get.constructor.name === "AsyncFunction"; } async close() { if (this.client && !this.externalClient) { if (typeof this.client.quit === "function") { await this.client.quit(); } else if (typeof this.client.disconnect === "function") { await this.client.disconnect(); } this.client = null; } } }; // src/storage/index.ts var STORAGE_INSTANCES = /* @__PURE__ */ new Map(); function createStorage(config) { const key = `storage:${config.storage}`; if (STORAGE_INSTANCES.has(key)) { return STORAGE_INSTANCES.get(key); } let storage; switch (config.storage) { case "redis": storage = new RedisStorage(config.redisOptions); break; case "memory": default: storage = new MemoryStorage(); } STORAGE_INSTANCES.set(key, storage); return storage; } // src/index.ts var DEFAULT_CONFIG = { enabled: true, tokenName: "csrf-token", tokenLength: 32, headerName: "x-csrf-token", cookieName: "csrf-token", cookieOptions: { httpOnly: false, secure: true, sameSite: "lax", path: "/" }, storage: "memory", tokenLocation: "cookie-header", ignoredMethods: ["GET", "HEAD", "OPTIONS"], ignoredPaths: [], ignoredContentTypes: ["multipart/form-data"], failureStatusCode: 403, failureMessage: "CSRF token validation failed", refreshToken: true, tokenTtl: 86400, doubleSubmit: true, samesite: true }; var csrfProtection = (0, import_core.createModule)({ name: "csrf-protection", defaultConfig: DEFAULT_CONFIG, async check(context, config) { try { if (!config.enabled) { return { passed: true }; } const req = context.request; const res = context.response; const method = req.method?.toUpperCase() || ""; if (config.ignoredMethods.includes(method)) { if (method === "GET" && config.refreshToken) { await setCSRFToken(context, config); } return { passed: true }; } const path = req.path || req.url || ""; for (const ignoredPath of config.ignoredPaths) { if (typeof ignoredPath === "string" && path === ignoredPath) { return { passed: true }; } else if (ignoredPath instanceof RegExp && ignoredPath.test(path)) { return { passed: true }; } } const contentType = req.headers?.["content-type"] || ""; for (const ignoredType of config.ignoredContentTypes) { if (contentType.toLowerCase().includes(ignoredType.toLowerCase())) { return { passed: true }; } } const storage = createStorage(config); const token = extractToken(req, config); if (!token) { return { passed: false, reason: "csrf.token.missing" /* CSRF_TOKEN_MISSING */, data: { path, method }, severity: "medium" }; } const sessionIdentifier = getSessionIdentifier(req, config); const isValid = await validateToken(token, sessionIdentifier, storage, config); if (!isValid) { return { passed: false, reason: "csrf.token.invalid" /* CSRF_TOKEN_INVALID */, data: { token, path, method }, severity: "medium" }; } if (config.doubleSubmit && config.tokenLocation === "cookie-header") { const cookieToken = extractCSRFCookie(req, config); if (!cookieToken || cookieToken !== token) { return { passed: false, reason: "csrf.double.submit.failure" /* CSRF_DOUBLE_SUBMIT_FAILURE */, data: { headerToken: token, cookieToken, path, method }, severity: "medium" }; } } if (config.refreshToken) { await setCSRFToken(context, config); } return { passed: true, reason: "csrf.validated" /* CSRF_VALIDATED */, data: { path, method }, severity: "low" }; } catch (error) { console.error(`CSRF protection error: ${error.message}`); return { passed: false, reason: "csrf.error" /* CSRF_ERROR */, data: { error: error.message }, severity: "medium" }; } }, async handleFailure(context, reason, data) { const config = context.data.get("csrf-protection:config"); const res = context.response; if (res.headersSent || res.writableEnded) { return; } let message = config.failureMessage; if (reason === "csrf.token.missing" /* CSRF_TOKEN_MISSING */) { message = "CSRF token missing"; } else if (reason === "csrf.token.invalid" /* CSRF_TOKEN_INVALID */) { message = "CSRF token invalid"; } else if (reason === "csrf.double.submit.failure" /* CSRF_DOUBLE_SUBMIT_FAILURE */) { message = "CSRF token mismatch between cookie and header"; } if (typeof res.status === "function") { return res.status(config.failureStatusCode).json({ error: message, blocked: true }); } else if (typeof res.statusCode === "number") { res.statusCode = config.failureStatusCode; res.setHeader("Content-Type", "application/json"); return res.end( JSON.stringify({ error: message, blocked: true }) ); } } }); function extractCSRFCookie(req, config) { const cookies = req.cookies || parseCookies2(req.headers?.cookie || ""); return cookies[config.cookieName] || null; } function parseCookies2(cookieString) { const cookies = {}; cookieString.split(";").forEach((cookie) => { const [name, value] = cookie.split("=").map((c) => c.trim()); if (name && value) cookies[name] = value; }); return cookies; } function getSessionIdentifier(req, config) { if (req.session?.id) { return req.session.id; } const ip = req.ip || req.connection?.remoteAddress || ""; const userAgent = req.headers?.["user-agent"] || ""; return `${ip}:${userAgent}`; } async function setCSRFToken(context, config) { const req = context.request; const res = context.response; const storage = createStorage(config); const sessionIdentifier = getSessionIdentifier(req, config); const token = await generateToken(config.tokenLength, sessionIdentifier, storage, config); if (config.tokenLocation === "cookie" || config.tokenLocation === "cookie-header") { if (typeof res.cookie === "function") { res.cookie(config.cookieName, token, config.cookieOptions); } else { const cookieOptions = []; cookieOptions.push(`${config.cookieName}=${token}`); if (config.cookieOptions.httpOnly) cookieOptions.push("HttpOnly"); if (config.cookieOptions.secure) cookieOptions.push("Secure"); if (config.cookieOptions.sameSite) cookieOptions.push(`SameSite=${config.cookieOptions.sameSite}`); if (config.cookieOptions.path) cookieOptions.push(`Path=${config.cookieOptions.path}`); if (config.cookieOptions.domain) cookieOptions.push(`Domain=${config.cookieOptions.domain}`); if (config.cookieOptions.maxAge) cookieOptions.push(`Max-Age=${config.cookieOptions.maxAge}`); const cookieString = cookieOptions.join("; "); res.setHeader("Set-Cookie", cookieString); } } if (config.tokenLocation === "header" || config.tokenLocation === "cookie-header") { res.setHeader(config.headerName, token); } if (config.tokenLocation === "session" && req.session) { req.session[config.tokenName] = token; } if (res.locals) { res.locals[config.tokenName] = token; } } function csrfToken(config = {}) { const finalConfig = { ...DEFAULT_CONFIG, ...config }; return async (req, res, next) => { try { const context = { request: req, response: res, data: /* @__PURE__ */ new Map() }; await setCSRFToken(context, finalConfig); next(); } catch (error) { console.error("Error setting CSRF token:", error); next(error); } }; } (0, import_core2.registerModule)("csrfProtection", csrfProtection); // Annotate the CommonJS export names for ESM import in node: 0 && (module.exports = { CSRFEventType, MemoryStorage, RedisStorage, createStorage, csrfProtection, csrfToken, extractFromBody, extractFromCookie, extractFromHeader, extractFromQuery, extractFromSession, extractToken, generateSecret, generateToken, generateTokenSync, hashToken, parseCookies, secureCompare, validateToken });