GitHub Advisoryadvisory · npm
GHSA-38f7-945m-qr2g Effect `AsyncLocalStorage` context lost/contaminated inside Effect fibers under concurrent load with RPC
HIGH
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