diff --git a/frontend/src/__tests__/liveUamapProxyBoundary.test.ts b/frontend/src/__tests__/liveUamapProxyBoundary.test.ts new file mode 100644 index 0000000..de2598a --- /dev/null +++ b/frontend/src/__tests__/liveUamapProxyBoundary.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import type { NextRequest } from 'next/server'; + +import { proxy } from '@/proxy'; + +function request(path: string, headers: Record): NextRequest { + return { + nextUrl: { pathname: path }, + headers: new Headers(headers), + } as unknown as NextRequest; +} + +describe('LiveUAMap privileged proxy boundary', () => { + it('rejects a cross-site browser opt-in request', async () => { + const response = proxy( + request('/api/liveuamap/scraper-opt-in', { + host: 'localhost:3000', + origin: 'https://evil.example', + 'sec-fetch-site': 'cross-site', + }), + ); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toEqual({ + detail: 'Cross-origin privileged request denied', + }); + }); + + it('allows the same-origin dashboard request without adding page CSP', () => { + const response = proxy( + request('/api/liveuamap/scraper-opt-in', { + host: 'localhost:3000', + origin: 'http://localhost:3000', + 'sec-fetch-site': 'same-origin', + }), + ); + + expect(response.status).toBe(200); + expect(response.headers.get('content-security-policy')).toBeNull(); + }); + + it('also protects the provider status endpoint from cross-site browser reads', () => { + const response = proxy( + request('/api/liveuamap/scraper-status', { + host: 'localhost:3000', + 'sec-fetch-site': 'cross-site', + }), + ); + + expect(response.status).toBe(403); + }); +}); diff --git a/frontend/src/proxy.ts b/frontend/src/proxy.ts index d5327a2..c3ec932 100644 --- a/frontend/src/proxy.ts +++ b/frontend/src/proxy.ts @@ -43,6 +43,8 @@ function isPrivilegedApiPath(pathname: string): boolean { path === '/api/debug-latest' || path === '/api/system/update' || path === '/api/layers' || + path === '/api/liveuamap' || + path.startsWith('/api/liveuamap/') || path === '/api/ais/feed' || path === '/api/mesh/infonet/ingest' || path === '/api/mesh/meshtastic/send' ||