|
|
@@ -6,6 +6,7 @@ import WebRuntime from '@deepseek-ai/dsh-web'
|
|
|
import { HttpFetchProvider, LOCAL_FETCH_PROVIDER_ID } from '@deepseek-ai/dsh-web-fetch-http'
|
|
|
import type { HttpFetchLimits } from '@deepseek-ai/dsh-web-fetch-http'
|
|
|
import * as fetchPlugin from '@deepseek-ai/dsh-web-fetch-http'
|
|
|
+import { createPinnedLookup, isPublicIpAddress, publicHttpNetwork, requestPinned, resolvePublicAddresses } from '../src/network.ts'
|
|
|
import { classifyContentType, decoderForCharset, isSameOrigin, parseCharset, validateFetchUrl } from '../src/policy.ts'
|
|
|
|
|
|
const limits: HttpFetchLimits = {
|
|
|
@@ -22,6 +23,7 @@ type Handler = (req: IncomingMessage, res: ServerResponse) => void
|
|
|
let server: Server
|
|
|
let base: string
|
|
|
let handler: Handler
|
|
|
+let restoreResolution: () => void
|
|
|
|
|
|
beforeEach(async () => {
|
|
|
handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('default') }
|
|
|
@@ -29,10 +31,13 @@ beforeEach(async () => {
|
|
|
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
|
|
|
const { port } = server.address() as AddressInfo
|
|
|
base = `http://127.0.0.1:${port}`
|
|
|
+ const spy = vi.spyOn(publicHttpNetwork, 'resolve').mockResolvedValue([{ address: '127.0.0.1', family: 4 }])
|
|
|
+ restoreResolution = () => { spy.mockRestore() }
|
|
|
})
|
|
|
|
|
|
afterEach(async () => {
|
|
|
vi.unstubAllGlobals()
|
|
|
+ vi.restoreAllMocks()
|
|
|
await new Promise<void>(resolve => server.close(() => { resolve() }))
|
|
|
})
|
|
|
|
|
|
@@ -78,6 +83,131 @@ describe('policy helpers', () => {
|
|
|
})
|
|
|
})
|
|
|
|
|
|
+describe('public-network policy', () => {
|
|
|
+ it('accepts only globally reachable unicast addresses', () => {
|
|
|
+ for (const address of ['8.8.8.8', '2001:4860:4860::8888', '::ffff:8.8.8.8']) {
|
|
|
+ expect(isPublicIpAddress(address), address).toBe(true)
|
|
|
+ }
|
|
|
+ for (const address of [
|
|
|
+ '0.0.0.0',
|
|
|
+ '10.0.0.1',
|
|
|
+ '100.64.0.1',
|
|
|
+ '127.0.0.1',
|
|
|
+ '169.254.169.254',
|
|
|
+ '192.0.2.1',
|
|
|
+ '224.0.0.1',
|
|
|
+ '255.255.255.255',
|
|
|
+ '::',
|
|
|
+ '::1',
|
|
|
+ 'fe80::1',
|
|
|
+ 'fc00::1',
|
|
|
+ 'ff02::1',
|
|
|
+ '::ffff:127.0.0.1',
|
|
|
+ '64:ff9b::808:808',
|
|
|
+ 'not-an-ip',
|
|
|
+ ]) {
|
|
|
+ expect(isPublicIpAddress(address), address).toBe(false)
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ it('retains one fully public DNS answer set', async () => {
|
|
|
+ const resolver = vi.fn(async () => [
|
|
|
+ { address: '8.8.4.4', family: 4 },
|
|
|
+ { address: '2001:4860:4860::8888', family: 6 },
|
|
|
+ ])
|
|
|
+ await expect(resolvePublicAddresses('example.test', new AbortController().signal, resolver))
|
|
|
+ .resolves.toEqual([
|
|
|
+ { address: '8.8.4.4', family: 4 },
|
|
|
+ { address: '2001:4860:4860::8888', family: 6 },
|
|
|
+ ])
|
|
|
+ })
|
|
|
+
|
|
|
+ it('rejects the whole DNS answer set when one address is not public', async () => {
|
|
|
+ const resolver = vi.fn(async () => [
|
|
|
+ { address: '8.8.8.8', family: 4 },
|
|
|
+ { address: '127.0.0.1', family: 4 },
|
|
|
+ ])
|
|
|
+ await expect(resolvePublicAddresses('rebinding.test', new AbortController().signal, resolver))
|
|
|
+ .rejects.toThrow(expect.objectContaining({ code: 'WEB_BLOCKED_URL' }))
|
|
|
+ })
|
|
|
+
|
|
|
+ it('rejects empty and invalid resolver results', async () => {
|
|
|
+ await expect(resolvePublicAddresses('empty.test', new AbortController().signal, async () => []))
|
|
|
+ .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' }))
|
|
|
+ await expect(resolvePublicAddresses('family.test', new AbortController().signal, async () => [{ address: '8.8.8.8', family: 0 }]))
|
|
|
+ .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' }))
|
|
|
+ await expect(resolvePublicAddresses('mismatch.test', new AbortController().signal, async () => [{ address: '::1', family: 4 }]))
|
|
|
+ .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' }))
|
|
|
+ })
|
|
|
+
|
|
|
+ it('validates bracketed IPv6 literals without invoking DNS', async () => {
|
|
|
+ const resolver = vi.fn(async () => [])
|
|
|
+ await expect(resolvePublicAddresses('[2001:4860:4860::8888]', new AbortController().signal, resolver))
|
|
|
+ .resolves.toEqual([{ address: '2001:4860:4860::8888', family: 6 }])
|
|
|
+ expect(resolver).not.toHaveBeenCalled()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('stops waiting for DNS when the request is aborted', async () => {
|
|
|
+ let finish!: (value: never[]) => void
|
|
|
+ const resolver = vi.fn(() => new Promise<never[]>((resolve) => { finish = resolve }))
|
|
|
+ const controller = new AbortController()
|
|
|
+ const pending = resolvePublicAddresses('slow.test', controller.signal, resolver)
|
|
|
+ controller.abort(new Error('stop'))
|
|
|
+ await expect(pending).rejects.toThrow('web fetch aborted during hostname resolution')
|
|
|
+ finish([])
|
|
|
+
|
|
|
+ const alreadyAborted = new AbortController()
|
|
|
+ alreadyAborted.abort(new Error('already stopped'))
|
|
|
+ await expect(resolvePublicAddresses('slow.test', alreadyAborted.signal, resolver))
|
|
|
+ .rejects.toThrow('web fetch aborted during hostname resolution')
|
|
|
+ })
|
|
|
+
|
|
|
+ it('propagates resolver failures', async () => {
|
|
|
+ await expect(resolvePublicAddresses('broken.test', new AbortController().signal, async () => { throw new Error('dns failed') }))
|
|
|
+ .rejects.toThrow('dns failed')
|
|
|
+ })
|
|
|
+
|
|
|
+ it('serves only the retained addresses through the connector lookup', async () => {
|
|
|
+ const lookup = createPinnedLookup([
|
|
|
+ { address: '8.8.8.8', family: 4 },
|
|
|
+ { address: '2001:4860:4860::8888', family: 6 },
|
|
|
+ ])
|
|
|
+ const call = (options: Parameters<typeof lookup>[1]) => new Promise<{
|
|
|
+ error: NodeJS.ErrnoException | null
|
|
|
+ address: string | import('node:dns').LookupAddress[]
|
|
|
+ family: number | undefined
|
|
|
+ }>((resolve) => {
|
|
|
+ lookup('fixed.test', options, (error, address, family) => { resolve({ error, address, family }) })
|
|
|
+ })
|
|
|
+
|
|
|
+ await expect(call({ all: true })).resolves.toMatchObject({
|
|
|
+ error: null,
|
|
|
+ address: [{ address: '8.8.8.8', family: 4 }, { address: '2001:4860:4860::8888', family: 6 }],
|
|
|
+ })
|
|
|
+ await expect(call({ family: 4 })).resolves.toMatchObject({ error: null, address: '8.8.8.8', family: 4 })
|
|
|
+ await expect(call({ family: 'IPv6' })).resolves.toMatchObject({ error: null, address: '2001:4860:4860::8888', family: 6 })
|
|
|
+ await expect(call({ family: 'IPv4' })).resolves.toMatchObject({ error: null, address: '8.8.8.8', family: 4 })
|
|
|
+ await expect(call({ family: 7 })).resolves.toMatchObject({ error: { code: 'ENOTFOUND' }, address: '', family: 7 })
|
|
|
+ await expect(call({ family: 7, all: true })).resolves.toMatchObject({ error: { code: 'ENOTFOUND' }, address: [], family: 7 })
|
|
|
+ })
|
|
|
+
|
|
|
+ it('pins the connection to the validated address without resolving the URL hostname again', async () => {
|
|
|
+ handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('pinned') }
|
|
|
+ const { port } = server.address() as AddressInfo
|
|
|
+ const request = await requestPinned(
|
|
|
+ new URL(`http://does-not-resolve.invalid:${port}/`),
|
|
|
+ [{ address: '127.0.0.1', family: 4 }],
|
|
|
+ {},
|
|
|
+ new AbortController().signal,
|
|
|
+ )
|
|
|
+ try {
|
|
|
+ await expect(request.response.text()).resolves.toBe('pinned')
|
|
|
+ } finally {
|
|
|
+ await request.close()
|
|
|
+ }
|
|
|
+ })
|
|
|
+})
|
|
|
+
|
|
|
describe('HttpFetchProvider success', () => {
|
|
|
it('fetches a text body', async () => {
|
|
|
handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('hello world') }
|
|
|
@@ -274,6 +404,12 @@ describe('HttpFetchProvider redirects', () => {
|
|
|
})
|
|
|
|
|
|
describe('HttpFetchProvider invalid URLs and abort', () => {
|
|
|
+ it('blocks a loopback destination before opening a connection', async () => {
|
|
|
+ restoreResolution()
|
|
|
+ await expect(provider().fetch({ url: base }))
|
|
|
+ .rejects.toThrow(expect.objectContaining({ code: 'WEB_BLOCKED_URL' }))
|
|
|
+ })
|
|
|
+
|
|
|
it('rejects a non-http scheme before any network access', async () => {
|
|
|
await expect(provider().fetch({ url: 'ftp://example.com' }))
|
|
|
.rejects.toThrow(expect.objectContaining({ code: 'WEB_INVALID_URL' }))
|
|
|
@@ -342,9 +478,16 @@ describe('HttpFetchProvider body cancellation on error paths', () => {
|
|
|
return { response, cancelled: () => cancelled }
|
|
|
}
|
|
|
|
|
|
+ function stubRequest(response: Response): void {
|
|
|
+ vi.spyOn(publicHttpNetwork, 'request').mockResolvedValue({
|
|
|
+ response: response as never,
|
|
|
+ close: async () => {},
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
it('cancels the body when a cross-origin redirect is blocked', async () => {
|
|
|
const { response, cancelled } = fakeResponse({ status: 302, headers: {}, location: 'https://elsewhere.test/' })
|
|
|
- vi.stubGlobal('fetch', vi.fn(async () => response))
|
|
|
+ stubRequest(response)
|
|
|
await expect(provider().fetch({ url: 'http://127.0.0.1:9/' }))
|
|
|
.rejects.toThrow(expect.objectContaining({ code: 'WEB_REDIRECT_BLOCKED' }))
|
|
|
expect(cancelled()).toBe(true)
|
|
|
@@ -352,7 +495,7 @@ describe('HttpFetchProvider body cancellation on error paths', () => {
|
|
|
|
|
|
it('cancels the body when an unsupported charset is rejected', async () => {
|
|
|
const { response, cancelled } = fakeResponse({ status: 200, headers: { 'content-type': 'text/plain; charset=not-a-charset' } })
|
|
|
- vi.stubGlobal('fetch', vi.fn(async () => response))
|
|
|
+ stubRequest(response)
|
|
|
await expect(provider().fetch({ url: 'http://127.0.0.1:9/' }))
|
|
|
.rejects.toThrow(expect.objectContaining({ code: 'WEB_UNSUPPORTED_CONTENT_TYPE' }))
|
|
|
expect(cancelled()).toBe(true)
|
|
|
@@ -360,7 +503,7 @@ describe('HttpFetchProvider body cancellation on error paths', () => {
|
|
|
|
|
|
it('cancels the body when a redirect has no Location header', async () => {
|
|
|
const { response, cancelled } = fakeResponse({ status: 302, headers: {} })
|
|
|
- vi.stubGlobal('fetch', vi.fn(async () => response))
|
|
|
+ stubRequest(response)
|
|
|
await expect(provider().fetch({ url: 'http://127.0.0.1:9/' }))
|
|
|
.rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' }))
|
|
|
expect(cancelled()).toBe(true)
|