Public mode

No access to your tenant or devices. Every item links to its source. What this means

Sources need attention Customize Public modeDiscuss ChangeIntel on Discord

Effect Bug: `AsyncLocalStorage` context lost/contaminated inside Effect fibers under concurrent load with RPC

CVSS 3.1 7.4 HIGHEUVD CVSS 3.1 7.4 CVSS 3.1 · [email protected] · Secondary 7.4 HIGHCVSS_V3 Publisher rating HIGHFixed by effect 3.20.0KEV status unknown

cve-program · CVE-2026-32887

Published

Effect is a TypeScript framework that consists of several packages that work together to help build TypeScript applications. Prior to version 3.20.0, when using `RpcServer.toWebHandler` (or `HttpApp.toWebHandlerRuntime`) inside a Next.js App Router route handler, any Node.js `AsyncLocalStorage`-dependent API called from within an Effect fiber can read another concurrent request's context — or no context at all. Under production traffic, `auth()` from `@clerk/nextjs/server` returns a different user's session. Version 3.20.0 contains a fix for the issue.

Read the publisher's advisory

euvd · EUVD-2026-13818

Published

Effect is a TypeScript framework that consists of several packages that work together to help build TypeScript applications. Prior to version 3.20.0, when using `RpcServer.toWebHandler` (or `HttpApp.toWebHandlerRuntime`) inside a Next.js App Router route handler, any Node.js `AsyncLocalStorage`-dependent API called from within an Effect fiber can read another concurrent request's context — or no context at all. Under production traffic, `auth()` from `@clerk/nextjs/server` returns a different user's session. Version 3.20.0 contains a fix for the issue.

Read the publisher's advisory

nvd · CVE-2026-32887

Published

Effect is a TypeScript framework that consists of several packages that work together to help build TypeScript applications. Prior to version 3.20.0, when using `RpcServer.toWebHandler` (or `HttpApp.toWebHandlerRuntime`) inside a Next.js App Router route handler, any Node.js `AsyncLocalStorage`-dependent API called from within an Effect fiber can read another concurrent request's context — or no context at all. Under production traffic, `auth()` from `@clerk/nextjs/server` returns a different user's session. Version 3.20.0 contains a fix for the issue.

Read the publisher's advisory

osv · GHSA-38f7-945m-qr2g

Published

