UNPKG

@sun-asterisk/sunlint

Version:

☀️ SunLint - Multi-language static analysis tool for code quality and security | Sun* Engineering Standards

212 lines (185 loc) 7.2 kB
/** * Custom ESLint rule: S041 – Require session invalidation on logout * Rule ID: custom/s041 * Purpose: Ensure logout handlers properly invalidate session tokens and clear cookies * OWASP 3.3.1: Verify that logout and expiration invalidate the session token */ "use strict"; module.exports = { meta: { type: "problem", docs: { description: "Ensure logout handlers properly invalidate session tokens and prevent session reuse", recommended: true, }, schema: [], messages: { missingSessionInvalidation: "Logout method '{{method}}' must invalidate session token. Use session.invalidate(), req.session.destroy(), or equivalent session cleanup.", missingCookieClear: "Logout method '{{method}}' should clear authentication cookies to prevent session reuse.", missingCacheControl: "Logout method '{{method}}' should set cache-control headers to prevent back button authentication.", }, }, create(context) { // Keywords that indicate logout functionality const logoutKeywords = [ "logout", "signout", "sign-out", "logoff", "signoff", "disconnect", "terminate", "exit", "end-session" ]; // Session invalidation methods const sessionInvalidationMethods = [ "invalidate", "destroy", "remove", "clear", "delete", "expire", "revoke", "blacklist" ]; // Cookie clearing methods const cookieClearMethods = [ "clearCookie", "removeCookie", "deleteCookie", "expireCookie" ]; // Cache control methods const cacheControlMethods = [ "setHeader", "header", "set", "no-cache", "no-store" ]; function isLogoutMethod(name) { if (!name) return false; const lowerName = name.toLowerCase(); return logoutKeywords.some(keyword => lowerName.includes(keyword)); } function checkLogoutMethodBody(node, methodName) { let hasSessionInvalidation = false; let hasCookieClearing = false; let hasCacheControl = false; function checkNode(n, visited = new Set()) { if (!n || visited.has(n)) return; visited.add(n); // Check for session invalidation if (n.type === "CallExpression") { const callee = n.callee; // session.invalidate(), req.session.destroy(), etc. if (callee.type === "MemberExpression") { const property = callee.property.name; const object = callee.object; if (sessionInvalidationMethods.includes(property)) { // Check if it's session-related: session.invalidate(), req.session.destroy() if (object.type === "Identifier" && object.name === "session") { hasSessionInvalidation = true; } else if (object.type === "MemberExpression" && object.property && object.property.name === "session") { hasSessionInvalidation = true; } } // Check for cookie clearing: res.clearCookie() if (cookieClearMethods.includes(property)) { hasCookieClearing = true; } // Check for cache control headers if (cacheControlMethods.includes(property)) { const args = n.arguments; if (args.length > 0) { const firstArg = args[0]; if (firstArg.type === "Literal") { const header = firstArg.value; if (typeof header === "string" && (header.toLowerCase().includes("cache-control") || header.toLowerCase().includes("pragma"))) { hasCacheControl = true; } } } } } } // Recursively check specific node types to avoid infinite loops const nodeTypesToCheck = [ 'BlockStatement', 'ExpressionStatement', 'CallExpression', 'MemberExpression', 'ArrowFunctionExpression', 'FunctionExpression' ]; for (const key in n) { if (n[key] && typeof n[key] === "object" && key !== 'parent') { if (Array.isArray(n[key])) { n[key].forEach(child => { if (child && child.type && nodeTypesToCheck.includes(child.type)) { checkNode(child, visited); } }); } else if (n[key].type && nodeTypesToCheck.includes(n[key].type)) { checkNode(n[key], visited); } } } } // Check method body if (node.body) { checkNode(node.body); } // Report missing requirements if (!hasSessionInvalidation) { context.report({ node, messageId: "missingSessionInvalidation", data: { method: methodName } }); } if (!hasCookieClearing) { context.report({ node, messageId: "missingCookieClear", data: { method: methodName } }); } if (!hasCacheControl) { context.report({ node, messageId: "missingCacheControl", data: { method: methodName } }); } } return { // Check class methods (NestJS controllers) MethodDefinition(node) { const methodName = node.key.name; if (isLogoutMethod(methodName)) { checkLogoutMethodBody(node.value, methodName); } }, // Check function declarations FunctionDeclaration(node) { const functionName = node.id?.name; if (isLogoutMethod(functionName)) { checkLogoutMethodBody(node, functionName); } }, // Check arrow functions and function expressions assigned to variables VariableDeclarator(node) { if (node.id.type === "Identifier" && node.init) { const varName = node.id.name; if (isLogoutMethod(varName)) { if (node.init.type === "ArrowFunctionExpression" || node.init.type === "FunctionExpression") { checkLogoutMethodBody(node.init, varName); } } } }, // Check route handlers with logout paths CallExpression(node) { const callee = node.callee; // Express/NestJS route: app.post('/logout', handler) if (callee.type === "MemberExpression" && ["post", "get", "put", "delete"].includes(callee.property.name) && node.arguments.length >= 2) { const pathArg = node.arguments[0]; if (pathArg.type === "Literal" && typeof pathArg.value === "string") { const path = pathArg.value.toLowerCase(); if (logoutKeywords.some(keyword => path.includes(keyword))) { const handler = node.arguments[node.arguments.length - 1]; if (handler.type === "ArrowFunctionExpression" || handler.type === "FunctionExpression") { checkLogoutMethodBody(handler, `route handler for ${pathArg.value}`); } } } } } }; }, };