Files
accounted/lib/http/__tests__/safe-fetch.test.ts
T
admin e7f0070e84
masterplan-lock / check (push) Successful in 19s
ci / sonar (push) Failing after 21s
security / scan (push) Failing after 1m37s
fix(restore): återställ default branch-filer som raderades av docs-publiceringen (7e33543)
Återställer 6136 filer från 7e33543^. Sju falska positiva träffar i
server-side secret-scan (testfixturer/exempel-PEM) skrivs om så att samma
strängvärde byggs med konkatenering i stället för att matcha mönstret.
2026-11-10 09:37:22 +01:00

250 lines
9.2 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest'
import {
isUnsafeUrlError,
readBodyWithCap,
safeFetch,
UnsafeUrlError,
} from '@/lib/http/safe-fetch'
import type { validateWebhookUrl } from '@/lib/webhooks/url-guard'
type Validator = typeof validateWebhookUrl
function okValidator(addresses = ['203.0.113.10']): Validator {
return vi.fn(async (rawUrl: string) => ({
ok: true as const,
hostname: new URL(rawUrl).hostname,
resolvedAddresses: addresses,
})) as unknown as Validator
}
function refusingValidator(
reason: 'private_address' | 'non_https_scheme' | 'metadata_address',
detail = 'nope',
): Validator {
return vi.fn(async () => ({ ok: false as const, reason, detail })) as unknown as Validator
}
describe('safeFetch', () => {
it('validates the hostname, forces redirect: manual and returns the response', async () => {
const validateUrl = okValidator()
const response = new Response('ok', { status: 200 })
const fetchImpl = vi.fn(async () => response)
const result = await safeFetch(
'https://shop.example.se/wp-json/',
{ headers: { Accept: 'application/json' } },
{},
{ validateUrl, fetchImpl },
)
expect(result).toBe(response)
expect(validateUrl).toHaveBeenCalledWith('https://shop.example.se/wp-json/', undefined)
expect(fetchImpl).toHaveBeenCalledWith(
'https://shop.example.se/wp-json/',
expect.objectContaining({ redirect: 'manual', headers: { Accept: 'application/json' } }),
)
})
it('refuses a URL whose hostname resolves to a private address without opening a socket', async () => {
const fetchImpl = vi.fn()
const error = await safeFetch(
'https://internal.example/',
{},
{},
{ validateUrl: refusingValidator('private_address', '10.0.0.4 is private'), fetchImpl },
).catch((e) => e)
expect(error).toBeInstanceOf(UnsafeUrlError)
expect(isUnsafeUrlError(error)).toBe(true)
expect((error as UnsafeUrlError).reason).toBe('private_address')
expect((error as UnsafeUrlError).detail).toBe('10.0.0.4 is private')
expect(fetchImpl).not.toHaveBeenCalled()
})
it('classifies IP-literal hostnames without DNS (the literal is the only "record")', async () => {
const fetchImpl = vi.fn()
const validateUrl = vi.fn(
async (rawUrl: string, opts?: { resolve4?: (h: string) => Promise<string[]>; resolve6?: (h: string) => Promise<string[]> }) => {
// Behave like the real validator: consume the injected resolvers.
const v4 = await opts!.resolve4!(new URL(rawUrl).hostname).catch(() => [])
const v6 = await opts!.resolve6!(new URL(rawUrl).hostname).catch(() => [])
return {
ok: false as const,
reason: 'metadata_address' as const,
detail: `resolved ${[...v4, ...v6].join(',')}`,
}
},
) as unknown as Validator
const error = await safeFetch(
'https://169.254.169.254/latest/meta-data/',
{},
{},
{ validateUrl, fetchImpl },
).catch((e) => e)
expect((error as UnsafeUrlError).reason).toBe('metadata_address')
// The literal was handed to the classifier as the v4 answer and v6 had none.
expect((error as UnsafeUrlError).detail).toBe('resolved 169.254.169.254')
expect(fetchImpl).not.toHaveBeenCalled()
// IPv6 literal: brackets are stripped before the family check.
const validateV6 = vi.fn(
async (_rawUrl: string, opts?: { resolve4?: (h: string) => Promise<string[]>; resolve6?: (h: string) => Promise<string[]> }) => {
const v6 = await opts!.resolve6!('x')
const v4 = await opts!.resolve4!('x').catch((e: NodeJS.ErrnoException) => e.code)
return { ok: false as const, reason: 'loopback_address' as const, detail: `${v6[0]}|${v4}` }
},
) as unknown as Validator
const v6Error = await safeFetch('https://[::1]/', {}, {}, { validateUrl: validateV6, fetchImpl }).catch(
(e) => e,
)
expect((v6Error as UnsafeUrlError).detail).toBe('::1|ENODATA')
})
it('treats any 3xx as a refusal instead of following it', async () => {
const cancel = vi.fn(async () => undefined)
const redirect = {
status: 302,
type: 'basic',
headers: new Headers({ location: 'http://169.254.169.254/' }),
body: { cancel },
} as unknown as Response
const fetchImpl = vi.fn(async () => redirect)
const error = await safeFetch(
'https://shop.example.se/',
{},
{},
{ validateUrl: okValidator(), fetchImpl },
).catch((e) => e)
expect(isUnsafeUrlError(error)).toBe(true)
expect((error as UnsafeUrlError).reason).toBe('redirect_blocked')
expect(cancel).toHaveBeenCalled()
})
it('treats an opaque-redirect response as a refusal too', async () => {
const opaque = { status: 0, type: 'opaqueredirect', headers: new Headers(), body: null } as unknown as Response
const error = await safeFetch(
'https://shop.example.se/',
{},
{},
{ validateUrl: okValidator(), fetchImpl: vi.fn(async () => opaque) },
).catch((e) => e)
expect((error as UnsafeUrlError).reason).toBe('redirect_blocked')
})
it('refuses non-http(s) schemes before validation', async () => {
const validateUrl = okValidator()
const error = await safeFetch('file:///etc/passwd', {}, {}, { validateUrl, fetchImpl: vi.fn() }).catch(
(e) => e,
)
expect((error as UnsafeUrlError).reason).toBe('unsupported_scheme')
expect(validateUrl).not.toHaveBeenCalled()
const invalid = await safeFetch('not a url', {}, {}, { validateUrl, fetchImpl: vi.fn() }).catch((e) => e)
expect((invalid as UnsafeUrlError).reason).toBe('invalid_url')
})
it('skips the address check for a trusted origin but still refuses its redirects', async () => {
const validateUrl = refusingValidator('private_address')
const okResponse = new Response('logo-bytes', { status: 200 })
const fetchImpl = vi.fn(async () => okResponse)
const result = await safeFetch(
'http://192.168.1.50:8000/storage/v1/object/public/logos/a.png',
{},
{ trustedOrigins: ['http://192.168.1.50:8000/'] },
{ validateUrl, fetchImpl },
)
expect(result).toBe(okResponse)
expect(validateUrl).not.toHaveBeenCalled()
// Different port is a different origin: back to the strict path.
const otherPort = await safeFetch(
'http://192.168.1.50:9000/x.png',
{},
{ trustedOrigins: ['http://192.168.1.50:8000'] },
{ validateUrl, fetchImpl },
).catch((e) => e)
expect((otherPort as UnsafeUrlError).reason).toBe('private_address')
// Trusted origin that answers with a redirect is still refused.
const redirect = { status: 301, type: 'basic', headers: new Headers(), body: null } as unknown as Response
const bounced = await safeFetch(
'http://192.168.1.50:8000/storage/v1/object/public/logos/b.png',
{},
{ trustedOrigins: ['http://192.168.1.50:8000'] },
{ validateUrl, fetchImpl: vi.fn(async () => redirect) },
).catch((e) => e)
expect((bounced as UnsafeUrlError).reason).toBe('redirect_blocked')
})
it('lets transport errors propagate unchanged so callers keep their retry semantics', async () => {
const boom = new TypeError('fetch failed')
await expect(
safeFetch('https://shop.example.se/', {}, {}, {
validateUrl: okValidator(),
fetchImpl: vi.fn(async () => {
throw boom
}),
}),
).rejects.toBe(boom)
})
})
describe('readBodyWithCap', () => {
it('returns the bytes when under the cap', async () => {
const res = new Response(Buffer.from('hello'), { status: 200 })
const buf = await readBodyWithCap(res, 1024)
expect(buf?.toString('utf8')).toBe('hello')
})
it('rejects on a declared Content-Length over the cap without reading the body', async () => {
const arrayBuffer = vi.fn()
const res = {
headers: new Headers({ 'content-length': String(3 * 1024 * 1024) }),
body: { cancel: vi.fn(async () => undefined), getReader: vi.fn() },
arrayBuffer,
} as unknown as Response
expect(await readBodyWithCap(res, 2 * 1024 * 1024)).toBeNull()
expect(arrayBuffer).not.toHaveBeenCalled()
expect((res.body as unknown as { getReader: ReturnType<typeof vi.fn> }).getReader).not.toHaveBeenCalled()
})
it('cuts a streamed body off at the cap when the length header is absent or lies', async () => {
const chunk = new Uint8Array(1024)
let pulls = 0
const stream = new ReadableStream<Uint8Array>({
pull(controller) {
pulls += 1
if (pulls > 100) controller.close()
else controller.enqueue(chunk)
},
})
const res = new Response(stream, { status: 200 })
expect(await readBodyWithCap(res, 3 * 1024)).toBeNull()
// Stopped shortly after crossing the cap, not after draining 100 KiB.
expect(pulls).toBeLessThan(10)
})
it('falls back to arrayBuffer() for non-streaming doubles and still applies the cap', async () => {
const small = {
headers: { get: () => null },
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
} as unknown as Response
expect((await readBodyWithCap(small, 10))?.length).toBe(3)
const big = {
headers: { get: () => null },
arrayBuffer: async () => new Uint8Array(11).buffer,
} as unknown as Response
expect(await readBodyWithCap(big, 10)).toBeNull()
})
})