fix(nextjs): stop flagging response.headers and local Map/Set/Headers in GET handlers (#206)#263
fix(nextjs): stop flagging response.headers and local Map/Set/Headers in GET handlers (#206)#263NisargIO wants to merge 6 commits into
Conversation
… in GET handlers (#206) Rewrites `nextjs-no-side-effect-in-get-handler` precision so it no longer floods Next.js codebases with false positives from `response.headers.set()` and request-scoped `new Map/Set/Headers/URLSearchParams/FormData(...)` mutations, while still catching real CSRF-relevant writes: drizzle/prisma ORM mutations, module-level mutable state, mutating fetch, and all three forms of `cookies().set/delete()` including aliased `const cs = await cookies(); cs.set(...)`. Also folds in the safe handler-resolution improvements from PR #251 (cron route skip and depth-bounded `const GET = withAuth(handler)` resolution). Supersedes #209, #211, #233, #238. Co-authored-by: Cursor <cursoragent@cursor.com>
The `isHeadersPropertyAccess` predicate was added during the issue #206 work but never imported — its check (`.headers` member access) is fully covered by `isSafeIntrinsicMemberAccess` in `is-safe-mutable-receiver- source.ts` (which also handles `.searchParams`). Removing the dead file to comply with the workspace "remove unused code and don't repeat yourself" rule. Co-authored-by: Cursor <cursoragent@cursor.com>
…ies-call utils `isCookiesCall` in `collect-locally-scoped-cookie-bindings.ts` and `isDirectCookiesCall` in `find-side-effect.ts` were byte-identical, and `isCookiesInit` was structurally the same as `(direct || awaited)` from `find-side-effect.ts`. Extract to single-purpose utils (one per file, per workspace convention) and rewire both consumers so the cookies-detection predicate has one definition. Co-authored-by: Cursor <cursoragent@cursor.com>
…ollectors `collectLocallyScopedSafeBindings` / `collectLocallyScopedCookieBindings` use `walkInsideStatementBlocks`, which returns immediately at any function boundary. `tanstack-start-get-mutation` was passing the `.handler(fn)` callback NODE itself, so both binding sets came back empty — every aliased shape inside a TanStack Start handler was misclassified: - `const customHeaders = new Headers(); customHeaders.set(...)` leaked a false positive. - `const cs = cookies(); cs.set(...)` got reported as `cs.set()` instead of the canonical `cookies().set()`. Unwrap to `handlerFunction.body` (mirroring how the Next.js rule passes `handlerBody` after `resolveGetHandlerBodies`) and add four regression cases that pin this down. `findSideEffect` still gets the body too, which is a no-op for it (walkAst descends through functions) but keeps the inputs consistent across both consumers. Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
|
🔴 React Review — 0/100 (unchanged) · Copy prompt for agentReviewed by react-review for commit bd9778a. Configure here. |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
|
||
| // Verbatim repro from issue #206 — used to flood codebases with false | ||
| // positives. Response header shaping must not fire the rule. | ||
| export async function GET(req: NextRequest, ctx: RouteContext) { |
There was a problem hiding this comment.
Caution
GET handler has side effects (.set()) — use POST to prevent CSRF and unintended prefetch triggers
Move the side effect to a POST handler and use a
or fetch with method POST — GET requests can be triggered by prefetching and are vulnerable to CSRFRule: nextjs-no-side-effect-in-get-handler
Disable nextjs-no-side-effect-in-get-handler for this line
| export async function GET(req: NextRequest, ctx: RouteContext) { | |
| // react-doctor-disable-next-line nextjs-no-side-effect-in-get-handler | |
| export async function GET(req: NextRequest, ctx: RouteContext) { |
Copy prompt for agent
Check if this React Review issue is valid. If so, understand the root cause and fix it.
Run this before and after your changes to verify the result:
npx react-doctor@latest --verbose --diff
Do not modify the react-doctor configuration unless explicitly asked.
Fix the underlying code issue instead of changing or suppressing the rule.
<file name="packages/react-doctor/tests/fixtures/nextjs-app/src/app/api/documents/[id]/route.tsx">
<violation number="1" location="packages/react-doctor/tests/fixtures/nextjs-app/src/app/api/documents/[id]/route.tsx:13">
Severity: Error
GET handler has side effects (.set()) — use POST to prevent CSRF and unintended prefetch triggers
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
Rule: `nextjs-no-side-effect-in-get-handler`
</violation>
</file>
Reviewed by react-review for commit 71ecf0a. Configure here.
|
|
||
| // Vercel Cron always invokes GET — real side effects are expected | ||
| // and the rule must not fire here. | ||
| export async function GET() { |
There was a problem hiding this comment.
Caution
GET handler has side effects (db.insert()) — use POST to prevent CSRF and unintended prefetch triggers
Move the side effect to a POST handler and use a
or fetch with method POST — GET requests can be triggered by prefetching and are vulnerable to CSRFRule: nextjs-no-side-effect-in-get-handler
Disable nextjs-no-side-effect-in-get-handler for this line
| export async function GET() { | |
| // react-doctor-disable-next-line nextjs-no-side-effect-in-get-handler | |
| export async function GET() { |
Copy prompt for agent
Check if this React Review issue is valid. If so, understand the root cause and fix it.
Run this before and after your changes to verify the result:
npx react-doctor@latest --verbose --diff
Do not modify the react-doctor configuration unless explicitly asked.
Fix the underlying code issue instead of changing or suppressing the rule.
<file name="packages/react-doctor/tests/fixtures/nextjs-app/src/app/api/cron/refresh/route.tsx">
<violation number="1" location="packages/react-doctor/tests/fixtures/nextjs-app/src/app/api/cron/refresh/route.tsx:9">
Severity: Error
GET handler has side effects (db.insert()) — use POST to prevent CSRF and unintended prefetch triggers
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
Rule: `nextjs-no-side-effect-in-get-handler`
</violation>
</file>
Reviewed by react-review for commit 71ecf0a. Configure here.
| // side effect (server state leaks across requests). | ||
| const cache = new Map<string, unknown>(); | ||
|
|
||
| export async function GET() { |
There was a problem hiding this comment.
Caution
GET handler has side effects (cache.set()) — use POST to prevent CSRF and unintended prefetch triggers
Move the side effect to a POST handler and use a
or fetch with method POST — GET requests can be triggered by prefetching and are vulnerable to CSRFRule: nextjs-no-side-effect-in-get-handler
Disable nextjs-no-side-effect-in-get-handler for this line
| export async function GET() { | |
| // react-doctor-disable-next-line nextjs-no-side-effect-in-get-handler | |
| export async function GET() { |
Copy prompt for agent
Check if this React Review issue is valid. If so, understand the root cause and fix it.
Run this before and after your changes to verify the result:
npx react-doctor@latest --verbose --diff
Do not modify the react-doctor configuration unless explicitly asked.
Fix the underlying code issue instead of changing or suppressing the rule.
<file name="packages/react-doctor/tests/fixtures/nextjs-app/src/app/api/admin/route.tsx">
<violation number="1" location="packages/react-doctor/tests/fixtures/nextjs-app/src/app/api/admin/route.tsx:15">
Severity: Error
GET handler has side effects (cache.set()) — use POST to prevent CSRF and unintended prefetch triggers
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
Rule: `nextjs-no-side-effect-in-get-handler`
</violation>
</file>
Reviewed by react-review for commit 71ecf0a. Configure here.
71ecf0a to
bd9778a
Compare
|
Closing in favor of reopened #260 (same diff, same branch). |
Summary
Re-opens #260, which GitHub auto-closed after a force-push during rebase onto
main. Same diff, now rebased andformat:check-clean.Rewrites
nextjs-no-side-effect-in-get-handlerprecision so it no longer floods Next.js codebases with false positives from response-shaping calls, while still catching real CSRF-relevant server-state mutations.Fixes #206. Supersedes #209, #211, #233, #238. The handler-resolution improvements from #251 (cron route skip + depth-bounded
const GET = withAuth(handler)resolution) are also folded in; the broader rule expansion in #251 remains an independent PR.What changes for users
No longer flagged (false positives — were ~138 errors in one Next.js 14 codebase)
res.headers.set/append/delete(...)and any chain ending in.headers(handlesNextResponse.json({...}).headers.set(...),(await fetcher()).headers.append(...)).new Map/Set/WeakMap/WeakSet/Headers/URLSearchParams/FormData/Response/NextResponse(...)bindings and any mutation on those aliases.new URL(...).searchParams.set(...)and any.searchParams.*()chain.headers()/(await headers())fromnext/headers(returnsReadonlyHeaders; any mutation would throw at runtime) and any aliasedconst h = headers(); h.get(...)./cron/or/jobs/cron/— Vercel Cron always invokes GET and is expected to do real work.Still flagged (real CSRF-relevant side effects)
db.update(table).set({...}).where(...),prisma.user.create(...),db.insert(...),repository.upsert(...).const cache = new Map()declared outside the handler, thencache.set(...)inside.fetch(url, { method: 'POST' | 'PUT' | 'DELETE' | 'PATCH' }).cookies().set/append/delete()in all forms — direct,(await cookies()).set(...), and (new) aliasedconst cs = await cookies(); cs.set(...)(the previously-missed aliased form)./logout,/signout,/unsubscribe,/delete, …).Implementation
packages/oxlint-plugin-react-doctor/src/plugin/utils/find-side-effect.tsnow takes an options bag and short-circuits any mutation call whose receiver chain is structurally "safe" (a.headers/.searchParamsaccess, anew X()of a safe constructor, aResponse/NextResponsefactory, or an Identifier in a per-handler safe-bindings set). The rule itself collects two binding sets before scanning:collectLocallyScopedSafeBindings(handlerBody)—const X = new Map(),const X = new Headers(),const X = NextResponse.json(...),const X = await headers(), etc.collectLocallyScopedCookieBindings(handlerBody)—const X = cookies()andconst X = await cookies(). Any<alias>.set/append/delete()is reported ascookies().<method>()so the diagnostic stays consistent.The rule also gained depth-bounded handler-binding resolution (
GET_HANDLER_BINDING_RESOLUTION_DEPTH = 3) soexport const GET = withAuth(handler)andexport const GET = app.get('/x', handler)chains get scanned correctly.Test plan
packages/react-doctor/tests/regressions/nextjs-get-side-effects.test.ts(new) builds 23 isolated synthetic projects covering:packages/react-doctor/tests/run-oxlint/nextjs.test.tsalso gains three integration assertions on new fixtures undertests/fixtures/nextjs-app/src/app/api/to lock the behavior in the shared Next.js fixture project.nr typecheck— all packages passnr lint— 0 warnings / 0 errorsnr test— 909/909 passnr format:check— cleanMade with Cursor