vercel/next.js · #99477
fix(next/image): Pin DNS resolution when fetching external images
packages/next/src/server/image-optimizer.ts362 + / 55 −
@@ -1,5 +1,11 @@ import { promises } from 'fs'-import type { IncomingMessage, ServerResponse } from 'http'+import type {+ IncomingMessage,+ RequestOptions as HttpRequestOptions,+ ServerResponse,+} from 'http'+import { Agent as HttpAgent, request as httpRequest } from 'http'+import { Agent as HttpsAgent, request as httpsRequest } from 'https' import { mediaType } from 'next/dist/compiled/@hapi/accept' import contentDisposition from 'next/dist/compiled/content-disposition' import { join } from 'path'@@ -26,8 +32,21 @@ import { parseUrl, parseReqUrl } from '../lib/url' import type { CacheControl } from './lib/cache-control' import { InvariantError } from '../shared/lib/invariant-error' import { lookup } from 'dns/promises'-import { isIP } from 'net'-import { ALL } from 'dns'+import { isIP, type LookupFunction } from 'net'+import { ADDRCONFIG, type LookupAddress } from 'dns'+import {+ pipeline,+ Transform,+ type Readable,+ type TransformCallback,+} from 'stream'+import {+ constants as zlibConstants,+ createBrotliDecompress,+ createGunzip,+ createInflate,+ createInflateRaw,+} from 'zlib' import { imageOptimizerTransform, type ImageUpstream,@@ -518,24 +537,286 @@ function isRedirect(statusCode: number) { return [301, 302, 303, 307, 308].includes(statusCode) } +function upstreamTimedOut(href: string): ImageError {+ Log.error('upstream image response timed out for', href)+ return new ImageError(+ 504,+ '"url" parameter is valid but upstream response timed out'+ )+}++const agentOptions = {+ keepAlive: true,+ timeout: 7_000,+}+type ImageRequestOptions = HttpRequestOptions & { imageLookupKey?: string }+type AgentGetName = (options?: HttpRequestOptions) => string++const getHttpAgentName = (+ HttpAgent.prototype as HttpAgent & { getName: AgentGetName }+).getName+const getHttpsAgentName = (+ HttpsAgent.prototype as HttpsAgent & { getName: AgentGetName }+).getName++class ImageHttpAgent extends HttpAgent {+ getName(options: ImageRequestOptions = {}): string {+ return `${getHttpAgentName.call(this, options)}:${options.imageLookupKey ?? ''}`+ }+}++class ImageHttpsAgent extends HttpsAgent {+ getName(options: ImageRequestOptions = {}): string {+ return `${getHttpsAgentName.call(this, options)}:${options.imageLookupKey ?? ''}`+ }+}++let protectedHttpAgent: ImageHttpAgent | undefined+let protectedHttpsAgent: ImageHttpsAgent | undefined+let permissiveHttpAgent: ImageHttpAgent | undefined+let permissiveHttpsAgent: ImageHttpsAgent | undefined++function getImageAgent(+ dangerouslyAllowLocalIP: boolean,+ isHttps: boolean+): ImageHttpAgent | ImageHttpsAgent {+ if (isHttps) {+ if (dangerouslyAllowLocalIP) {+ return (permissiveHttpsAgent ??= new ImageHttpsAgent(agentOptions))+ }+ return (protectedHttpsAgent ??= new ImageHttpsAgent(agentOptions))+ }+ if (dangerouslyAllowLocalIP) {+ return (permissiveHttpAgent ??= new ImageHttpAgent(agentOptions))+ }+ return (protectedHttpAgent ??= new ImageHttpAgent(agentOptions))+}++const BASE_REQ_HEADERS = {+ accept: '*/*',+ 'accept-language': '*',+ 'sec-fetch-mode': 'cors',+ 'user-agent': 'node',+}+const HTTP_FETCH_HEADERS = {+ ...BASE_REQ_HEADERS,+ 'accept-encoding': 'gzip, deflate',+}+const HTTPS_FETCH_HEADERS = {+ ...BASE_REQ_HEADERS,+ 'accept-encoding': 'br, gzip, deflate',+}++/**+ * Resolves `hostname` with the DNS options the socket itself would have used,+ * so the addresses checked below are the exact set the connection can reach.+ * Mirrors `lookupAndConnect()` in Node's `lib/net.js`.+ */+function lookupAsSocketWould(hostname: string): Promise<LookupAddress[]> {+ return lookup(hostname, {+ all: true,+ // Node applies ADDRCONFIG whenever no address family is requested,+ // on every platform except Windows.+ hints: process.platform === 'win32' ? 0 : ADDRCONFIG,+ })+}++/**+ * Pins the connection to `addresses` instead of letting it resolve `hostname`+ * a second time. Checking addresses up front only proves where the name+ * pointed at that moment; the socket runs its own lookup, so a record that+ * changes in between (DNS rebinding) would otherwise still reach a private IP.+ *+ * Every address in the list was checked, so all of them are handed back to+ * keep Node's happy eyeballs failover between IPv6 and IPv4 intact.+ */+export function createPinnedLookup(+ hostname: string,+ addresses: LookupAddress[]+): LookupFunction {+ return (lookupHostname, options, callback) => {+ if (lookupHostname !== hostname) {+ callback(+ new InvariantError(+ `Image lookup hostname "${lookupHostname}" does not match request hostname "${hostname}"`+ ),+ []+ )+ return+ }++ const family = typeof options.family === 'number' ? options.family : 0+ const matched = family+ ? addresses.filter((address) => address.family === family)+ : addresses++ if (matched.length === 0) {+ // Reporting this as an error rather than an empty list is not optional:+ // Node destructures the first address, so the resulting TypeError is+ // thrown inside the socket and escapes the request entirely.+ const err: NodeJS.ErrnoException = new Error(+ `No IPv${family} address available for ${hostname}`+ )+ err.code = 'ENODATA'+ callback(err, [])+ return+ }++ if (options.all) {+ callback(null, matched)+ } else {+ callback(null, matched[0].address, matched[0].family)+ }+ }+}++function requestUpstreamImage(+ url: URL,+ pinnedLookup: LookupFunction | undefined,+ imageLookupKey: string | undefined,+ dangerouslyAllowLocalIP: boolean,+ signal: AbortSignal+): Promise<IncomingMessage> {+ return new Promise((resolve, reject) => {+ const isHttps = url.protocol === 'https:'+ const request = isHttps ? httpsRequest : httpRequest+ const options: ImageRequestOptions = {+ agent: getImageAgent(dangerouslyAllowLocalIP, isHttps),+ headers: isHttps ? HTTPS_FETCH_HEADERS : HTTP_FETCH_HEADERS,+ imageLookupKey,+ lookup: pinnedLookup,+ signal,+ }+ const req = request(url, options, resolve)+ req.on('error', reject)+ req.end()+ })+}++class DeflateDecoder extends Transform {+ private decoder: Transform | undefined++ constructor() {+ super()+ this.decoder = undefined+ }++ _transform(+ chunk: Buffer,+ encoding: BufferEncoding,+ callback: TransformCallback+ ) {+ if (!this.decoder) {+ if (chunk.length === 0) {+ callback()+ return+ }+ const options = {+ flush: zlibConstants.Z_SYNC_FLUSH,+ finishFlush: zlibConstants.Z_SYNC_FLUSH,+ }+ this.decoder =+ (chunk[0] & 0x0f) === 0x08+ ? createInflate(options)+ : createInflateRaw(options)+ this.decoder.on('data', (data: Buffer) => this.push(data))+ this.decoder.on('end', () => this.push(null))+ this.decoder.on('error', (err) => this.destroy(err))+ }+ this.decoder.write(chunk, encoding, callback)+ }++ _final(callback: TransformCallback) {+ this.decoder?.end()+ this.decoder = undefined+ callback()+ }+}++// Matches the chain limit of native fetch (undici), so an upstream cannot+// force unbounded decoder allocations with the header alone.+const MAX_CONTENT_ENCODINGS = 5++function decodeResponseBody(res: IncomingMessage, href: string): Readable {+ const contentEncoding = res.headers['content-encoding']+ if (!contentEncoding) {+ return res+ }++ const codings = contentEncoding.toLowerCase().split(',')+ if (codings.length > MAX_CONTENT_ENCODINGS) {+ Log.error(+ 'upstream image response had too many content encodings for',+ href+ )+ throw new ImageError(+ 400,+ '"url" parameter is valid but upstream response is invalid'+ )+ }+ const decoders: Transform[] = []+ for (let i = codings.length - 1; i >= 0; i--) {+ switch (codings[i].trim()) {+ case 'x-gzip':+ case 'gzip':+ decoders.push(+ createGunzip({+ flush: zlibConstants.Z_SYNC_FLUSH,+ finishFlush: zlibConstants.Z_SYNC_FLUSH,+ })+ )+ break+ case 'deflate':+ decoders.push(new DeflateDecoder())+ break+ case 'br':+ decoders.push(+ createBrotliDecompress({+ flush: zlibConstants.BROTLI_OPERATION_FLUSH,+ finishFlush: zlibConstants.BROTLI_OPERATION_FLUSH,+ })+ )+ break+ default:+ return res+ }+ }++ pipeline([res, ...decoders], () => {})+ return decoders[decoders.length - 1]+}+ export async function fetchExternalImage( href: string, dangerouslyAllowLocalIP: boolean, maximumResponseBody: number, count = 3 ): Promise<ImageUpstream> {+ const url = new URL(href)+ // `URL.hostname` keeps the brackets around an IPv6 literal, the socket does not+ const hostname = url.hostname.replace(/^\[|\]$/g, '')+ let pinnedLookup: LookupFunction | undefined+ let imageLookupKey: string | undefined+ if (!dangerouslyAllowLocalIP) {- const { hostname } = new URL(href)- let ips = [hostname]- if (!isIP(hostname)) {- const records = await lookup(hostname, {- family: 0,- all: true,- hints: ALL,- }).catch((_) => [{ address: hostname }])- ips = records.map((record) => record.address)- }- const privateIps = ips.filter((ip) => isPrivateIp(ip))+ const literalFamily = isIP(hostname)+ let addresses: LookupAddress[]++ if (literalFamily > 0) {+ // A literal address is connected to as-is, without any DNS resolution+ addresses = [{ address: hostname, family: literalFamily }]+ } else {+ addresses = await lookupAsSocketWould(hostname)+ pinnedLookup = createPinnedLookup(hostname, addresses)+ }+ imageLookupKey = addresses+ .map((address) => `${address.family}:${address.address}`)+ .join(',')++ const privateIps = addresses+ .map((record) => record.address)+ .filter((ip) => isPrivateIp(ip))+ if (privateIps.length > 0) { Log.error( 'upstream image',@@ -547,29 +828,44 @@ export async function fetchExternalImage( throw new ImageError(400, '"url" parameter is not allowed') } }- const res = await fetch(href, {- signal: AbortSignal.timeout(7_000),- redirect: 'manual',- }).catch((err) => err as Error) - if (res instanceof Error) {- const err = res as Error- if (err.name === 'TimeoutError') {- Log.error('upstream image response timed out for', href)- throw new ImageError(- 504,- '"url" parameter is valid but upstream response timed out'- )+ // The signal is owned here rather than by `requestUpstreamImage()` because it+ // has to stay readable while the body streams. Node reports an abort during+ // the body as a plain `ECONNRESET` on the response, so the signal is the only+ // way to tell a timeout apart from the upstream resetting the connection.+ const signal = AbortSignal.timeout(agentOptions.timeout)+ let res: IncomingMessage+ try {+ res = await requestUpstreamImage(+ url,+ pinnedLookup,+ imageLookupKey,+ dangerouslyAllowLocalIP,+ signal+ )+ } catch (err) {+ if (signal.aborted) {+ throw upstreamTimedOut(href) } throw err } - const locationHeader = res.headers.get('Location')+ const statusCode = res.statusCode+ if (statusCode === undefined) {+ res.destroy()+ throw new InvariantError('Expected statusCode on upstream image response')+ }+ const locationHeader = res.headers.location if (- isRedirect(res.status) &&+ isRedirect(statusCode) && locationHeader && URL.canParse(locationHeader, href) ) {+ // Discard the body rather than draining it. A redirect body is never used,+ // and draining one would download unlimited bytes from the upstream because+ // `maximumResponseBody` is only enforced on the final response.+ res.destroy()+ if (count === 0) { Log.error('upstream image response had too many redirects', href) throw new ImageError(@@ -586,46 +882,57 @@ export async function fetchExternalImage( ) } - if (!res.ok) {- Log.error('upstream image response failed for', href, res.status)+ if (statusCode < 200 || statusCode > 299) {+ res.destroy()+ Log.error('upstream image response failed for', href, statusCode) throw new ImageError(- res.status,+ statusCode, '"url" parameter is valid but upstream response is invalid' ) } - if (!res.body) {+ const chunks: Buffer[] = []+ let totalSize = 0++ try {+ const body = decodeResponseBody(res, href)+ // Throwing out of this loop destroys the response, so an oversized or+ // timed out download stops instead of running to completion.+ for await (const chunk of body as AsyncIterable<Buffer>) {+ totalSize += chunk.byteLength+ if (totalSize > maximumResponseBody) {+ Log.error(+ 'upstream image response exceeded maximum size for',+ href,+ totalSize+ )+ throw new ImageError(+ 413,+ '"url" parameter is valid but upstream response is invalid'+ )+ }+ chunks.push(chunk)+ }+ } catch (err) {+ res.destroy()+ if (signal.aborted) {+ throw upstreamTimedOut(href)+ }+ throw err+ }++ if (totalSize === 0) { Log.error('upstream image response is empty for', href) throw new ImageError( 400, '"url" parameter is valid but upstream response is invalid' ) } - const chunks: Buffer[] = []- let totalSize = 0-- for await (const c of res.body) {- const chunk = Buffer.from(c)- totalSize += chunk.byteLength- if (totalSize > maximumResponseBody) {- Log.error(- 'upstream image response exceeded maximum size for',- href,- totalSize- )- throw new ImageError(- 413,- '"url" parameter is valid but upstream response is invalid'- )- }- chunks.push(chunk)- }- const buffer = Buffer.concat(chunks)- const contentType = res.headers.get('Content-Type')- const cacheControl = res.headers.get('Cache-Control')- const etag = extractEtag(res.headers.get('ETag'), buffer)+ const contentType = res.headers['content-type']+ const cacheControl = res.headers['cache-control']+ const etag = extractEtag(res.headers.etag, buffer) return { buffer, contentType, cacheControl, etag } } test/e2e/testmode/next.config.js3 + / 0 −
@@ -1,5 +1,8 @@ /** @type {import('next').NextConfig} */ module.exports = {+ images: {+ remotePatterns: [new URL('https://example.com/**')],+ }, experimental: { testProxy: true, },test/e2e/testmode/testmode.test.ts24 + / 0 −
@@ -1,6 +1,11 @@ import { nextTestSetup } from 'e2e-utils' import { createProxyServer } from 'next/experimental/testmode/proxy' +const PNG_1X1 = Buffer.from(+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',+ 'base64'+)+ describe('testmode', () => { const { next, skipped } = nextTestSetup({ files: __dirname,@@ -17,6 +22,14 @@ describe('testmode', () => { beforeEach(async () => { proxyServer = await createProxyServer({ onFetch: async (testData, request) => {+ if (+ request.method === 'GET' &&+ request.url === 'https://example.com/image.png'+ ) {+ return new Response(PNG_1X1, {+ headers: { 'content-type': 'image/png' },+ })+ } if ( request.method === 'GET' && [@@ -139,4 +152,15 @@ describe('testmode', () => { expect(text).toEqual('test1') }) })++ describe('image optimizer', () => {+ it('should intercept external image requests', async () => {+ const response = await fetchForTest(+ '/_next/image?url=https%3A%2F%2Fexample.com%2Fimage.png&w=64&q=75'+ )++ expect(response.status).toBe(200)+ expect(response.headers.get('content-type')).toMatch(/^image\//)+ })+ }) })test/unit/image-optimizer/create-pinned-lookup.test.tsadded65 + / 0 −
@@ -0,0 +1,65 @@+/* eslint-env jest */+import { ADDRCONFIG, type LookupAddress, type LookupOptions } from 'dns'+import { createPinnedLookup } from 'next/dist/server/image-optimizer'++const HOSTNAME = 'example.com'+const IPV4: LookupAddress = { address: '93.184.216.34', family: 4 }+const IPV6: LookupAddress = {+ address: '2606:2800:220:1:248:1893:25c8:1946',+ family: 6,+}++function callLookup(+ options: LookupOptions,+ { hostname = HOSTNAME, addresses = [IPV4, IPV6] } = {}+) {+ const calls: Array<{+ err: NodeJS.ErrnoException | null+ result: string | LookupAddress[]+ family?: number+ }> = []++ createPinnedLookup(HOSTNAME, addresses)(+ hostname,+ options,+ (err, result, family) => calls.push({ err, result, family })+ )++ expect(calls).toHaveLength(1)+ return calls[0]+}++describe('createPinnedLookup', () => {+ it('should hand back every pinned address for the options a socket uses', () => {+ const { err, result } = callLookup({ all: true, hints: ADDRCONFIG })+ expect(err).toBeNull()+ expect(result).toEqual([IPV4, IPV6])+ })++ it('should report ENODATA rather than an empty list when the family matches nothing', () => {+ // An empty list is not recoverable: node destructures the first address, so+ // the resulting TypeError is thrown inside the socket and escapes the request+ const { err, result } = callLookup(+ { all: true, family: 6 },+ { addresses: [IPV4] }+ )+ expect(err?.code).toBe('ENODATA')+ expect(result).toEqual([])+ })++ it('should report ENODATA when the family matches nothing and all is unset', () => {+ const { err } = callLookup({ family: 6 }, { addresses: [IPV4] })+ expect(err?.code).toBe('ENODATA')+ })++ it('should return a single address of the requested family', () => {+ const { err, result, family } = callLookup({ family: 6 })+ expect(err).toBeNull()+ expect({ result, family }).toEqual({ result: IPV6.address, family: 6 })+ })++ it('should refuse to resolve a hostname other than the pinned one', () => {+ const { err } = callLookup({ all: true }, { hostname: 'attacker.example' })+ expect(err?.message).toContain('does not match request hostname')+ })+})test/unit/image-optimizer/fetch-external-image.test.ts635 + / 134 −
@@ -1,73 +1,371 @@ /* eslint-env jest */+import {+ createServer,+ type IncomingMessage,+ type Server,+ type ServerResponse,+} from 'http'+import type { AddressInfo } from 'net'+import { readFile } from 'fs/promises'+import { join } from 'path'+import { brotliCompressSync, deflateRawSync, deflateSync, gzipSync } from 'zlib' import { fetchExternalImage, ImageError, } from 'next/dist/server/image-optimizer'+import { detectContentType } from 'next/dist/server/image-optimizer/detect-content-type'++jest.mock('dns/promises', () => {+ const actual = jest.requireActual('dns/promises')+ return { ...actual, lookup: jest.fn(actual.lookup) }+})++jest.mock('next/dist/server/is-private-ip', () => {+ const actual = jest.requireActual('next/dist/server/is-private-ip')+ return { ...actual, isPrivateIp: jest.fn(actual.isPrivateIp) }+})++const { lookup: lookupMock } =+ jest.requireMock<typeof import('dns/promises')>('dns/promises')+const { isPrivateIp: isPrivateIpMock } = jest.requireMock<+ typeof import('next/dist/server/is-private-ip')+>('next/dist/server/is-private-ip')++const MAXIMUM_RESPONSE_BODY = 50_000_000+const actualLookup =+ jest.requireActual<typeof import('dns/promises')>('dns/promises').lookup+const actualIsPrivateIp = jest.requireActual<+ typeof import('next/dist/server/is-private-ip')+>('next/dist/server/is-private-ip').isPrivateIp++let server: Server+let handler: (req: IncomingMessage, res: ServerResponse) => void+let requests: string[]++function port() {+ return (server.address() as AddressInfo).port+}++function origin() {+ return `http://127.0.0.1:${port()}`+}++beforeAll(async () => {+ server = createServer((req, res) => {+ requests.push(req.url ?? '')+ // The client destroys the socket when a response is too large, so writes+ // from these handlers are expected to fail+ res.on('error', () => {})+ handler(req, res)+ })+ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))+})++afterAll(async () => {+ server.closeAllConnections()+ await new Promise<void>((resolve) => server.close(() => resolve()))+})++beforeEach(() => {+ requests = []+ handler = (_req, res) => {+ res.writeHead(200, { 'content-type': 'image/jpeg' })+ res.end(Buffer.from([0xff, 0xd8, 0xff]))+ }+})++afterEach(() => {+ jest.mocked(lookupMock).mockReset().mockImplementation(actualLookup)+ jest.mocked(isPrivateIpMock).mockReset().mockImplementation(actualIsPrivateIp)+}) describe('fetchExternalImage', () => { describe('private IP / SSRF guard', () => { it('should reject a literal private IP hostname with a generic error message', async () => {- const fetchMock = jest.fn()- global.fetch = fetchMock+ const error = await fetchExternalImage(+ `${origin()}/private.jpg`,+ false,+ MAXIMUM_RESPONSE_BODY+ ).catch((e) => e)++ expect(error).toBeInstanceOf(ImageError)+ expect((error as ImageError).statusCode).toBe(400)+ expect((error as ImageError).message).toBe(+ '"url" parameter is not allowed'+ )+ expect(requests).toHaveLength(0)+ })++ it('should reject a hostname that resolves to a private IP', async () => {+ jest+ .mocked(lookupMock)+ .mockResolvedValueOnce([{ address: '10.0.0.1', family: 4 }] as any) const error = await fetchExternalImage(- 'http://192.168.0.1/private.jpg',+ 'http://internal.invalid/private.jpg', false,- 50_000_000+ MAXIMUM_RESPONSE_BODY ).catch((e) => e) expect(error).toBeInstanceOf(ImageError) expect((error as ImageError).statusCode).toBe(400) expect((error as ImageError).message).toBe( '"url" parameter is not allowed' )- expect(fetchMock).not.toHaveBeenCalled()+ expect(requests).toHaveLength(0) }) it('should allow a literal private IP when dangerouslyAllowLocalIP is true', async () => {- global.fetch = jest.fn().mockResolvedValue({- ok: true,- status: 200,- body: new ReadableStream({- start(controller) {- controller.enqueue(new Uint8Array([1, 2, 3]))- controller.close()- },- }),- headers: {- get: jest.fn((header: string) => {- if (header === 'Content-Type') return 'image/jpeg'- return null- }),- },- })+ const result = await fetchExternalImage(+ `${origin()}/private.jpg`,+ true,+ MAXIMUM_RESPONSE_BODY+ )++ expect(result.buffer).toEqual(Buffer.from([0xff, 0xd8, 0xff]))+ expect(requests).toEqual(['/private.jpg'])+ })++ it('should skip the DNS lookup for a literal IP', async () => {+ await fetchExternalImage(+ `${origin()}/literal.jpg`,+ true,+ MAXIMUM_RESPONSE_BODY+ )++ expect(lookupMock).not.toHaveBeenCalled()+ })+ })++ describe('DNS pinning', () => {+ it('should connect to the address that was checked instead of resolving again', async () => {+ // `.invalid` never resolves, so the request can only succeed if the+ // socket uses the address that `isPrivateIp()` already approved+ jest+ .mocked(lookupMock)+ .mockResolvedValueOnce([{ address: '127.0.0.1', family: 4 }] as any)+ jest.mocked(isPrivateIpMock).mockReturnValueOnce(false) const result = await fetchExternalImage(- 'http://192.168.0.1/private.jpg',+ `http://pinned.invalid:${port()}/pinned.jpg`,+ false,+ MAXIMUM_RESPONSE_BODY+ )++ expect(result.buffer).toEqual(Buffer.from([0xff, 0xd8, 0xff]))+ expect(requests).toEqual(['/pinned.jpg'])+ expect(lookupMock).toHaveBeenCalledTimes(1)+ expect(lookupMock).toHaveBeenCalledWith(+ 'pinned.invalid',+ expect.objectContaining({ all: true })+ )+ })++ it('should send the original hostname in the Host header', async () => {+ jest+ .mocked(lookupMock)+ .mockResolvedValueOnce([{ address: '127.0.0.1', family: 4 }] as any)+ jest.mocked(isPrivateIpMock).mockReturnValueOnce(false)++ let host: string | undefined+ handler = (req, res) => {+ host = req.headers.host+ res.writeHead(200, { 'content-type': 'image/jpeg' })+ res.end(Buffer.from([0xff, 0xd8, 0xff]))+ }++ await fetchExternalImage(+ `http://pinned.invalid:${port()}/host.jpg`,+ false,+ MAXIMUM_RESPONSE_BODY+ )++ expect(host).toBe(`pinned.invalid:${port()}`)+ })++ it('should not reuse a permissive socket for a protected request', async () => {+ const sockets = new Set()+ handler = (req, res) => {+ sockets.add(req.socket)+ res.writeHead(200, { 'content-type': 'image/jpeg' })+ res.end(Buffer.from([0xff, 0xd8, 0xff]))+ }+ jest.mocked(isPrivateIpMock).mockReturnValueOnce(false)++ await fetchExternalImage(+ `${origin()}/permissive.jpg`, true,- 50_000_000+ MAXIMUM_RESPONSE_BODY+ )+ await fetchExternalImage(+ `${origin()}/protected.jpg`,+ false,+ MAXIMUM_RESPONSE_BODY ) - expect(result.buffer).toBeInstanceOf(Buffer)- expect(global.fetch).toHaveBeenCalled()+ expect(sockets.size).toBe(2)+ })++ it('should pool permissive sockets separately', async () => {+ const sockets = new Set()+ handler = (req, res) => {+ sockets.add(req.socket)+ res.writeHead(200, { 'content-type': 'image/jpeg' })+ res.end(Buffer.from([0xff, 0xd8, 0xff]))+ }++ const href = `${origin()}/permissive.jpg`+ await fetchExternalImage(href, true, MAXIMUM_RESPONSE_BODY)+ await fetchExternalImage(href, true, MAXIMUM_RESPONSE_BODY)++ expect(sockets.size).toBe(1)+ })++ it('should pool protected sockets', async () => {+ const sockets = new Set()+ handler = (req, res) => {+ sockets.add(req.socket)+ res.writeHead(200, { 'content-type': 'image/jpeg' })+ res.end(Buffer.from([0xff, 0xd8, 0xff]))+ }+ jest+ .mocked(lookupMock)+ .mockResolvedValue([{ address: '127.0.0.1', family: 4 }] as any)+ jest.mocked(isPrivateIpMock).mockReturnValue(false)++ const href = `http://protected.invalid:${port()}/protected.jpg`+ await fetchExternalImage(href, false, MAXIMUM_RESPONSE_BODY)+ await fetchExternalImage(href, false, MAXIMUM_RESPONSE_BODY)++ expect(sockets.size).toBe(1)+ expect(lookupMock).toHaveBeenCalledTimes(2)+ })++ it('should reject a private DNS answer before reusing a protected socket', async () => {+ jest+ .mocked(lookupMock)+ .mockResolvedValue([{ address: '127.0.0.1', family: 4 }] as any)+ jest+ .mocked(isPrivateIpMock)+ .mockReturnValueOnce(false)+ .mockReturnValueOnce(true)++ const href = `http://revalidate.invalid:${port()}/protected.jpg`+ await fetchExternalImage(href, false, MAXIMUM_RESPONSE_BODY)+ const error = await fetchExternalImage(+ href,+ false,+ MAXIMUM_RESPONSE_BODY+ ).catch((e) => e)++ expect(error).toBeInstanceOf(ImageError)+ expect((error as ImageError).statusCode).toBe(400)+ expect(requests).toEqual(['/protected.jpg'])+ })++ it('should not reuse a socket for a different validated address', async () => {+ const sockets = new Set()+ handler = (req, res) => {+ sockets.add(req.socket)+ res.writeHead(200, { 'content-type': 'image/jpeg' })+ res.end(Buffer.from([0xff, 0xd8, 0xff]))+ }+ jest+ .mocked(lookupMock)+ .mockResolvedValueOnce([{ address: '127.0.0.1', family: 4 }] as any)+ .mockResolvedValueOnce([+ { address: '127.0.0.1', family: 4 },+ { address: '127.0.0.2', family: 4 },+ ] as any)+ jest.mocked(isPrivateIpMock).mockReturnValue(false)++ const href = `http://rebound.invalid:${port()}/protected.jpg`+ await fetchExternalImage(href, false, MAXIMUM_RESPONSE_BODY)+ await fetchExternalImage(href, false, MAXIMUM_RESPONSE_BODY)++ expect(sockets.size).toBe(2)+ expect(lookupMock).toHaveBeenCalledTimes(2) }) }) - describe('response size limit', () => {- it('should throw error when response has no body', async () => {- global.fetch = jest.fn().mockResolvedValue({- ok: true,- status: 200,- body: null,- headers: {- get: jest.fn(() => null),- },+ describe('response handling', () => {+ it('should send the same default headers as fetch', async () => {+ let headers: IncomingMessage['headers'] | undefined+ handler = (req, res) => {+ headers = req.headers+ res.writeHead(200, { 'content-type': 'image/jpeg' })+ res.end(Buffer.from([0xff, 0xd8, 0xff]))+ }++ await fetchExternalImage(+ `${origin()}/headers.jpg`,+ true,+ MAXIMUM_RESPONSE_BODY+ )++ expect(headers).toEqual({+ accept: '*/*',+ 'accept-encoding': 'gzip, deflate',+ 'accept-language': '*',+ connection: 'keep-alive',+ host: `127.0.0.1:${port()}`,+ 'sec-fetch-mode': 'cors',+ 'user-agent': 'node', })+ })++ it('should return the content type, cache control and etag', async () => {+ handler = (_req, res) => {+ res.writeHead(200, {+ 'content-type': 'image/png',+ 'cache-control': 'public, max-age=600',+ etag: '"upstream-etag"',+ })+ res.end(Buffer.from([1, 2, 3]))+ }++ const result = await fetchExternalImage(+ `${origin()}/headers.png`,+ true,+ MAXIMUM_RESPONSE_BODY+ )++ expect(result.contentType).toBe('image/png')+ expect(result.cacheControl).toBe('public, max-age=600')+ expect(result.etag).toBe(+ Buffer.from('"upstream-etag"').toString('base64url')+ )+ })++ it('should throw error when the upstream response is not ok', async () => {+ handler = (_req, res) => {+ res.writeHead(404)+ res.end()+ } const error = await fetchExternalImage(- 'http://example.com/no-body.jpg',- false,- 50_000_000+ `${origin()}/missing.jpg`,+ true,+ MAXIMUM_RESPONSE_BODY+ ).catch((e) => e)++ expect(error).toBeInstanceOf(ImageError)+ expect((error as ImageError).statusCode).toBe(404)+ expect((error as ImageError).message).toBe(+ '"url" parameter is valid but upstream response is invalid'+ )+ })++ it('should throw error when response has no body', async () => {+ handler = (_req, res) => {+ res.writeHead(200, { 'content-type': 'image/jpeg' })+ res.end()+ }++ const error = await fetchExternalImage(+ `${origin()}/no-body.jpg`,+ true,+ MAXIMUM_RESPONSE_BODY ).catch((e) => e) expect(error).toBeInstanceOf(ImageError)@@ -77,40 +375,290 @@ describe('fetchExternalImage', () => { ) }) + it.each([+ ['gzip', 'gzip', gzipSync],+ ['Brotli', 'br', brotliCompressSync],+ ['zlib-wrapped deflate', 'deflate', deflateSync],+ ['raw deflate', 'deflate', deflateRawSync],+ ])(+ 'should decompress a %s-encoded SVG response',+ async (_name, encoding, zip) => {+ const svg = await readFile(join(__dirname, 'images/test.svg'))+ handler = (_req, res) => {+ res.writeHead(200, {+ 'content-encoding': encoding,+ 'content-type': 'image/svg+xml',+ })+ res.end(zip(svg))+ }++ const result = await fetchExternalImage(+ `${origin()}/compressed.svg`,+ true,+ MAXIMUM_RESPONSE_BODY+ )++ expect(result.buffer).toEqual(svg)+ expect(result.contentType).toBe('image/svg+xml')+ expect(await detectContentType(result.buffer)).toBe('image/svg+xml')+ }+ )++ it('should decompress chained content encodings', async () => {+ const svg = await readFile(join(__dirname, 'images/test.svg'))+ handler = (_req, res) => {+ res.writeHead(200, {+ 'content-encoding': 'gzip, br',+ 'content-type': 'image/svg+xml',+ })+ res.end(brotliCompressSync(gzipSync(svg)))+ }++ const result = await fetchExternalImage(+ `${origin()}/compressed.svg`,+ true,+ MAXIMUM_RESPONSE_BODY+ )++ expect(result.buffer).toEqual(svg)+ })++ it('should decompress five chained content encodings', async () => {+ const svg = await readFile(join(__dirname, 'images/test.svg'))+ let body = svg+ for (let i = 0; i < 5; i++) {+ body = gzipSync(body)+ }+ handler = (_req, res) => {+ res.writeHead(200, {+ 'content-encoding': 'gzip, gzip, gzip, gzip, gzip',+ 'content-type': 'image/svg+xml',+ })+ res.end(body)+ }++ const result = await fetchExternalImage(+ `${origin()}/compressed.svg`,+ true,+ MAXIMUM_RESPONSE_BODY+ )++ expect(result.buffer).toEqual(svg)+ })++ it('should reject more than five chained content encodings', async () => {+ const svg = await readFile(join(__dirname, 'images/test.svg'))+ let body = svg+ for (let i = 0; i < 6; i++) {+ body = gzipSync(body)+ }+ handler = (_req, res) => {+ res.writeHead(200, {+ 'content-encoding': 'gzip, gzip, gzip, gzip, gzip, gzip',+ 'content-type': 'image/svg+xml',+ })+ res.end(body)+ }++ const error = await fetchExternalImage(+ `${origin()}/compressed.svg`,+ true,+ MAXIMUM_RESPONSE_BODY+ ).catch((e) => e)++ expect(error).toBeInstanceOf(ImageError)+ expect((error as ImageError).statusCode).toBe(400)+ expect((error as ImageError).message).toBe(+ '"url" parameter is valid but upstream response is invalid'+ )+ })++ it('should reject an over-long content-encoding header without a body', async () => {+ handler = (_req, res) => {+ res.writeHead(200, {+ 'content-encoding': Array(1000).fill('gzip').join(', '),+ 'content-type': 'image/svg+xml',+ })+ res.end()+ }++ const error = await fetchExternalImage(+ `${origin()}/compressed.svg`,+ true,+ MAXIMUM_RESPONSE_BODY+ ).catch((e) => e)++ expect(error).toBeInstanceOf(ImageError)+ expect((error as ImageError).statusCode).toBe(400)+ expect((error as ImageError).message).toBe(+ '"url" parameter is valid but upstream response is invalid'+ )+ })++ it('should enforce maximumResponseBody on decompressed bytes', async () => {+ const maximumResponseBody = 2_000+ handler = (_req, res) => {+ res.writeHead(200, {+ 'content-encoding': 'gzip',+ 'content-type': 'image/svg+xml',+ })+ res.end(gzipSync(Buffer.alloc(maximumResponseBody + 1)))+ }++ const error = await fetchExternalImage(+ `${origin()}/compressed.svg`,+ true,+ maximumResponseBody+ ).catch((e) => e)++ expect(error).toBeInstanceOf(ImageError)+ expect((error as ImageError).statusCode).toBe(413)+ })++ it('should follow a redirect', async () => {+ handler = (req, res) => {+ if (req.url === '/redirect.jpg') {+ res.writeHead(302, { location: '/final.jpg' })+ return res.end()+ }+ res.writeHead(200, { 'content-type': 'image/jpeg' })+ res.end(Buffer.from([4, 5, 6]))+ }++ const result = await fetchExternalImage(+ `${origin()}/redirect.jpg`,+ true,+ MAXIMUM_RESPONSE_BODY+ )++ expect(result.buffer).toEqual(Buffer.from([4, 5, 6]))+ expect(requests).toEqual(['/redirect.jpg', '/final.jpg'])+ })++ it('should throw error when there are too many redirects', async () => {+ handler = (_req, res) => {+ res.writeHead(302, { location: '/loop.jpg' })+ res.end()+ }++ const error = await fetchExternalImage(+ `${origin()}/loop.jpg`,+ true,+ MAXIMUM_RESPONSE_BODY,+ 0+ ).catch((e) => e)++ expect(error).toBeInstanceOf(ImageError)+ expect((error as ImageError).statusCode).toBe(508)+ expect((error as ImageError).message).toBe(+ '"url" parameter is valid but upstream response is invalid'+ )+ })++ it('should not download the body of a redirect response', async () => {+ // `maximumResponseBody` is only enforced on the final response, so+ // draining a redirect body would let the upstream send unlimited bytes+ const redirectBodySize = 64 * 1024 * 1024+ const chunk = Buffer.alloc(1024 * 1024)+ let bytesSent = 0+ let bodyFullyWritten = false+ let redirectClosed: Promise<void> | undefined++ handler = (req, res) => {+ if (req.url !== '/redirect-body.jpg') {+ res.writeHead(200, { 'content-type': 'image/jpeg' })+ return res.end(Buffer.from([4, 5, 6]))+ }+ res.writeHead(302, { location: '/final.jpg' })+ redirectClosed = new Promise((resolve) => res.on('close', resolve))+ const write = () => {+ if (res.destroyed || res.writableEnded) return+ if (bytesSent >= redirectBodySize) {+ bodyFullyWritten = true+ return res.end()+ }+ bytesSent += chunk.byteLength+ if (res.write(chunk)) setImmediate(write)+ else res.once('drain', write)+ }+ write()+ }++ const result = await fetchExternalImage(+ `${origin()}/redirect-body.jpg`,+ true,+ MAXIMUM_RESPONSE_BODY+ )+ await redirectClosed++ expect(result.buffer).toEqual(Buffer.from([4, 5, 6]))+ expect(requests).toEqual(['/redirect-body.jpg', '/final.jpg'])+ // Discarding the body stops at whatever the socket already buffered,+ // whereas draining it would transfer every byte the upstream offered+ expect(bodyFullyWritten).toBe(false)+ expect(bytesSent).toBeLessThan(redirectBodySize / 2)+ })+ })++ describe('upstream timeout', () => {+ // The 7s upstream timeout is not configurable, so both phases are covered+ // by one test to avoid waiting for it twice+ it('should map a timeout to a 504 before the headers and during the body', async () => {+ handler = (req, res) => {+ if (req.url === '/stalled-body.jpg') {+ // Headers arrive, then the body stalls until the timeout fires+ res.writeHead(200, { 'content-type': 'image/jpeg' })+ res.write(Buffer.from([0xff, 0xd8]))+ }+ // `/stalled-headers.jpg` is never answered at all+ }++ const summarize = (error: unknown) =>+ error instanceof ImageError+ ? { statusCode: error.statusCode, message: error.message }+ : { statusCode: 'not an ImageError', message: String(error) }++ const [beforeHeaders, duringBody] = await Promise.all([+ fetchExternalImage(+ `${origin()}/stalled-headers.jpg`,+ true,+ MAXIMUM_RESPONSE_BODY+ ).catch((e) => e),+ fetchExternalImage(+ `${origin()}/stalled-body.jpg`,+ true,+ MAXIMUM_RESPONSE_BODY+ ).catch((e) => e),+ ])++ const timedOut = {+ statusCode: 504,+ message: '"url" parameter is valid but upstream response timed out',+ }+ expect({+ beforeHeaders: summarize(beforeHeaders),+ duringBody: summarize(duringBody),+ }).toEqual({ beforeHeaders: timedOut, duringBody: timedOut })+ }, 20_000)+ })++ describe('response size limit', () => { it('should throw error when exceeding maximumResponseBody config on later chunk', async () => { const maximumResponseBody = 2_000 // 2KB custom limit const chunkSize = 1_000 // 1KB chunks const numChunks = 3 // 3KB total, exceeds custom 2KB limit - global.fetch = jest.fn().mockImplementation(() => {- let chunksRead = 0- const mockReadableStream = new ReadableStream({- async pull(controller) {- if (chunksRead < numChunks) {- controller.enqueue(new Uint8Array(chunkSize))- chunksRead++- } else {- controller.close()- }- },- })-- return Promise.resolve({- ok: true,- status: 200,- body: mockReadableStream,- headers: {- get: jest.fn((header: string) => {- if (header === 'Content-Type') return 'image/jpeg'- return null- }),- },- })- })+ handler = (_req, res) => {+ res.writeHead(200, { 'content-type': 'image/jpeg' })+ for (let i = 0; i < numChunks; i++) {+ res.write(Buffer.alloc(chunkSize))+ }+ res.end()+ } const error = await fetchExternalImage(- 'http://example.com/custom-limit.jpg',- false,+ `${origin()}/custom-limit.jpg`,+ true, maximumResponseBody ).catch((e) => e) @@ -124,30 +672,14 @@ describe('fetchExternalImage', () => { it('should throw error when exceeding maximumResponseBody config on first chunk', async () => { const maximumResponseBody = 2_000 // 2KB custom limit - global.fetch = jest.fn().mockImplementation(() => {- const mockReadableStream = new ReadableStream({- async pull(controller) {- controller.enqueue(new Uint8Array(maximumResponseBody + 1))- controller.close()- },- })-- return Promise.resolve({- ok: true,- status: 200,- body: mockReadableStream,- headers: {- get: jest.fn((header: string) => {- if (header === 'Content-Type') return 'image/jpeg'- return null- }),- },- })- })+ handler = (_req, res) => {+ res.writeHead(200, { 'content-type': 'image/jpeg' })+ res.end(Buffer.alloc(maximumResponseBody + 1))+ } const error = await fetchExternalImage(- 'http://example.com/custom-limit.jpg',- false,+ `${origin()}/custom-limit.jpg`,+ true, maximumResponseBody ).catch((e) => e) @@ -161,30 +693,14 @@ describe('fetchExternalImage', () => { it('should succeed when exactly matching maximumResponseBody config on first chunk', async () => { const maximumResponseBody = 3_000 // 3KB custom limit - global.fetch = jest.fn().mockImplementation(() => {- const mockReadableStream = new ReadableStream({- async pull(controller) {- controller.enqueue(new Uint8Array(maximumResponseBody))- controller.close()- },- })-- return Promise.resolve({- ok: true,- status: 200,- body: mockReadableStream,- headers: {- get: jest.fn((header: string) => {- if (header === 'Content-Type') return 'image/jpeg'- return null- }),- },- })- })+ handler = (_req, res) => {+ res.writeHead(200, { 'content-type': 'image/jpeg' })+ res.end(Buffer.alloc(maximumResponseBody))+ } const result = await fetchExternalImage(- 'http://example.com/custom-limit.jpg',- false,+ `${origin()}/custom-limit.jpg`,+ true, maximumResponseBody ) @@ -197,35 +713,20 @@ describe('fetchExternalImage', () => { const chunkSize = 1_000 // 1KB chunks const numChunks = 3 // 3KB total - global.fetch = jest.fn().mockImplementation(() => {- let chunksRead = 0- const mockReadableStream = new ReadableStream({- async pull(controller) {- if (chunksRead < numChunks) {- controller.enqueue(new Uint8Array(chunkSize))- chunksRead++- } else {- controller.close()- }- },+ handler = (_req, res) => {+ res.writeHead(200, {+ 'content-type': 'image/jpeg',+ 'content-length': String(maximumResponseBody), })-- return Promise.resolve({- ok: true,- status: 200,- body: mockReadableStream,- headers: {- get: jest.fn((header: string) => {- if (header === 'Content-Type') return 'image/jpeg'- return null- }),- },- })- })+ for (let i = 0; i < numChunks; i++) {+ res.write(Buffer.alloc(chunkSize))+ }+ res.end()+ } const result = await fetchExternalImage(- 'http://example.com/custom-limit.jpg',- false,+ `${origin()}/custom-limit.jpg`,+ true, maximumResponseBody )