1- import { MUTATING_ROUTE_SEGMENTS , ROUTE_HANDLER_FILE_PATTERN } from "../../constants/nextjs.js" ;
1+ import {
2+ CRON_ROUTE_PATTERN ,
3+ MUTATING_ROUTE_SEGMENTS ,
4+ ROUTE_HANDLER_FILE_PATTERN ,
5+ } from "../../constants/nextjs.js" ;
6+ import { GET_HANDLER_BINDING_RESOLUTION_DEPTH } from "../../constants/thresholds.js" ;
7+ import { collectLocallyScopedCookieBindings } from "../../utils/collect-locally-scoped-cookie-bindings.js" ;
8+ import { collectLocallyScopedSafeBindings } from "../../utils/collect-locally-scoped-safe-bindings.js" ;
29import { defineRule } from "../../utils/define-rule.js" ;
310import { findSideEffect } from "../../utils/find-side-effect.js" ;
411import type { EsTreeNode } from "../../utils/es-tree-node.js" ;
12+ import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js" ;
13+ import { isNodeOfType } from "../../utils/is-node-of-type.js" ;
514import type { Rule } from "../../utils/rule.js" ;
615import type { RuleContext } from "../../utils/rule-context.js" ;
7- import { isNodeOfType } from "../../utils/is-node-of-type.js" ;
8- import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js" ;
916
1017const extractMutatingRouteSegment = ( filename : string ) : string | null => {
1118 const segments = filename . split ( "/" ) ;
@@ -16,30 +23,173 @@ const extractMutatingRouteSegment = (filename: string): string | null => {
1623 return null ;
1724} ;
1825
19- const getExportedGetHandlerBody = ( node : EsTreeNode ) : EsTreeNode | null => {
20- if ( ! isNodeOfType ( node , "ExportNamedDeclaration" ) ) return null ;
26+ const buildProgramBindingLookup = (
27+ programNode : EsTreeNode ,
28+ ) : ( ( identifierName : string ) => EsTreeNode | null ) => {
29+ const topLevelBindings = new Map < string , EsTreeNode > ( ) ;
30+ if ( ! isNodeOfType ( programNode , "Program" ) ) return ( ) => null ;
31+
32+ const collectFromStatements = ( statements : EsTreeNode [ ] ) : void => {
33+ for ( const statement of statements ) {
34+ if ( isNodeOfType ( statement , "VariableDeclaration" ) ) {
35+ for ( const declarator of statement . declarations ?? [ ] ) {
36+ if ( ! isNodeOfType ( declarator . id , "Identifier" ) ) continue ;
37+ if ( ! declarator . init ) continue ;
38+ topLevelBindings . set ( declarator . id . name , declarator . init ) ;
39+ }
40+ continue ;
41+ }
42+ if (
43+ isNodeOfType ( statement , "FunctionDeclaration" ) &&
44+ isNodeOfType ( statement . id , "Identifier" ) &&
45+ statement . body
46+ ) {
47+ topLevelBindings . set ( statement . id . name , statement ) ;
48+ continue ;
49+ }
50+ if ( isNodeOfType ( statement , "ExportNamedDeclaration" ) && statement . declaration ) {
51+ collectFromStatements ( [ statement . declaration ] ) ;
52+ }
53+ }
54+ } ;
55+
56+ collectFromStatements ( programNode . body ?? [ ] ) ;
57+ return ( identifierName : string ) => topLevelBindings . get ( identifierName ) ?? null ;
58+ } ;
59+
60+ const isExportedGetHandler = ( node : EsTreeNode ) : boolean => {
61+ if ( ! isNodeOfType ( node , "ExportNamedDeclaration" ) ) return false ;
2162 const declaration = node . declaration ;
22- if ( ! declaration ) return null ;
63+ if ( ! declaration ) return false ;
2364
2465 if ( isNodeOfType ( declaration , "FunctionDeclaration" ) && declaration . id ?. name === "GET" ) {
25- return declaration . body ;
66+ return true ;
2667 }
2768
2869 if ( isNodeOfType ( declaration , "VariableDeclaration" ) ) {
2970 for ( const declarator of declaration . declarations ?? [ ] ) {
71+ if ( isNodeOfType ( declarator ?. id , "Identifier" ) && declarator . id . name === "GET" ) {
72+ return true ;
73+ }
74+ }
75+ }
76+
77+ return false ;
78+ } ;
79+
80+ const isGetMethodCall = ( callExpression : EsTreeNode ) : boolean =>
81+ isNodeOfType ( callExpression , "CallExpression" ) &&
82+ isNodeOfType ( callExpression . callee , "MemberExpression" ) &&
83+ isNodeOfType ( callExpression . callee . property , "Identifier" ) &&
84+ callExpression . callee . property . name === "get" ;
85+
86+ const isStringLikeNode = ( node : EsTreeNode ) : boolean =>
87+ ( isNodeOfType ( node , "Literal" ) && typeof node . value === "string" ) ||
88+ isNodeOfType ( node , "TemplateLiteral" ) ;
89+
90+ const getHandlerCallbackBody = (
91+ callExpression : EsTreeNodeOfType < "CallExpression" > ,
92+ ) : EsTreeNode | null => {
93+ const callArguments = callExpression . arguments ?? [ ] ;
94+ if ( callArguments . length < 2 ) return null ;
95+ const routePatternArgument = callArguments [ 0 ] ;
96+ if ( ! isStringLikeNode ( routePatternArgument ) ) return null ;
97+ const handlerArgument = callArguments [ callArguments . length - 1 ] ;
98+ if (
99+ ( isNodeOfType ( handlerArgument , "ArrowFunctionExpression" ) ||
100+ isNodeOfType ( handlerArgument , "FunctionExpression" ) ) &&
101+ handlerArgument . body
102+ ) {
103+ return handlerArgument . body ;
104+ }
105+ return null ;
106+ } ;
107+
108+ const collectChainedGetHandlerBodies = ( initNode : EsTreeNode ) : EsTreeNode [ ] => {
109+ const chainedBodies : EsTreeNode [ ] = [ ] ;
110+ let cursor : EsTreeNode | null | undefined = initNode ;
111+ while ( cursor && isNodeOfType ( cursor , "CallExpression" ) ) {
112+ if ( isGetMethodCall ( cursor ) ) {
113+ const body = getHandlerCallbackBody ( cursor ) ;
114+ if ( body ) chainedBodies . push ( body ) ;
115+ }
116+ cursor = isNodeOfType ( cursor . callee , "MemberExpression" ) ? cursor . callee . object : null ;
117+ }
118+ return chainedBodies ;
119+ } ;
120+
121+ const resolveBodiesFromExpression = (
122+ expression : EsTreeNode ,
123+ resolveBinding : ( identifierName : string ) => EsTreeNode | null ,
124+ remainingDepth : number ,
125+ ) : EsTreeNode [ ] => {
126+ if ( remainingDepth <= 0 ) return [ ] ;
127+
128+ if (
129+ isNodeOfType ( expression , "ArrowFunctionExpression" ) ||
130+ isNodeOfType ( expression , "FunctionExpression" ) ||
131+ isNodeOfType ( expression , "FunctionDeclaration" )
132+ ) {
133+ return expression . body ? [ expression . body ] : [ ] ;
134+ }
135+
136+ if ( isNodeOfType ( expression , "CallExpression" ) ) {
137+ for ( const callArgument of expression . arguments ?? [ ] ) {
30138 if (
31- isNodeOfType ( declarator ?. id , "Identifier" ) &&
32- declarator . id . name === "GET" &&
33- declarator . init &&
34- ( isNodeOfType ( declarator . init , "ArrowFunctionExpression" ) ||
35- isNodeOfType ( declarator . init , "FunctionExpression" ) )
139+ isNodeOfType ( callArgument , "ArrowFunctionExpression" ) ||
140+ isNodeOfType ( callArgument , "FunctionExpression" )
36141 ) {
37- return declarator . init . body ;
142+ if ( callArgument . body ) return [ callArgument . body ] ;
38143 }
144+ if ( ! isNodeOfType ( callArgument , "Identifier" ) ) continue ;
145+ const argumentInit = resolveBinding ( callArgument . name ) ;
146+ if ( ! argumentInit ) continue ;
147+ const resolvedBodies = resolveBodiesFromExpression (
148+ argumentInit ,
149+ resolveBinding ,
150+ remainingDepth - 1 ,
151+ ) ;
152+ if ( resolvedBodies . length > 0 ) return resolvedBodies ;
153+ const chainedBodies = collectChainedGetHandlerBodies ( argumentInit ) ;
154+ if ( chainedBodies . length > 0 ) return chainedBodies ;
39155 }
156+ return [ ] ;
40157 }
41158
42- return null ;
159+ if ( isNodeOfType ( expression , "Identifier" ) ) {
160+ const boundInit = resolveBinding ( expression . name ) ;
161+ if ( ! boundInit ) return [ ] ;
162+ return resolveBodiesFromExpression ( boundInit , resolveBinding , remainingDepth - 1 ) ;
163+ }
164+
165+ return [ ] ;
166+ } ;
167+
168+ const resolveGetHandlerBodies = (
169+ exportNode : EsTreeNode ,
170+ resolveBinding : ( identifierName : string ) => EsTreeNode | null ,
171+ ) : EsTreeNode [ ] => {
172+ if ( ! isNodeOfType ( exportNode , "ExportNamedDeclaration" ) ) return [ ] ;
173+ const declaration = exportNode . declaration ;
174+ if ( ! declaration ) return [ ] ;
175+
176+ if ( isNodeOfType ( declaration , "FunctionDeclaration" ) && declaration . id ?. name === "GET" ) {
177+ return declaration . body ? [ declaration . body ] : [ ] ;
178+ }
179+
180+ if ( ! isNodeOfType ( declaration , "VariableDeclaration" ) ) return [ ] ;
181+
182+ for ( const declarator of declaration . declarations ?? [ ] ) {
183+ if ( ! isNodeOfType ( declarator . id , "Identifier" ) || declarator . id . name !== "GET" ) continue ;
184+ if ( ! declarator . init ) return [ ] ;
185+ return resolveBodiesFromExpression (
186+ declarator . init ,
187+ resolveBinding ,
188+ GET_HANDLER_BINDING_RESOLUTION_DEPTH ,
189+ ) ;
190+ }
191+
192+ return [ ] ;
43193} ;
44194
45195export const nextjsNoSideEffectInGetHandler = defineRule < Rule > ( {
@@ -49,30 +199,44 @@ export const nextjsNoSideEffectInGetHandler = defineRule<Rule>({
49199 category : "Security" ,
50200 recommendation :
51201 "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" ,
52- create : ( context : RuleContext ) => ( {
53- ExportNamedDeclaration ( node : EsTreeNodeOfType < "ExportNamedDeclaration" > ) {
54- const filename = context . getFilename ?.( ) ?? "" ;
55- if ( ! ROUTE_HANDLER_FILE_PATTERN . test ( filename ) ) return ;
56-
57- const handlerBody = getExportedGetHandlerBody ( node ) ;
58- if ( ! handlerBody ) return ;
59-
60- const mutatingSegment = extractMutatingRouteSegment ( filename ) ;
61- if ( mutatingSegment ) {
62- context . report ( {
63- node,
64- message : `GET handler on "/${ mutatingSegment } " route — use POST to prevent CSRF and unintended prefetch triggers` ,
65- } ) ;
66- return ;
67- }
202+ create : ( context : RuleContext ) => {
203+ let resolveBinding : ( identifierName : string ) => EsTreeNode | null = ( ) => null ;
68204
69- const sideEffect = findSideEffect ( handlerBody ) ;
70- if ( sideEffect ) {
71- context . report ( {
72- node,
73- message : `GET handler has side effects (${ sideEffect } ) — use POST to prevent CSRF and unintended prefetch triggers` ,
74- } ) ;
75- }
76- } ,
77- } ) ,
205+ return {
206+ Program ( node : EsTreeNodeOfType < "Program" > ) {
207+ resolveBinding = buildProgramBindingLookup ( node ) ;
208+ } ,
209+ ExportNamedDeclaration ( node : EsTreeNodeOfType < "ExportNamedDeclaration" > ) {
210+ const filename = context . getFilename ?.( ) ?? "" ;
211+ if ( ! ROUTE_HANDLER_FILE_PATTERN . test ( filename ) ) return ;
212+ if ( CRON_ROUTE_PATTERN . test ( filename ) ) return ;
213+ if ( ! isExportedGetHandler ( node ) ) return ;
214+
215+ const mutatingSegment = extractMutatingRouteSegment ( filename ) ;
216+ if ( mutatingSegment ) {
217+ context . report ( {
218+ node,
219+ message : `GET handler on "/${ mutatingSegment } " route — use POST to prevent CSRF and unintended prefetch triggers` ,
220+ } ) ;
221+ return ;
222+ }
223+
224+ const handlerBodies = resolveGetHandlerBodies ( node , resolveBinding ) ;
225+ for ( const handlerBody of handlerBodies ) {
226+ const locallyScopedSafeBindings = collectLocallyScopedSafeBindings ( handlerBody ) ;
227+ const locallyScopedCookieBindings = collectLocallyScopedCookieBindings ( handlerBody ) ;
228+ const sideEffect = findSideEffect ( handlerBody , {
229+ locallyScopedSafeBindings,
230+ locallyScopedCookieBindings,
231+ } ) ;
232+ if ( ! sideEffect ) continue ;
233+ context . report ( {
234+ node,
235+ message : `GET handler has side effects (${ sideEffect } ) — use POST to prevent CSRF and unintended prefetch triggers` ,
236+ } ) ;
237+ return ;
238+ }
239+ } ,
240+ } ;
241+ } ,
78242} ) ;
0 commit comments