Spaces:
Running
Running
| 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) | |
| }) | |
| }) | |