@lock-sdk/csrf
Version:
CSRF protection module for Lock security framework
614 lines (605 loc) • 19.1 kB
JavaScript
"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
});