## 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 < toRun.length; i++) { toRun[i]() // ← Every fiber continuation runs in the SAME ALS context } } // ... } ``` `scheduleTask` only calls `starve()` when `running` is `false`. Subsequent tasks accumulate in `this.tasks` until `starveInternal` drains them all. The `Promise.then()` (or `setTimeout`) callback inherits the ALS context from whichever call site created it — i.e., whichever request's fiber first set `running = true`. **Result:** Under concurrent load, fiber continuations from Request A and Request B execute inside the same `starveInternal` call, sharing a single ALS context. If Request A triggered `starve()`, then Request B's fiber reads Request A's ALS context. #### 2. `toWebHandlerRuntime` does not propagate ALS (`@effect/platform/src/HttpApp.ts:211-240`) ```typescript export const toWebHandlerRuntime = <R>(runtime: Runtime.Runtime<R>) => { const httpRuntime: Types.Mutable<Runtime.Runtime<R>> = Runtime.make(runtime) const run = Runtime.runFork(httpRuntime) return <E>(self: Default<E, R | Scope.Scope>, middleware?) => { return (request: Request, context?): Promise<Response> => new Promise((resolve) => { // Per-request Effect context is correctly set via contextMap: const contextMap = new Map<string, any>(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<{ userId: string }>() 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-clerk-auth-user-id"] if (!userId) return yield* Effect.fail(new UnauthorizedError({ message: "Auth required" })) return userId }) ``` ## Suggested fix (for Effect maintainers) ### Option A: Propagate ALS context through the scheduler Capture the `AsyncLocalStorage` snapshot when a fiber continuation is scheduled, and restore it when the continuation executes: ```typescript // In MixedScheduler or the fiber runtime import { AsyncLocalStorage } from "node:async_hooks" scheduleTask(task: Task, priority: number) { // Capture current ALS context const snapshot = AsyncLocalStorage.snapshot() this.tasks.scheduleTask(() => snapshot(task), priority) // ... } ``` `AsyncLocalStorage.snapshot()` (Node.js 20.5+) returns a function that, when called, restores the ALS context from the point of capture. This ensures each fiber continuation runs with its originating request's ALS context. **Trade-off:** Adds one closure allocation per scheduled task. Could be opt-in via a `FiberRef` or scheduler option. ### Option B: Capture ALS at `runFork` and restore per fiber step When `Runtime.runFork` is called, capture the ALS snapshot and associate it with the fiber. Before each fiber step (in the fiber runtime's `evaluateEffect` loop), restore the snapshot. **Trade-off:** More invasive but provides correct ALS propagation for the fiber's entire lifetime, including across `flatMap` chains and `Effect.tryPromise` thunks. ### Option C: Document the limitation and provide a `context` injection API If ALS propagation is intentionally not supported, document this prominently and provide a first-class API for `toWebHandler` to accept per-request context. The existing `context?: Context.Context<never>` parameter on the handler function partially addresses this, but it requires callers to know about the issue and manually extract values before entering Effect. ## Related - Node.js `AsyncLocalStorage` docs: https://nodejs.org/api/async_context.html - `AsyncLocalStorage.snapshot()`: https://nodejs.org/api/async_context.html#static-method-asynclocalstoragesnapshot - Next.js uses ALS for `cookies()`, `headers()`, `auth()` in App Router - Similar issue pattern in other fiber-based runtimes (e.g., ZIO has `FiberRef` propagation for this) ## POC replica of my setup ``` // Create web handler from Effect RPC // sharedMemoMap ensures all RPC routes share the same connection pool const { handler: webHandler, dispose } = RpcServer.toWebHandler(DemoRpc, { layer: RpcLayer, memoMap: sharedMemoMap, }); /** * POST /api/rpc/demo */ export const POST = async (request: Request) => { return webHandler(request); }; registerDispose(dispose); ``` ### Used util functions ``` /** * Creates a dispose registry that collects dispose callbacks and runs them * when `runAll` is invoked. Handles both sync and async dispose functions, * catching errors to prevent one failing dispose from breaking others. * * @internal Exported for testing — use `registerDispose` in application code. */ export const makeDisposeRegistry = () => { const disposeFns: Array<() => void | Promise<void>> = [] const runAll = () => { for (const fn of disposeFns) { try { const result = fn() if (result && typeof result.then === "function") { result.then(undefined, (err: unknown) => console.error("Dispose error:", err)) } } catch (err) { console.error("Dispose error:", err) } } } const register = (dispose: () => void | Promise<void>) => { disposeFns.push(dispose) } return { register, runAll } } export const registerDispose: (dispose: () => void | Promise<void>) => void = globalValue( Symbol.for("@global/RegisterDispose"), () => { const registry = makeDisposeRegistry() if (typeof process !== "undefined") { process.once("beforeExit", registry.runAll) } return registry.register } ) ``` ### The actual effect that was run within the RPC context that the bug was found ``` export const getAuthenticatedUserId: Effect.Effect<string, UnauthorizedError> = Effect.gen(function*() { const authResult = yield* Effect.tryPromise({ try: async () => auth(), catch: () => new UnauthorizedError({ message: "Failed to get auth session" }) }) if (!authResult.userId) { return yield* Effect.fail( new UnauthorizedError({ message: "Authentication required" }) ) } return authResult.userId }) ```

Read the publisher's advisory

Affected versions, risk and source history

cve-program · CVE-2026-32887

Published

effect

Vendor product

Publisher's version information

Shown as published. These rules are not interpreted as NuGet package version ranges.

{
                    "vendor": "Effect-TS",
                    "product": "effect",
                    "versions": [
                        {
                            "version": "< 3.20.0",
                            "status": "affected"
                        }
                    ]
                }

Original source record · Cite this exact observation · Complete original publisher JSON

euvd · EUVD-2026-13818

Published

EUVD source date text has no stated timezone and is retained without inventing a UTC instant. Related bulletin membership is not an alias, common cause or applicability claim. A missing vector with a zero score is treated as unrated. Complete vendor/product and underlying source evidence remains in the original JSON.

Original publisher date fields

datePublished: Mar 20, 2026, 9:35:05 PM

dateUpdated: Mar 25, 2026, 1:37:15 PM

1 related source bulletins · separate from aliases

WID-SEC-W-2026-3046: IBM Concert: Mehrere Schwachstellen

