Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,18 @@ export const MUTATION_METHOD_NAMES = new Set([
]);

export const MUTATING_HTTP_METHODS = new Set(["POST", "PUT", "DELETE", "PATCH"]);

export const SAFE_MUTABLE_CONSTRUCTOR_NAMES = new Set([
"Map",
"Set",
"WeakMap",
"WeakSet",
"Headers",
"URLSearchParams",
"FormData",
"Response",
"NextResponse",
]);

export const RESPONSE_FACTORY_OBJECTS = new Set(["Response", "NextResponse"]);
export const RESPONSE_FACTORY_METHODS = new Set(["json", "redirect", "next", "rewrite", "error"]);
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export const APP_DIRECTORY_PATTERN = /\/app\//;

export const ROUTE_HANDLER_FILE_PATTERN = /\/route\.(tsx?|jsx?)$/;

export const CRON_ROUTE_PATTERN = /\/(?:cron|jobs\/cron)(?:\/|$)/i;

export const MUTATING_ROUTE_SEGMENTS = new Set([
"logout",
"log-out",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ export const SEQUENTIAL_AWAIT_THRESHOLD = 3;
export const PROPERTY_ACCESS_REPEAT_THRESHOLD = 3;
export const BOOLEAN_PROP_THRESHOLD = 4;
export const RENDER_PROP_PROLIFERATION_THRESHOLD = 3;
export const GET_HANDLER_BINDING_RESOLUTION_DEPTH = 3;
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
import { MUTATING_ROUTE_SEGMENTS, ROUTE_HANDLER_FILE_PATTERN } from "../../constants/nextjs.js";
import {
CRON_ROUTE_PATTERN,
MUTATING_ROUTE_SEGMENTS,
ROUTE_HANDLER_FILE_PATTERN,
} from "../../constants/nextjs.js";
import { GET_HANDLER_BINDING_RESOLUTION_DEPTH } from "../../constants/thresholds.js";
import { collectLocallyScopedCookieBindings } from "../../utils/collect-locally-scoped-cookie-bindings.js";
import { collectLocallyScopedSafeBindings } from "../../utils/collect-locally-scoped-safe-bindings.js";
import { defineRule } from "../../utils/define-rule.js";
import { findSideEffect } from "../../utils/find-side-effect.js";
import type { EsTreeNode } from "../../utils/es-tree-node.js";
import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js";
import { isNodeOfType } from "../../utils/is-node-of-type.js";
import type { Rule } from "../../utils/rule.js";
import type { RuleContext } from "../../utils/rule-context.js";
import { isNodeOfType } from "../../utils/is-node-of-type.js";
import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js";

const extractMutatingRouteSegment = (filename: string): string | null => {
const segments = filename.split("/");
Expand All @@ -16,30 +23,173 @@ const extractMutatingRouteSegment = (filename: string): string | null => {
return null;
};

const getExportedGetHandlerBody = (node: EsTreeNode): EsTreeNode | null => {
if (!isNodeOfType(node, "ExportNamedDeclaration")) return null;
const buildProgramBindingLookup = (
programNode: EsTreeNode,
): ((identifierName: string) => EsTreeNode | null) => {
const topLevelBindings = new Map<string, EsTreeNode>();
if (!isNodeOfType(programNode, "Program")) return () => null;

const collectFromStatements = (statements: EsTreeNode[]): void => {
for (const statement of statements) {
if (isNodeOfType(statement, "VariableDeclaration")) {
for (const declarator of statement.declarations ?? []) {
if (!isNodeOfType(declarator.id, "Identifier")) continue;
if (!declarator.init) continue;
topLevelBindings.set(declarator.id.name, declarator.init);
}
continue;
}
if (
isNodeOfType(statement, "FunctionDeclaration") &&
isNodeOfType(statement.id, "Identifier") &&
statement.body
) {
topLevelBindings.set(statement.id.name, statement);
continue;
}
if (isNodeOfType(statement, "ExportNamedDeclaration") && statement.declaration) {
collectFromStatements([statement.declaration]);
}
}
};

collectFromStatements(programNode.body ?? []);
return (identifierName: string) => topLevelBindings.get(identifierName) ?? null;
};

const isExportedGetHandler = (node: EsTreeNode): boolean => {
if (!isNodeOfType(node, "ExportNamedDeclaration")) return false;
const declaration = node.declaration;
if (!declaration) return null;
if (!declaration) return false;

if (isNodeOfType(declaration, "FunctionDeclaration") && declaration.id?.name === "GET") {
return declaration.body;
return true;
}

if (isNodeOfType(declaration, "VariableDeclaration")) {
for (const declarator of declaration.declarations ?? []) {
if (isNodeOfType(declarator?.id, "Identifier") && declarator.id.name === "GET") {
return true;
}
}
}

return false;
};

const isGetMethodCall = (callExpression: EsTreeNode): boolean =>
isNodeOfType(callExpression, "CallExpression") &&
isNodeOfType(callExpression.callee, "MemberExpression") &&
isNodeOfType(callExpression.callee.property, "Identifier") &&
callExpression.callee.property.name === "get";

const isStringLikeNode = (node: EsTreeNode): boolean =>
(isNodeOfType(node, "Literal") && typeof node.value === "string") ||
isNodeOfType(node, "TemplateLiteral");

const getHandlerCallbackBody = (
callExpression: EsTreeNodeOfType<"CallExpression">,
): EsTreeNode | null => {
const callArguments = callExpression.arguments ?? [];
if (callArguments.length < 2) return null;
const routePatternArgument = callArguments[0];
if (!isStringLikeNode(routePatternArgument)) return null;
const handlerArgument = callArguments[callArguments.length - 1];
if (
(isNodeOfType(handlerArgument, "ArrowFunctionExpression") ||
isNodeOfType(handlerArgument, "FunctionExpression")) &&
handlerArgument.body
) {
return handlerArgument.body;
}
return null;
};

const collectChainedGetHandlerBodies = (initNode: EsTreeNode): EsTreeNode[] => {
const chainedBodies: EsTreeNode[] = [];
let cursor: EsTreeNode | null | undefined = initNode;
while (cursor && isNodeOfType(cursor, "CallExpression")) {
if (isGetMethodCall(cursor)) {
const body = getHandlerCallbackBody(cursor);
if (body) chainedBodies.push(body);
}
cursor = isNodeOfType(cursor.callee, "MemberExpression") ? cursor.callee.object : null;
}
return chainedBodies;
};

const resolveBodiesFromExpression = (
expression: EsTreeNode,
resolveBinding: (identifierName: string) => EsTreeNode | null,
remainingDepth: number,
): EsTreeNode[] => {
if (remainingDepth <= 0) return [];

if (
isNodeOfType(expression, "ArrowFunctionExpression") ||
isNodeOfType(expression, "FunctionExpression") ||
isNodeOfType(expression, "FunctionDeclaration")
) {
return expression.body ? [expression.body] : [];
}

if (isNodeOfType(expression, "CallExpression")) {
for (const callArgument of expression.arguments ?? []) {
if (
isNodeOfType(declarator?.id, "Identifier") &&
declarator.id.name === "GET" &&
declarator.init &&
(isNodeOfType(declarator.init, "ArrowFunctionExpression") ||
isNodeOfType(declarator.init, "FunctionExpression"))
isNodeOfType(callArgument, "ArrowFunctionExpression") ||
isNodeOfType(callArgument, "FunctionExpression")
) {
return declarator.init.body;
if (callArgument.body) return [callArgument.body];
}
if (!isNodeOfType(callArgument, "Identifier")) continue;
const argumentInit = resolveBinding(callArgument.name);
if (!argumentInit) continue;
const resolvedBodies = resolveBodiesFromExpression(
argumentInit,
resolveBinding,
remainingDepth - 1,
);
if (resolvedBodies.length > 0) return resolvedBodies;
const chainedBodies = collectChainedGetHandlerBodies(argumentInit);
if (chainedBodies.length > 0) return chainedBodies;
}
return [];
}

return null;
if (isNodeOfType(expression, "Identifier")) {
const boundInit = resolveBinding(expression.name);
if (!boundInit) return [];
return resolveBodiesFromExpression(boundInit, resolveBinding, remainingDepth - 1);
}

return [];
};

const resolveGetHandlerBodies = (
exportNode: EsTreeNode,
resolveBinding: (identifierName: string) => EsTreeNode | null,
): EsTreeNode[] => {
if (!isNodeOfType(exportNode, "ExportNamedDeclaration")) return [];
const declaration = exportNode.declaration;
if (!declaration) return [];

if (isNodeOfType(declaration, "FunctionDeclaration") && declaration.id?.name === "GET") {
return declaration.body ? [declaration.body] : [];
}

if (!isNodeOfType(declaration, "VariableDeclaration")) return [];

for (const declarator of declaration.declarations ?? []) {
if (!isNodeOfType(declarator.id, "Identifier") || declarator.id.name !== "GET") continue;
if (!declarator.init) return [];
return resolveBodiesFromExpression(
declarator.init,
resolveBinding,
GET_HANDLER_BINDING_RESOLUTION_DEPTH,
);
}

return [];
};

export const nextjsNoSideEffectInGetHandler = defineRule<Rule>({
Expand All @@ -49,30 +199,44 @@ export const nextjsNoSideEffectInGetHandler = defineRule<Rule>({
category: "Security",
recommendation:
"Move the side effect to a POST handler and use a <form> or fetch with method POST — GET requests can be triggered by prefetching and are vulnerable to CSRF",
create: (context: RuleContext) => ({
ExportNamedDeclaration(node: EsTreeNodeOfType<"ExportNamedDeclaration">) {
const filename = context.getFilename?.() ?? "";
if (!ROUTE_HANDLER_FILE_PATTERN.test(filename)) return;

const handlerBody = getExportedGetHandlerBody(node);
if (!handlerBody) return;

const mutatingSegment = extractMutatingRouteSegment(filename);
if (mutatingSegment) {
context.report({
node,
message: `GET handler on "/${mutatingSegment}" route — use POST to prevent CSRF and unintended prefetch triggers`,
});
return;
}
create: (context: RuleContext) => {
let resolveBinding: (identifierName: string) => EsTreeNode | null = () => null;

const sideEffect = findSideEffect(handlerBody);
if (sideEffect) {
context.report({
node,
message: `GET handler has side effects (${sideEffect}) — use POST to prevent CSRF and unintended prefetch triggers`,
});
}
},
}),
return {
Program(node: EsTreeNodeOfType<"Program">) {
resolveBinding = buildProgramBindingLookup(node);
},
ExportNamedDeclaration(node: EsTreeNodeOfType<"ExportNamedDeclaration">) {
const filename = context.getFilename?.() ?? "";
if (!ROUTE_HANDLER_FILE_PATTERN.test(filename)) return;
if (CRON_ROUTE_PATTERN.test(filename)) return;
if (!isExportedGetHandler(node)) return;

const mutatingSegment = extractMutatingRouteSegment(filename);
if (mutatingSegment) {
context.report({
node,
message: `GET handler on "/${mutatingSegment}" route — use POST to prevent CSRF and unintended prefetch triggers`,
});
return;
}

const handlerBodies = resolveGetHandlerBodies(node, resolveBinding);
for (const handlerBody of handlerBodies) {
const locallyScopedSafeBindings = collectLocallyScopedSafeBindings(handlerBody);
const locallyScopedCookieBindings = collectLocallyScopedCookieBindings(handlerBody);
const sideEffect = findSideEffect(handlerBody, {
locallyScopedSafeBindings,
locallyScopedCookieBindings,
});
if (!sideEffect) continue;
context.report({
node,
message: `GET handler has side effects (${sideEffect}) — use POST to prevent CSRF and unintended prefetch triggers`,
});
return;
}
},
};
},
});
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { MUTATING_HTTP_METHODS } from "../../constants/library.js";
import { collectLocallyScopedCookieBindings } from "../../utils/collect-locally-scoped-cookie-bindings.js";
import { collectLocallyScopedSafeBindings } from "../../utils/collect-locally-scoped-safe-bindings.js";
import { defineRule } from "../../utils/define-rule.js";
import { findSideEffect } from "../../utils/find-side-effect.js";
import type { Rule } from "../../utils/rule.js";
Expand Down Expand Up @@ -34,7 +36,24 @@ export const tanstackStartGetMutation = defineRule<Rule>({
const handlerFunction = node.arguments?.[0];
if (!handlerFunction) return;

const sideEffect = findSideEffect(handlerFunction);
// HACK: `collectLocallyScoped*Bindings` uses `walkInsideStatementBlocks`,
// which intentionally stops at function boundaries — so we must hand it
// the function's body, not the function itself, or every aliased
// pattern (`const m = new Map(); m.set(...)`) would slip past.
if (
!isNodeOfType(handlerFunction, "ArrowFunctionExpression") &&
!isNodeOfType(handlerFunction, "FunctionExpression")
)
return;
const handlerBody = handlerFunction.body;
if (!handlerBody) return;

const locallyScopedSafeBindings = collectLocallyScopedSafeBindings(handlerBody);
const locallyScopedCookieBindings = collectLocallyScopedCookieBindings(handlerBody);
const sideEffect = findSideEffect(handlerBody, {
locallyScopedSafeBindings,
locallyScopedCookieBindings,
});
if (sideEffect) {
context.report({
node,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { collectPatternNames } from "./collect-pattern-names.js";
import type { EsTreeNode } from "./es-tree-node.js";
import { isCookiesOrAwaitedCookiesCall } from "./is-cookies-or-awaited-cookies-call.js";
import { isNodeOfType } from "./is-node-of-type.js";
import { walkInsideStatementBlocks } from "./walk-inside-statement-blocks.js";

export const collectLocallyScopedCookieBindings = (handlerBody: EsTreeNode): Set<string> => {
const cookieBindingNames = new Set<string>();
walkInsideStatementBlocks(handlerBody, (node: EsTreeNode) => {
if (!isNodeOfType(node, "VariableDeclarator")) return;
if (!node.init) return;
if (!isCookiesOrAwaitedCookiesCall(node.init)) return;
collectPatternNames(node.id, cookieBindingNames);
});
return cookieBindingNames;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { collectPatternNames } from "./collect-pattern-names.js";
import type { EsTreeNode } from "./es-tree-node.js";
import { isNodeOfType } from "./is-node-of-type.js";
import { isSafeMutableReceiverSource } from "./is-safe-mutable-receiver-source.js";
import { walkInsideStatementBlocks } from "./walk-inside-statement-blocks.js";

export const collectLocallyScopedSafeBindings = (handlerBody: EsTreeNode): Set<string> => {
const safeBindingNames = new Set<string>();
walkInsideStatementBlocks(handlerBody, (node: EsTreeNode) => {
if (!isNodeOfType(node, "VariableDeclarator")) return;
if (!node.init) return;
if (!isSafeMutableReceiverSource(node.init)) return;
collectPatternNames(node.id, safeBindingNames);
});
return safeBindingNames;
};
Loading
Loading