Skip to content

fix(nextjs): stop flagging response.headers and local Map/Set/Headers in GET handlers (#206)#263

Closed
NisargIO wants to merge 6 commits into
mainfrom
fix/nextjs-get-handler-side-effects-206
Closed

fix(nextjs): stop flagging response.headers and local Map/Set/Headers in GET handlers (#206)#263
NisargIO wants to merge 6 commits into
mainfrom
fix/nextjs-get-handler-side-effects-206

Conversation

@NisargIO
Copy link
Copy Markdown
Member

Summary

Re-opens #260, which GitHub auto-closed after a force-push during rebase onto main. Same diff, now rebased and format:check-clean.

Rewrites nextjs-no-side-effect-in-get-handler precision 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 (handles NextResponse.json({...}).headers.set(...), (await fetcher()).headers.append(...)).
  • Locally-constructed 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()) from next/headers (returns ReadonlyHeaders; any mutation would throw at runtime) and any aliased const h = headers(); h.get(...).
  • Route handlers under /cron/ or /jobs/cron/ — Vercel Cron always invokes GET and is expected to do real work.

Still flagged (real CSRF-relevant side effects)

  • ORM mutations: drizzle db.update(table).set({...}).where(...), prisma.user.create(...), db.insert(...), repository.upsert(...).
  • Module-level mutable state: const cache = new Map() declared outside the handler, then cache.set(...) inside.
  • fetch(url, { method: 'POST' | 'PUT' | 'DELETE' | 'PATCH' }).
  • cookies().set/append/delete() in all forms — direct, (await cookies()).set(...), and (new) aliased const cs = await cookies(); cs.set(...) (the previously-missed aliased form).
  • Mutating route segments (/logout, /signout, /unsubscribe, /delete, …).

Implementation

packages/oxlint-plugin-react-doctor/src/plugin/utils/find-side-effect.ts now takes an options bag and short-circuits any mutation call whose receiver chain is structurally "safe" (a .headers/.searchParams access, a new X() of a safe constructor, a Response/NextResponse factory, 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() and const X = await cookies(). Any <alias>.set/append/delete() is reported as cookies().<method>() so the diagnostic stays consistent.

The rule also gained depth-bounded handler-binding resolution (GET_HANDLER_BINDING_RESOLUTION_DEPTH = 3) so export const GET = withAuth(handler) and export 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.ts also gains three integration assertions on new fixtures under tests/fixtures/nextjs-app/src/app/api/ to lock the behavior in the shared Next.js fixture project.

  • nr typecheck — all packages pass
  • nr lint — 0 warnings / 0 errors
  • nr test — 909/909 pass
  • nr format:check — clean

Made with Cursor

NisargIO and others added 6 commits May 15, 2026 02:06
… 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>
@reactreview
Copy link
Copy Markdown

reactreview Bot commented May 16, 2026

🔴 React Review0/100 (unchanged) · 3 ❌ errors · 1 ⚠️ warning

Copy prompt for agent
Check if these React Review issues are valid. If so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.

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 issues instead of changing or suppressing the rules.

React Review found 3 errors and 1 warning. This PR leaves the React health score unchanged.

<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>

<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>

<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>

<violation number="2" location="packages/react-doctor/tests/fixtures/nextjs-app/src/app/api/admin/route.tsx:18">
Severity: Warning

fetch("/api/notify") in a Server Component / route handler defaults to forever-caching — pass { next: { revalidate: <seconds> } } / { next: { tags: [...] } } / { cache: "no-store" } so stale data doesn't quietly persist

Pass `{ next: { revalidate: <seconds> } }` (or `cache: "no-store"` / `next: { tags: [...] }`) so stale cached data doesn't silently persist

Rule: `server-fetch-without-revalidate`
</violation>

</file>

Reviewed by react-review for commit bd9778a. Configure here.

@vercel
Copy link
Copy Markdown

vercel Bot commented May 16, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
react-doctor-website Ready Ready Preview, Comment May 16, 2026 1:12am


// 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) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 CSRF

Rule: nextjs-no-side-effect-in-get-handler

Disable nextjs-no-side-effect-in-get-handler for this line
Suggested change
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() {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 CSRF

Rule: nextjs-no-side-effect-in-get-handler

Disable nextjs-no-side-effect-in-get-handler for this line
Suggested change
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() {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 CSRF

Rule: nextjs-no-side-effect-in-get-handler

Disable nextjs-no-side-effect-in-get-handler for this line
Suggested change
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.

@NisargIO NisargIO force-pushed the fix/nextjs-get-handler-side-effects-206 branch from 71ecf0a to bd9778a Compare May 16, 2026 01:18
@NisargIO
Copy link
Copy Markdown
Member Author

Closing in favor of reopened #260 (same diff, same branch).

@NisargIO NisargIO closed this May 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

False positive: nextjs-no-side-effect-in-get-handler flags Response.headers.set() and similar Headers API calls

1 participant