Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion scripts/test-sandbox-playground.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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' } })
}
}
}
})
6 changes: 4 additions & 2 deletions worker/sandbox-proxy.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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')
Expand Down
Loading