diff --git a/docs/docs/api/Dispatcher.md b/docs/docs/api/Dispatcher.md index 94a606b09a0..72ff3886717 100644 --- a/docs/docs/api/Dispatcher.md +++ b/docs/docs/api/Dispatcher.md @@ -1275,6 +1275,60 @@ All deduplicated requests receive the complete response including status code, h For observability, request deduplication events are published to the `undici:request:pending-requests` [diagnostic channel](/docs/docs/api/DiagnosticsChannel.md#undicirequestpending-requests). +##### `file` + +The `file` interceptor allows a dispatcher to serve `file:` URLs. + +By default, Undici `fetch()` does not read `file:` URLs. This interceptor is an explicit opt-in mechanism that lets applications define their own policy. + +**Security model** + +- Deny by default. +- You must provide an `allow()` policy callback. +- No reliance on Node's process permission model. + +**Options** + +- `allow({ path, url, method, opts })` - Policy callback. Must return `true` to allow access. Default denies all paths. +- `resolvePath(url)` - Converts a `file:` URL into a filesystem path. Default: `node:url.fileURLToPath`. +- `read(path)` - Reads file content. Default: `node:fs/promises.readFile`. +- `contentType({ path, url, method, opts })` - Optional callback to set a `content-type` header. + +**Example - Enable file URLs for a specific directory with `fetch`** + +```js +const { Agent, fetch, interceptors } = require('undici') + +const root = '/srv/static' + +const dispatcher = new Agent().compose(interceptors.file({ + allow: ({ path }) => path.startsWith(root) +})) + +const response = await fetch(new URL('file:///srv/static/readme.txt'), { + dispatcher +}) + +console.log(await response.text()) +``` + +**Example - Custom content type resolver** + +```js +const nodePath = require('node:path') +const { Agent, request, interceptors } = require('undici') + +const dispatcher = new Agent().compose(interceptors.file({ + allow: ({ path }) => path.endsWith('.json'), + contentType: ({ path }) => { + if (nodePath.extname(path) === '.json') return 'application/json' + } +})) + +const { body } = await request('file:///tmp/data.json', { dispatcher }) +console.log(await body.text()) +``` + ## Instance Events ### Event: `'connect'` diff --git a/index.js b/index.js index 708a8ee80c5..abb60e161c3 100644 --- a/index.js +++ b/index.js @@ -52,7 +52,8 @@ module.exports.interceptors = { dns: require('./lib/interceptor/dns'), cache: require('./lib/interceptor/cache'), decompress: require('./lib/interceptor/decompress'), - deduplicate: require('./lib/interceptor/deduplicate') + deduplicate: require('./lib/interceptor/deduplicate'), + file: require('./lib/interceptor/file') } module.exports.cacheStores = { diff --git a/lib/interceptor/file.js b/lib/interceptor/file.js new file mode 100644 index 00000000000..51bb74c05cf --- /dev/null +++ b/lib/interceptor/file.js @@ -0,0 +1,181 @@ +'use strict' + +const { readFile } = require('node:fs/promises') +const { fileURLToPath } = require('node:url') + +function createAbortController () { + let aborted = false + let reason = null + + return { + resume () {}, + pause () {}, + get paused () { + return false + }, + get aborted () { + return aborted + }, + get reason () { + return reason + }, + abort (err) { + if (aborted) { + return + } + + aborted = true + reason = err ?? new Error('Request aborted') + } + } +} + +function toFileURL (opts) { + if (opts == null || typeof opts !== 'object') { + return null + } + + if (opts.origin != null) { + try { + const origin = opts.origin instanceof URL ? opts.origin : new URL(String(opts.origin)) + if (origin.protocol === 'file:') { + return new URL(opts.path ?? '', origin) + } + } catch { + // Ignore invalid origin and try path. + } + } + + if (typeof opts.path === 'string' && opts.path.startsWith('file:')) { + try { + return new URL(opts.path) + } catch { + return null + } + } + + return null +} + +function toRawHeaders (headers) { + const rawHeaders = [] + for (const [name, value] of Object.entries(headers)) { + rawHeaders.push(Buffer.from(name), Buffer.from(String(value))) + } + return rawHeaders +} + +/** + * @param {import('../../types/interceptors').FileInterceptorOpts} [opts] + */ +function createFileInterceptor (opts = {}) { + const { + allow = () => false, + contentType, + read = readFile, + resolvePath = fileURLToPath + } = opts + + if (typeof allow !== 'function') { + throw new TypeError('file interceptor: opts.allow must be a function') + } + + if (contentType != null && typeof contentType !== 'function') { + throw new TypeError('file interceptor: opts.contentType must be a function') + } + + if (typeof read !== 'function') { + throw new TypeError('file interceptor: opts.read must be a function') + } + + if (typeof resolvePath !== 'function') { + throw new TypeError('file interceptor: opts.resolvePath must be a function') + } + + return dispatch => { + return function fileInterceptorDispatch (dispatchOpts, handler) { + const fileURL = toFileURL(dispatchOpts) + if (!fileURL) { + return dispatch(dispatchOpts, handler) + } + + const controller = createAbortController() + + try { + handler.onConnect?.((err) => controller.abort(err)) + handler.onRequestStart?.(controller, null) + } catch (err) { + handler.onResponseError?.(controller, err) + handler.onError?.(err) + return true + } + + if (controller.aborted) { + return true + } + + ;(async () => { + try { + const method = String(dispatchOpts.method || 'GET').toUpperCase() + if (method !== 'GET' && method !== 'HEAD') { + throw new TypeError(`Method ${method} is not supported for file URLs.`) + } + + const path = resolvePath(fileURL) + const allowed = await allow({ path, url: fileURL, method, opts: dispatchOpts }) + if (!allowed) { + throw new Error(`Access to ${fileURL.href} is not allowed by file interceptor.`) + } + + const fileContent = await read(path) + const chunk = Buffer.isBuffer(fileContent) ? fileContent : Buffer.from(fileContent) + + const headers = { + 'content-length': String(chunk.length) + } + + if (contentType) { + const value = await contentType({ path, url: fileURL, method, opts: dispatchOpts }) + if (typeof value === 'string' && value.length > 0) { + headers['content-type'] = value + } + } + + if (typeof handler.onResponseStart === 'function') { + handler.onResponseStart(controller, 200, headers, 'OK') + } else { + if (typeof handler.onHeaders === 'function') { + handler.onHeaders(200, toRawHeaders(headers), () => {}, 'OK') + } + } + + if (!controller.aborted && method !== 'HEAD') { + if (typeof handler.onResponseData === 'function') { + handler.onResponseData(controller, chunk) + } else { + handler.onData?.(chunk) + } + } + + if (!controller.aborted) { + if (typeof handler.onResponseEnd === 'function') { + handler.onResponseEnd(controller, {}) + } else { + handler.onComplete?.([]) + } + } + } catch (err) { + if (typeof handler.onResponseError === 'function') { + handler.onResponseError(controller, err) + } else { + handler.onError?.(err) + } + } + })() + + return true + } + } +} + +module.exports = createFileInterceptor diff --git a/lib/web/fetch/index.js b/lib/web/fetch/index.js index 8b88904e9d5..9380a174f68 100644 --- a/lib/web/fetch/index.js +++ b/lib/web/fetch/index.js @@ -951,9 +951,9 @@ function schemeFetch (fetchParams) { })) } case 'file:': { - // For now, unfortunate as it is, file URLs are left as an exercise for the reader. - // When in doubt, return a network error. - return Promise.resolve(makeNetworkError('not implemented... yet...')) + // file:// can be handled by custom dispatchers/interceptors. + return httpFetch(fetchParams) + .catch((err) => makeNetworkError(err)) } case 'http:': case 'https:': { @@ -2132,13 +2132,14 @@ async function httpNetworkFetch ( /** @type {import('../../..').Agent} */ const agent = fetchParams.controller.dispatcher + const isFileURL = url.protocol === 'file:' const path = url.pathname + url.search const hasTrailingQuestionMark = url.search.length === 0 && url.href[url.href.length - url.hash.length - 1] === '?' return new Promise((resolve, reject) => agent.dispatch( { - path: hasTrailingQuestionMark ? `${path}?` : path, - origin: url.origin, + path: isFileURL ? url.href : (hasTrailingQuestionMark ? `${path}?` : path), + origin: isFileURL ? undefined : url.origin, method: request.method, body: agent.isMockActive ? request.body && (request.body.source || request.body.stream) : body, headers: request.headersList.entries, diff --git a/test/fetch/file-url-interceptor.js b/test/fetch/file-url-interceptor.js new file mode 100644 index 00000000000..3b988007dcf --- /dev/null +++ b/test/fetch/file-url-interceptor.js @@ -0,0 +1,48 @@ +'use strict' + +const { test } = require('node:test') +const assert = require('node:assert') +const { mkdtemp, writeFile, rm } = require('node:fs/promises') +const { join } = require('node:path') +const { tmpdir } = require('node:os') +const { pathToFileURL } = require('node:url') + +const { Agent, fetch, interceptors } = require('../..') + +test('fetch() rejects file URLs by default', async () => { + const fileURL = pathToFileURL(__filename) + + await assert.rejects(fetch(fileURL), new TypeError('fetch failed')) +}) + +test('fetch() can read file URLs through a custom file interceptor', async (t) => { + const dir = await mkdtemp(join(tmpdir(), 'undici-fetch-file-url-')) + const filePath = join(dir, 'message.txt') + await writeFile(filePath, 'hello from file interceptor') + + const dispatcher = new Agent().compose(interceptors.file({ + allow: ({ path }) => path.startsWith(dir) + })) + + t.after(async () => { + await dispatcher.close() + await rm(dir, { recursive: true, force: true }) + }) + + const response = await fetch(pathToFileURL(filePath), { dispatcher }) + + assert.equal(response.status, 200) + assert.equal(await response.text(), 'hello from file interceptor') +}) + +test('fetch() with file interceptor rejects disallowed paths', async (t) => { + const dispatcher = new Agent().compose(interceptors.file({ + allow: () => false + })) + + t.after(async () => { + await dispatcher.close() + }) + + await assert.rejects(fetch(pathToFileURL(__filename), { dispatcher }), new TypeError('fetch failed')) +}) diff --git a/test/interceptors/file.js b/test/interceptors/file.js new file mode 100644 index 00000000000..33d9219a7c6 --- /dev/null +++ b/test/interceptors/file.js @@ -0,0 +1,96 @@ +'use strict' + +const { test } = require('node:test') +const assert = require('node:assert') +const { mkdtemp, writeFile, rm } = require('node:fs/promises') +const { join } = require('node:path') +const { tmpdir } = require('node:os') +const { pathToFileURL } = require('node:url') + +const createFileInterceptor = require('../../lib/interceptor/file') + +test('file interceptor serves file content for allowed paths', async () => { + const dir = await mkdtemp(join(tmpdir(), 'undici-file-interceptor-')) + const filePath = join(dir, 'hello.txt') + await writeFile(filePath, 'hello world') + + try { + const interceptor = createFileInterceptor({ + allow: ({ path }) => path === filePath, + contentType: () => 'text/plain' + }) + + const dispatch = interceptor(() => { + assert.fail('downstream dispatch must not be called for file URLs') + }) + + const result = await new Promise((resolve, reject) => { + const chunks = [] + let statusCode = 0 + let statusMessage = '' + let rawHeaders = null + + dispatch({ method: 'GET', path: pathToFileURL(filePath).href }, { + onHeaders (code, headers, resume, message) { + statusCode = code + statusMessage = message + rawHeaders = headers + }, + onData (chunk) { + chunks.push(chunk) + return true + }, + onComplete () { + resolve({ statusCode, statusMessage, rawHeaders, chunks }) + }, + onError: reject + }) + }) + + assert.equal(result.statusCode, 200) + assert.equal(result.statusMessage, 'OK') + assert.ok(Array.isArray(result.rawHeaders)) + assert.equal(Buffer.concat(result.chunks).toString(), 'hello world') + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +test('file interceptor blocks disallowed paths', async () => { + const interceptor = createFileInterceptor({ + allow: () => false + }) + + const dispatch = interceptor(() => { + assert.fail('downstream dispatch must not be called for blocked file URLs') + }) + + await assert.rejects(new Promise((resolve, reject) => { + dispatch({ method: 'GET', path: 'file:///tmp/nope.txt' }, { + onComplete: resolve, + onError: reject + }) + }), /not allowed by file interceptor/) +}) + +test('file interceptor passes through non-file requests', async () => { + const interceptor = createFileInterceptor({ + allow: () => true + }) + + let called = false + const dispatch = interceptor((opts, handler) => { + called = true + handler.onError(new Error('downstream')) + return true + }) + + await assert.rejects(new Promise((resolve, reject) => { + dispatch({ method: 'GET', origin: 'https://example.com', path: '/' }, { + onComplete: resolve, + onError: reject + }) + }), /downstream/) + + assert.equal(called, true) +}) diff --git a/types/interceptors.d.ts b/types/interceptors.d.ts index 71983a768c0..55a7ff5dfc4 100644 --- a/types/interceptors.d.ts +++ b/types/interceptors.d.ts @@ -16,6 +16,12 @@ declare namespace Interceptors { export type ResponseErrorInterceptorOpts = { throwOnError: boolean } export type CacheInterceptorOpts = CacheHandler.CacheOptions + export type FileInterceptorOpts = { + allow?: (opts: { path: string, url: URL, method: string, opts: Dispatcher.DispatchOptions }) => boolean | Promise + resolvePath?: (url: URL) => string + read?: (path: string) => Promise + contentType?: (opts: { path: string, url: URL, method: string, opts: Dispatcher.DispatchOptions }) => string | Promise | undefined + } // DNS interceptor export type DNSInterceptorRecord = { address: string, ttl: number, family: 4 | 6 } @@ -77,4 +83,5 @@ declare namespace Interceptors { export function dns (opts?: DNSInterceptorOpts): Dispatcher.DispatcherComposeInterceptor export function cache (opts?: CacheInterceptorOpts): Dispatcher.DispatcherComposeInterceptor export function deduplicate (opts?: DeduplicateInterceptorOpts): Dispatcher.DispatcherComposeInterceptor + export function file (opts?: FileInterceptorOpts): Dispatcher.DispatcherComposeInterceptor }