CVE-2019-19921 · CVE-2021-23337 · CVE-2024-3651 · CVE-2024-39338 · CVE-2024-45590 · CVE-2025-13465 · CVE-2025-15284 · CVE-2025-15599 · CVE-2025-22874 · CVE-2025-47273 · CVE-2025-47907 · CVE-2025-52881 · CVE-2025-53643 · CVE-2025-55184 · CVE-2025-58056 · CVE-2025-58188 · CVE-2025-58189 · CVE-2025-61723 · CVE-2025-61730 · CVE-2025-64649 · CVE-2025-64756 · CVE-2025-67221 · CVE-2025-68121 · CVE-2025-68146 · CVE-2026-0540 · CVE-2026-2327 · CVE-2026-23490 · CVE-2026-23897 · CVE-2026-2391 · CVE-2026-23949 · CVE-2026-24486 · CVE-2026-25528 · CVE-2026-25547 · CVE-2026-25639 · CVE-2026-25990 · CVE-2026-26007 · CVE-2026-26019 · CVE-2026-26996 · CVE-2026-27142 · CVE-2026-27212 · CVE-2026-27699 · CVE-2026-27903 · CVE-2026-27904 · CVE-2026-28684 · CVE-2026-29063 · CVE-2026-29181 · CVE-2026-2950 · CVE-2026-31808 · CVE-2026-31900 · CVE-2026-32274 · CVE-2026-32288 · CVE-2026-32597 · CVE-2026-32630 · CVE-2026-32887 · CVE-2026-32952 · CVE-2026-33186 · CVE-2026-33532 · CVE-2026-33750 · CVE-2026-33814 · CVE-2026-33816 · CVE-2026-33916 · CVE-2026-34043 · CVE-2026-34073 · CVE-2026-34077 · CVE-2026-3520 · CVE-2026-35469 · CVE-2026-35515 · CVE-2026-3627 · CVE-2026-39823 · CVE-2026-39825 · CVE-2026-39826 · CVE-2026-39892 · CVE-2026-39984 · CVE-2026-40175 · CVE-2026-40179 · CVE-2026-40181 · CVE-2026-40190 · CVE-2026-40347 · CVE-2026-41066 · CVE-2026-41182 · CVE-2026-41238 · CVE-2026-41239 · CVE-2026-41240 · CVE-2026-41305 · CVE-2026-41324 · CVE-2026-41425 · CVE-2026-41481 · CVE-2026-41488 · CVE-2026-41602 · CVE-2026-41636 · CVE-2026-42151 · CVE-2026-42154 · CVE-2026-42198 · CVE-2026-42211 · CVE-2026-42338 · CVE-2026-42342 · CVE-2026-42561 · CVE-2026-42577 · CVE-2026-43870 · CVE-2026-44405 · CVE-2026-44431 · CVE-2026-44432 · CVE-2026-44486 · CVE-2026-44487 · CVE-2026-44488 · CVE-2026-44492 · CVE-2026-44494 · CVE-2026-44496 · CVE-2026-44728 · CVE-2026-4539 · CVE-2026-45409 · CVE-2026-45736 · CVE-2026-46625 · CVE-2026-4800 · CVE-2026-4923 · CVE-2026-4926 · CVE-2026-7246 · CVE-2026-8723

No normalized affected-package data is projected for this source revision. The original publisher record may contain additional product and version evidence; this does not establish that a package is unaffected.

Original source record · Cite this exact observation · Complete original publisher JSON

nvd · CVE-2026-32887

Published

NVD analysis and CPE criteria are source context, not proof that a package or your environment is affected. Complete affected data, logical configurations and SSVC are retained in the original JSON.

Source analysis status: Analyzed

Weakness classifications: CWE-362

Original publisher date fields

published: 2026-03-20T22:16:27.980

lastModified: 2026-06-17T10:36:30.770

1 source CPE criteria · context only
  • cpe:2.3:a:effectful:effect:*:*:*:*:*:node.js:*:*

The original JSON retains the logical configuration operators, exclusions and version bounds.

No normalized affected-package data is projected for this source revision. The original publisher record may contain additional product and version evidence; this does not establish that a package is unaffected.

Original source record · Cite this exact observation · Complete original publisher JSON

osv · GHSA-38f7-945m-qr2g

Published

effect

npm

SEMVER · introduced 0 → fixed 3.20.0

Original source record · Cite this exact observation · Complete original publisher JSON

ChangeIntel

An IT change radar: releases, security, known issues, retirements, documentation changes, and service status from public sources. Every item links to supporting evidence; dates and statuses can change after they are read.

Sources read 15 Nov 21:13 UTC · 254 of 275 readable · 250 fully read · documentation 469/469 current