GitHub Advisoryadvisory · npm

GHSA-38f7-945m-qr2g Effect `AsyncLocalStorage` context lost/contaminated inside Effect fibers under concurrent load with RPC

HIGH Published 2026-03-20 Modified 2026-03-25 Official page ↗
Aliases
CVE-2026-32887
Ecosystem
npm
Packages
effect
Severity
High
CVSS vector
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N
Weaknesses
CWE-362
Reviewed
2026-03-20

Affected packages

• npm effect: < 3.20.0 → fixed in 3.20.0

Description

Versions • `effect`: 3.19.15 • `@effect/rpc`: 0.72.1 • `@effect/platform`: 0.94.2 • Node.js: v22.20.0 • Vercel runtime with Fluid compute • Next.js: 16 (App Router) • `@clerk/nextjs`: 6.x Root cause Effect's `MixedScheduler` batches fiber continuations and drains them inside a single microtask or timer callback. The `AsyncLocalStorage` context active during that callback belongs to whichever request first triggered the scheduler's drain cycle — not the request that owns the fiber being resumed. Detailed mechanism 1. Scheduler batching (`effect/src/Scheduler.ts`, `MixedScheduler`) ```typescript // MixedScheduler.starve() — called once when first task is scheduled private starve(depth = 0) { if (depth >= this.maxNextTickBeforeTimer) { setTimeout(() => this.starveInternal(0), 0) // timer queue } else { Promise.resolve(void 0).then(() => this.starveInternal(depth + 1)) // microtask queue } } // MixedScheduler.starveInternal() — drains ALL accumulated tasks in one call private starveInternal(depth: number) { const tasks = this.tasks.buckets this.tasks.buckets = [] for (const [_, toRun] of tasks) { for (let i = 0; i (runtime: Runtime.Runtime) => { const httpRuntime: Types.Mutable> = Runtime.make(runtime) const run = Runtime.runFork(httpRuntime) return (self: Default, middleware?) => { return (request: Request, context?): Promise => new Promise((resolve) => { // Per-request Effect context is correctly set via contextMap: const contextMap = new Map(runtime.context.unsafeMap) const httpServerRequest = ServerRequest.fromWeb(request) contextMap.set(ServerRequest.HttpServerRequest.key, httpServerRequest) httpRuntime.context = Context.unsafeMake(contextMap) // But the fiber is forked without any ALS propagation: const fiber = run(httpApp as any) // ← ALS context is NOT captured or restored }) } } ``` Effect's own `Context` (containing `HttpServerRequest`) is correctly set per-request. But the Node.js ALS context — which frameworks like Next.js, Clerk, and OpenTelemetry rely on — is not captured at fork time or restored when the fiber's continuations execute. 3. The dangerous pattern this enables ```typescript // RPC handler — runs inside an Effect fiber const handler = Effect.gen(function*() { // This calls auth() from @clerk/nextjs/server, which reads from ALS const { userId } = yield* Effect.tryPromise({ try: async () => auth(), // ← may read WRONG user's session catch: () => new UnauthorizedError({ message: "Auth failed" }) }) return yield* repository.getUser(userId) }) ``` The `async () => auth()` thunk executes when the fiber continuation is scheduled by `MixedScheduler`. At that point, the ALS context belongs to an arbitrary concurrent request. Reproduction scenario ``` Timeline (two concurrent requests to the same toWebHandler endpoint): T0: Request A arrives → POST handler → webHandler(requestA) → Promise executor runs synchronously → httpRuntime.context set to A's context → fiber A forked, runs first ops synchronously → fiber A yields (e.g., at Effect.tryPromise boundary) → scheduler.scheduleTask(fiberA_continuation) → running=false → starve() called → Promise.resolve().then(drain) ↑ ALS context captured = Request A's context T1: Request B arrives → POST handler → webHandler(requestB) → Promise executor runs synchronously → httpRuntime.context set to B's context → fiber B forked, runs first ops synchronously → fiber B yields → scheduler.scheduleTask(fiberB_continuation) → running=true → task queued, no new starve() T2: Microtask fires → starveInternal() runs → Drains fiberA_continuation → auth() reads ALS → gets A's context ✓ → Drains fiberB_continuation → auth() reads ALS → gets A's context ✗ ← WRONG USER ``` Minimal reproduction ```typescript import { AsyncLocalStorage } from "node:async_hooks" import { Effect, Layer } from "effect" import { RpcServer, RpcSerialization, Rpc, RpcGroup } from "@effect/rpc" import { HttpServer } from "@effect/platform" import * as S from "effect/Schema" // Simulate a framework's ALS (like Next.js / Clerk) const requestStore = new AsyncLocalStorage() class GetUser extends Rpc.make("GetUser", { success: S.Struct({ userId: S.String, alsUserId: S.String }), failure: S.Never, payload: {} }) {} const MyRpc = RpcGroup.make("MyRpc").add(GetUser) const MyRpcLive = MyRpc.toLayer( RpcGroup.toHandlers(MyRpc, { GetUser: () => Effect.gen(function*() { // Simulate calling an ALS-dependent API inside an Effect fiber const alsResult = yield* Effect.tryPromise({ try: async () => { const store = requestStore.getStore() return store?.userId ?? "NONE" }, catch: () => { throw new Error("impossible") } }) return { userId: "from-effect-context", alsUserId: alsResult } }) }) ) const RpcLayer = MyRpcLive.pipe( Layer.provideMerge(RpcSerialization.layerJson), Layer.provideMerge(HttpServer.layerContext) ) const { handler } = RpcServer.toWebHandler(MyRpc, { layer: RpcLayer }) // Simulate two concurrent requests with different ALS contexts async function main() { const results = await Promise.all([ requestStore.run({ userId: "user-A" }, () => handler(makeRpcRequest("GetUser"))), requestStore.run({ userId: "user-B" }, () => handler(makeRpcRequest("GetUser"))), ]) // Parse responses and check if alsUserId matches the expected user // Under the bug: both responses may show "user-A" (or one shows the other's) for (const res of results) { console.log(await res.json()) } } ``` Impact | Symptom | Severity | |---------|----------| | `auth()` returns wrong user's session | Critical — authentication bypass | | `cookies()` / `headers()` from Next.js read wrong request | High — data leakage | | OpenTelemetry trace context crosses requests | Medium — incorrect traces | | Works locally, fails in production | Hard to diagnose — only manifests under concurrent load | Workaround Capture ALS-dependent values before entering the Effect runtime and pass them via Effect's own context system: ```typescript // In the route handler — OUTSIDE the Effect fiber (ALS is correct here) export const POST = async (request: Request) => { const { userId } = await auth() // ← Safe: still in Next.js ALS context // Inject into request headers or use the `context` parameter const headers = new Headers(request.headers) headers.set("x-clerk-auth-user-id", userId ?? "") const enrichedRequest = new Request(request.url, { method: request.method, headers, body: request.body, duplex: "half" as any, }) return webHandler(enrichedRequest) } // In Effect handlers — read from HttpServerRequest headers instead of calling auth() const getAuthenticatedUserId = Effect.gen(function*() { const req = yield* HttpServerRequest.HttpServerRequest const userId = req.headers["x-c…

Related

Alias · CVE (1)

  • CVE-2026-32887 Effect is a TypeScript framework that consists of several packages that work together to help build TypeScript applications. vulnerability

Weakness · CWE (1)

  • CWE-362 Concurrent Execution using Shared Resource with Improper Synchronization weakness

References