Spaces:
Running
Running
File size: 3,502 Bytes
837c968 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 | import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { buildTargetUrl } from './proxy.js'
const BACKEND = 'https://real-backend.hf.space'
/**
* Build a minimal mock request object that mirrors what Vercel passes to
* serverless handlers β just enough for buildTargetUrl to work.
*/
function fakeReq(path) {
return { url: `/api/proxy?path=${encodeURIComponent(path)}` }
}
beforeAll(() => {
process.env.API_PROXY_TARGET = BACKEND
})
afterAll(() => {
delete process.env.API_PROXY_TARGET
})
// ββ Payloads that MUST be allowed (legitimate routes) ββββββββββββ
describe('buildTargetUrl β allowed paths', () => {
const allowed = [
['auth/login', `${BACKEND}/auth/login`],
['tests/5/results', `${BACKEND}/tests/5/results`],
// double-slash is treated as a same-origin relative path
['//attacker.example.com/steal', `${BACKEND}/attacker.example.com/steal`],
// bare hostname without scheme is a normal path segment
['evil.com/steal', `${BACKEND}/evil.com/steal`],
// hostname with userinfo-style @ is just a path segment
['real-backend.hf.space@attacker.example.com/steal', `${BACKEND}/real-backend.hf.space@attacker.example.com/steal`],
// ISO timestamp with colon should not false-positive
['2026-07-15T10:00:00/results', `${BACKEND}/2026-07-15T10:00:00/results`],
]
it.each(allowed)('allows "%s" β %s', (path, expectedHref) => {
const target = buildTargetUrl(fakeReq(path))
expect(target.href).toBe(expectedHref)
})
})
// ββ Payloads that MUST be blocked (SSRF attempts) ββββββββββββββββ
describe('buildTargetUrl β blocked paths', () => {
const blocked = [
'http://attacker.example.com/steal',
'HTTP://attacker.example.com/steal',
'https://attacker.example.com/steal',
'javascript:alert(1)',
'data:text/html,evil',
'ftp://attacker.example.com/exfil',
// double backslash β WHATWG URL parser treats \\ as // for special
// schemes, so this resolves as a network-path reference with
// attacker.example.com as the host. Layer 1 (regex) does not catch
// this; layer 2 (origin check) does.
'\\\\attacker.example.com/steal',
// null-byte / control-character smuggling attempts
'ht\ttp://attacker.example.com/steal',
]
it.each(blocked)('blocks "%s"', (path) => {
expect(() => buildTargetUrl(fakeReq(path))).toThrow('Invalid proxy path')
})
})
// ββ Edge cases βββββββββββββββββββββββββββββββββββββββββββββββββββ
describe('buildTargetUrl β edge cases', () => {
it('rejects when API_PROXY_TARGET is not set', () => {
const saved = process.env.API_PROXY_TARGET
delete process.env.API_PROXY_TARGET
delete process.env.BACKEND_URL
try {
expect(() => buildTargetUrl(fakeReq('auth/login'))).toThrow(
'API_PROXY_TARGET is not configured',
)
} finally {
process.env.API_PROXY_TARGET = saved
}
})
it('passes through extra query params', () => {
const req = { url: '/api/proxy?path=tests&page=2&limit=10' }
const target = buildTargetUrl(req)
expect(target.searchParams.get('page')).toBe('2')
expect(target.searchParams.get('limit')).toBe('10')
})
it('handles empty path gracefully', () => {
const target = buildTargetUrl(fakeReq(''))
expect(target.origin).toBe(new URL(BACKEND).origin)
})
})
|