diff --git a/frontend/src/hooks.server.test.ts b/frontend/src/hooks.server.test.ts index 0892214..525e624 100644 --- a/frontend/src/hooks.server.test.ts +++ b/frontend/src/hooks.server.test.ts @@ -90,6 +90,114 @@ describe('hooks.server handle', () => { } }); + it('should still return 502 for ordinary network errors (not 413)', async () => { + // Regression guard: error jaringan biasa (fetch failed) TIDAK Boleh + // diklasifikasikan 413 — harus tetap 502 "Backend tidak tersedia". + const arrayBufferMock = vi.fn().mockRejectedValue(new TypeError('fetch failed')); + const mockEvent: any = { + request: { + method: 'POST', + headers: new Headers({ 'content-type': 'application/json' }), + arrayBuffer: arrayBufferMock + }, + url: new URL('https://sinau-c-dev.manakin-gentoo.ts.net/api/content/assets/upload') + }; + + const origFetch = global.fetch; + global.fetch = vi.fn() as any; + + try { + const resolve = vi.fn(); + const response = await handle({ event: mockEvent, resolve }); + + expect(response.status).toBe(502); + const body = await response.json(); + expect(body.success).toBe(false); + expect(body.message).toBe('Backend tidak tersedia'); + expect(global.fetch).not.toHaveBeenCalled(); + } finally { + global.fetch = origFetch; + } + }); + + it('should return 413 when error has status 413 even without "exceeds limit" message', async () => { + // Deteksi via err.status === 413 harus bekerja tanpa regex pesan + // (pesan library bisa berubah; status code adalah kontrak). + const arrayBufferMock = vi.fn().mockRejectedValue( + Object.assign(new Error('request body too large'), { status: 413 }) + ); + const mockEvent: any = { + request: { + method: 'POST', + headers: new Headers({ 'content-type': 'application/json' }), + arrayBuffer: arrayBufferMock + }, + url: new URL('https://sinau-c-dev.manakin-gentoo.ts.net/api/content/assets/upload') + }; + + const origFetch = global.fetch; + global.fetch = vi.fn() as any; + + try { + const resolve = vi.fn(); + const response = await handle({ event: mockEvent, resolve }); + + expect(response.status).toBe(413); + const body = await response.json(); + expect(body.success).toBe(false); + expect(body.message).toMatch(/terlalu besar/i); + } finally { + global.fetch = origFetch; + } + }); + + it('should add restrictive CSP when proxying SVG assets (stored-XSS mitigasi)', async () => { + // SVG bisa memuat ', { + status: 200, + headers: { 'content-type': 'image/svg+xml' } + }) + ) as any; + + try { + const resolve = vi.fn(); + const response = await handle({ event: mockEvent, resolve }); + expect(response.status).toBe(200); + expect(response.headers.get('content-security-policy')).toBe("default-src 'none'; sandbox"); + } finally { + global.fetch = origFetch; + } + }); + + it('should NOT add CSP for non-SVG assets', async () => { + const mockEvent: any = { + request: { method: 'GET', headers: new Headers() }, + url: new URL('https://sinau-c-dev.manakin-gentoo.ts/assets/diagram.png') + }; + const origFetch = global.fetch; + global.fetch = vi.fn().mockResolvedValue( + new Response('binary', { status: 200, headers: { 'content-type': 'image/png' } }) + ) as any; + + try { + const resolve = vi.fn(); + const response = await handle({ event: mockEvent, resolve }); + expect(response.status).toBe(200); + expect(response.headers.get('content-security-policy')).toBeNull(); + } finally { + global.fetch = origFetch; + } + }); + it('should read body as arrayBuffer when proxying POST requests', async () => { const arrayBufferMock = vi.fn().mockResolvedValue(new Uint8Array([1, 2, 3]).buffer); const mockEvent: any = { diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index 31ebdf4..4c9eeca 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -73,6 +73,16 @@ export const handle: Handle = async ({ event, resolve }) => { const setCookie = res.headers.get('set-cookie'); if (setCookie) resHeaders['set-cookie'] = setCookie; + // Mitigasi stored-XSS: SVG asset bisa memuat ' + ) + resp = client.get("/assets/malware.svg") + assert resp.status_code == 200 + assert resp.headers.get("Content-Security-Policy") == "default-src 'none'; sandbox" + + +def test_serve_raster_image_no_csp(client): + """Gambar raster (PNG) tidak diberi CSP — hanya SVG yang berisiko.""" + (TEST_ASSETS / "normal.png").write_bytes(b"\x89PNG\r\n\x1a\n") + resp = client.get("/assets/normal.png") + assert resp.status_code == 200 + assert "Content-Security-Policy" not in resp.headers +