diff --git a/scripts/test-sandbox-playground.mjs b/scripts/test-sandbox-playground.mjs index 86c68fbf..1d8a1ea6 100644 --- a/scripts/test-sandbox-playground.mjs +++ b/scripts/test-sandbox-playground.mjs @@ -12,7 +12,7 @@ test('proxy forwards only fixed control routes and never forwards cookies', asyn let seen const r = await sandboxProxy(req('/control/v2/templates', { headers: { Cookie: 'private-cookie' } }), async (url, options) => { seen = { url, options }; return Response.json([{ templateID: 'one' }]) }) assert.equal(seen.url, 'https://sandbox.sandbase.ai/v2/templates'); assert.equal(seen.options.headers.get('X-API-Key'), key) - assert.equal(seen.options.headers.get('Cookie'), null); assert.equal(seen.options.redirect, 'error'); assert.equal(r.headers.get('Cache-Control'), 'no-store') + assert.equal(seen.options.headers.get('Cookie'), null); assert.equal(seen.options.redirect, 'manual'); assert.equal(r.headers.get('Cache-Control'), 'no-store') assert.equal((await r.json())[0].templateID, 'one') }) test('rejects cross-site calls, missing auth, unexpected routes and query injection before network', async () => { @@ -165,3 +165,23 @@ test('template picker uses only v2, follows pagination, and selects only ready b const denied = await sandboxProxy(req('/control/templates'), () => { throw new Error('legacy upstream must not be called') }) assert.equal(denied.status, 404) }) + + +test('redirects are rejected on control, access checks and data requests without forwarding Location', async () => { + for (const path of ['/control/v2/templates', '/data/sbx-owned/files?path=/tmp/x']) { + for (const status of [301, 302, 303, 307, 308]) { + const stages = path.startsWith('/data/') ? [1, 2] : [1] + for (const redirectAt of stages) { + let calls = 0 + const r = await sandboxProxy(req(path, { headers: { 'X-Access-Token': 'synthetic-token' } }), async (_url, options) => { + assert.equal(options.redirect, 'manual') + if (++calls < redirectAt) return Response.json({ state: 'running' }) + return new Response('private upstream body', { status, headers: { Location: 'https://untrusted.example/', 'Set-Cookie': 'private' } }) + }) + assert.equal(r.status, 502); assert.equal(calls, redirectAt) + assert.equal(r.headers.get('Location'), null); assert.equal(r.headers.get('Set-Cookie'), null) + assert.deepEqual(await r.json(), { error: { code: 'upstream_redirect_rejected' } }) + } + } + } +}) diff --git a/worker/sandbox-proxy.js b/worker/sandbox-proxy.js index 6cba673e..acaebf1e 100644 --- a/worker/sandbox-proxy.js +++ b/worker/sandbox-proxy.js @@ -55,7 +55,8 @@ export async function sandboxProxy(request, fetcher = fetch) { const [, id, action] = match, token = request.headers.get('X-Access-Token') if (!token || token.length > 8192) return error(401, 'connect_required') // Recheck the supplied customer's ownership before forwarding to any data host. - const check = await fetcher(`${UPSTREAM}/sandboxes/${id}`, { headers: { 'X-API-Key': key }, redirect: 'error', signal: AbortSignal.timeout(15000) }) + const check = await fetcher(`${UPSTREAM}/sandboxes/${id}`, { headers: { 'X-API-Key': key }, redirect: 'manual', signal: AbortSignal.timeout(15000) }) + if (check.status >= 300 && check.status < 400) { await check.body?.cancel(); return error(502, 'upstream_redirect_rejected') } if (!check.ok) { await check.body?.cancel(); return error(check.status, 'sandbox_access_denied') } const info = await check.json() if (info.state !== 'running') return error(409, 'sandbox_not_running') @@ -78,7 +79,8 @@ export async function sandboxProxy(request, fetcher = fetch) { } } } - const upstream = await fetcher(target, { method, headers, body, redirect: 'error', signal: AbortSignal.timeout(35000) }) + const upstream = await fetcher(target, { method, headers, body, redirect: 'manual', signal: AbortSignal.timeout(35000) }) + if (upstream.status >= 300 && upstream.status < 400) { await upstream.body?.cancel(); return error(502, 'upstream_redirect_rejected') } if (!upstream.ok) { await upstream.body?.cancel(); return error(upstream.status, `upstream_http_${upstream.status}`) } const outHeaders = new Headers(responseHeaders) outHeaders.set('Content-Type', upstream.headers.get('Content-Type') || 'application/octet-stream')