diff --git a/packages/desktop/src/main/net/safe-fetch.ts b/packages/desktop/src/main/net/safe-fetch.ts index a0bf5dda..efffd3fa 100644 --- a/packages/desktop/src/main/net/safe-fetch.ts +++ b/packages/desktop/src/main/net/safe-fetch.ts @@ -29,7 +29,7 @@ export async function safeFetch(url: string, opts: SafeFetchOptions = {}): Promi const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), opts.timeoutMs ?? DEFAULT_TIMEOUT_MS); try { - const res = await net.fetch(url, { signal: controller.signal }); + const res = await net.fetch(url, { signal: controller.signal, redirect: 'error' }); if (!res.ok) throw new Error(`fetch ${url}: ${res.status}`); const cl = Number(res.headers.get('content-length') ?? '0'); if (cl > maxSize) throw new Error('response too large'); diff --git a/packages/desktop/test/safe-fetch.test.ts b/packages/desktop/test/safe-fetch.test.ts index 56c435b2..b9da307c 100644 --- a/packages/desktop/test/safe-fetch.test.ts +++ b/packages/desktop/test/safe-fetch.test.ts @@ -99,4 +99,12 @@ describe('safeFetch', () => { await expect(safeFetch('https://cdn.example.com/missing.png')).rejects.toThrow('404'); }); + + it('passes redirect:error to net.fetch to prevent SSRF bypass via 3xx', async () => { + assertNotPrivateHostMock.mockResolvedValue(undefined); + netFetchMock.mockResolvedValue(mockResponse(new ArrayBuffer(4))); + + await safeFetch('https://cdn.example.com/img.png'); + expect(netFetchMock).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ redirect: 'error' })); + }); });