diff --git a/src/__tests__/__snapshots__/options.defaults.test.ts.snap b/src/__tests__/__snapshots__/options.defaults.test.ts.snap index 0d6e7ba0..8e242856 100644 --- a/src/__tests__/__snapshots__/options.defaults.test.ts.snap +++ b/src/__tests__/__snapshots__/options.defaults.test.ts.snap @@ -178,6 +178,7 @@ exports[`options defaults should return specific properties: defaults 1`] = ` "allowBinary": false, "maxSizeBytes": 5242880, "preflightHead": false, + "sanitizeMessages": true, "timeoutMs": 15000, }, } diff --git a/src/__tests__/__snapshots__/server.fetch.test.ts.snap b/src/__tests__/__snapshots__/server.fetch.test.ts.snap index 784c7f88..b5e3cbfa 100644 --- a/src/__tests__/__snapshots__/server.fetch.test.ts.snap +++ b/src/__tests__/__snapshots__/server.fetch.test.ts.snap @@ -3,7 +3,7 @@ exports[`setFetch should block outbound fetch and log warning in test mode when baseUrl is not configured: warning 1`] = ` [ [ - "Network access blocked for https://patternfly.org: server is in 'test' mode and 'modeOptions.test.baseUrl' is not configured.", + "Network access blocked for https://patternfly.org/: server is in 'test' mode and 'modeOptions.test.baseUrl' is not configured.", "Configure 'modeOptions.test.baseUrl' to allow outbound fetches.", ], ] diff --git a/src/__tests__/logger.helpers.test.ts b/src/__tests__/logger.helpers.test.ts index aa4d2ac4..7f55ebfe 100644 --- a/src/__tests__/logger.helpers.test.ts +++ b/src/__tests__/logger.helpers.test.ts @@ -27,6 +27,53 @@ describe('sanitizeHeaderContent', () => { input: 'Authorization: Bearer abc123', options: { redacted: '' }, expected: 'Authorization: Bearer ' + }, + { + description: 'api_key assignment', + input: 'api_key=xyz123', + expected: 'api_key=[REDACTED]' + }, + { + description: 'api-key header', + input: 'api-key: xyz123', + expected: 'api-key: [REDACTED]' + }, + { + description: 'token assignment', + input: 'token=abc456', + expected: 'token=[REDACTED]' + }, + { + description: 'key assignment', + input: 'key=secret123', + expected: 'key=[REDACTED]' + }, + { + description: 'auth assignment', + input: 'auth=secret123', + expected: 'auth=[REDACTED]' + }, + { + description: 'authorization assignment', + input: 'authorization=secret123', + expected: 'authorization=[REDACTED]' + }, + { + description: 'api-key without space after colon', + input: 'api-key:xyz123', + expected: 'api-key:[REDACTED]' + }, + { + description: 'custom secretParamRegExp option', + input: 'custom_identifier=xyz123', + options: { secretParamRegExp: /custom_identifier/i }, + expected: 'custom_identifier=[REDACTED]' + }, + { + description: 'custom secretParamRegExp option with multiple search params', + input: 'custom_identifier=xyz123!&lorem=ipsum', + options: { secretParamRegExp: /custom_identifier/i }, + expected: 'custom_identifier=[REDACTED]&lorem=ipsum' } ])('should sanitize header content, $description', ({ input, options, expected }) => { expect(sanitizeHeaderContent(input, options as any)).toBe(expected); @@ -55,6 +102,31 @@ describe('sanitizeUrlContent', () => { input: 'Fetch https://example.com/api?access_token=xyz now', expected: 'Fetch https://example.com/api?access_token=%5BREDACTED%5D now' }, + { + description: 'sanitize a URL with a parenthesis in its path', + input: 'Fetch https://example.com/a)path?token=abc123 now', + expected: 'Fetch https://example.com/a)path?token=%5BREDACTED%5D now' + }, + { + description: 'sanitize a token ending in a parenthesis', + input: 'Fetch (https://example.com/docs?token=abc123)', + expected: 'Fetch (https://example.com/docs?token=%5BREDACTED%5D' + }, + { + description: 'sanitize a token ending in nested parentheses', + input: 'Fetch ((https://example.com/a)path?token=abc123))', + expected: 'Fetch ((https://example.com/a)path?token=%5BREDACTED%5D' + }, + { + description: 'sanitize a token ending in square brackets', + input: 'Fetch [https://example.com/docs?token=abc123]', + expected: 'Fetch [https://example.com/docs?token=%5BREDACTED%5D' + }, + { + description: 'sanitize a token ending in an angle bracket', + input: 'Fetch ', + expected: 'Fetch { options: { redacted: '' }, expected: 'https://example.com/docs?token=%3CMASKED%3E' } - ])('should sanitize url content, $description', ({ input, options, expected }) => { + ])('should sanitize url content while maintaining delimiter-like characters, $description', ({ input, options, expected }) => { + // IMPORTANT: Delimiter-like characters are intentionally kept intact and not stripped from URLs. expect(sanitizeUrlContent(input as any, options as any)).toBe(expected); }); }); @@ -136,6 +209,11 @@ describe('sanitizeMessage', () => { description: 'non-string serializable object', input: { auth: 'Authorization: Bearer abc123' }, expected: '{"auth":"Authorization: Bearer [REDACTED]"}' + }, + { + description: 'sanitize non-URL api_key and token assignments', + input: 'Failed upstream: api_key=xyz123 and token=abc456', + expected: 'Failed upstream: api_key=[REDACTED] and token=[REDACTED]' } ])('should sanitize message content, $description', ({ input, expected }) => { expect(sanitizeMessage(input)).toContain(expected); diff --git a/src/__tests__/server.fetch.test.ts b/src/__tests__/server.fetch.test.ts index 4db9ebd5..6c51c75f 100644 --- a/src/__tests__/server.fetch.test.ts +++ b/src/__tests__/server.fetch.test.ts @@ -82,6 +82,79 @@ describe('decodeStream', () => { }); }); +describe('FetchError', () => { + it.each([ + { + description: 'sanitize an error message', + options: { + message: 'Failed to fetch https://patternfly.org/x?private_token=abc123', + sanitize: true + }, + expected: { + message: expect.stringContaining('private_token=%5BREDACTED%5D') + } + }, + { + description: 'not sanitize an error message', + options: { + message: 'Failed to fetch https://patternfly.org/x?private_token=abc123', + sanitize: false + }, + expected: { + message: expect.stringContaining('private_token=abc123') + } + }, + { + description: 'sanitize statusText', + options: { + message: 'Failed to fetch', + statusText: 'Unauthorized: private_token=abc123', + sanitize: true + }, + expected: { + statusText: expect.stringContaining('private_token=[REDACTED]') + } + }, + { + description: 'sanitize statusText with api_key and token', + options: { + message: 'Failed to fetch', + statusText: 'Unauthorized: api_key=xyz123 and token=abc456', + sanitize: true + }, + expected: { + statusText: 'Unauthorized: api_key=[REDACTED] and token=[REDACTED]' + } + }, + { + description: 'not sanitize statusText', + options: { + message: 'Failed to fetch', + statusText: 'Unauthorized: private_token=abc123', + sanitize: false + }, + expected: { + statusText: 'Unauthorized: private_token=abc123' + } + } + ])('should $description', ({ options, expected }) => { + const err = new FetchError(options); + + expect(err).toMatchObject(expected); + }); + + it('should preserve raw cause object identity', () => { + const cause = new Error('upstream fail private_token=abc123'); + const err = new FetchError({ + message: 'wrapper', + cause, + sanitize: true + }); + + expect(err.cause).toBe(cause); + }); +}); + describe('parsePayload', () => { it('should parse valid JSON', async () => { const payload = { kind: 'text' as const, mimeType: 'application/json', text: '{"key": "value"}' }; @@ -252,6 +325,60 @@ describe('setFetch', () => { expect(postPhase).toBe('error'); }); + it.each([ + { + description: 'sanitize URL and statusText secrets', + url: 'https://patternfly.org/secure?private_token=abc123&view=full', + statusText: 'Unauthorized: access_token=secret123', + expected: { + message: expect.stringContaining('private_token=%5BREDACTED%5D'), + statusText: expect.stringContaining('access_token=[REDACTED]') + }, + unexpected: ['private_token=abc123', 'access_token=secret123'] + }, + { + description: 'preserve the status delimiter after sanitizing a request URL', + url: 'https://patternfly.org/secure?access_token=abc123&view=full', + statusText: 'Unauthorized', + expected: { + message: expect.stringContaining('https://patternfly.org/secure?access_token=%5BREDACTED%5D&view=full: 401 Unauthorized'), + statusText: expect.stringContaining('Unauthorized') + }, + unexpected: ['access_token=abc123'] + }, + { + description: 'sanitize a URL secret in statusText with a parenthesis in its path', + url: 'https://patternfly.org/start', + statusText: 'Unauthorized: https://patternfly.org/a)path?token=abc123', + expected: { + message: expect.stringContaining('https://patternfly.org/a)path?token=%5BREDACTED%5D'), + statusText: expect.stringContaining('https://patternfly.org/a)path?token=%5BREDACTED%5D') + }, + unexpected: ['token=abc123'] + } + ])('should $description for a non-ok response', async ({ url, statusText, expected, unexpected }) => { + (global.fetch as jest.Mock).mockResolvedValue({ + ok: false, + status: 401, + statusText, + headers: { + get: () => null + } + }); + + const { get } = setFetch(); + const error = await get(url).catch(reason => reason); + + expect(error).toMatchObject(expected); + + unexpected.forEach(value => { + expect(error).toMatchObject({ + message: expect.not.stringContaining(value), + statusText: expect.not.stringContaining(value) + }); + }); + }); + it('should check content-length against maxSizeBytes', async () => { const mockResponse = { ok: true, @@ -276,7 +403,7 @@ describe('setFetch', () => { expect((status() as any).phase).toBe('error'); }); - it('should reject redirected URLs that are not whitelisted', async () => { + it('should reject redirected URLs that are not allowlisted', async () => { const mockResponse = { ok: true, url: 'https://untrusted.com/data', @@ -292,6 +419,54 @@ describe('setFetch', () => { expect(mockResponse.body.cancel).toHaveBeenCalled(); }); + it('should sanitize secrets in pre-request allowlist errors', async () => { + const options = { + ...getOptions(), + whitelist: { + urls: ['https://allowed.patternfly.org'] + } + }; + const { get } = setFetch(options as any); + + await expect(get('https://blocked.patternfly.org/path?private_token=abc123')).rejects.toMatchObject({ + message: expect.stringContaining('private_token=%5BREDACTED%5D') + }); + + await expect(get('https://blocked.patternfly.org/path?private_token=abc123')).rejects.toMatchObject({ + message: expect.not.stringContaining('private_token=abc123') + }); + }); + + it('should sanitize secrets in post-redirect allowlist errors', async () => { + const options = { + ...getOptions(), + whitelist: { + urls: ['https://www.patternfly.org'] + } + }; + + const mockResponse = { + ok: true, + status: 200, + statusText: 'OK', + url: 'https://error.patternfly.org/landing?access_token=secret123', + headers: { get: () => 'text/plain' }, + body: new ReadableStream({ start(controller) { controller.close(); } }) + }; + + (global.fetch as jest.Mock).mockResolvedValue(mockResponse); + const { get } = setFetch(options as any); + + // Leverage an allowlist url to check post-redirect + await expect(get('https://www.patternfly.org/start')).rejects.toMatchObject({ + message: expect.stringContaining('access_token=%5BREDACTED%5D') + }); + + await expect(get('https://www.patternfly.org/start')).rejects.toMatchObject({ + message: expect.not.stringContaining('access_token=secret123') + }); + }); + it('should de-duplicate concurrent requests to the same URL', async () => { (global.fetch as jest.Mock).mockReturnValue(new Promise(() => {})); // Hang const { get } = setFetch(); @@ -401,6 +576,30 @@ describe('setFetch', () => { warnSpy.mockRestore(); }); + it('should preserve the warning URL when message sanitization is disabled', async () => { + const warnSpy = jest.spyOn(log, 'warn').mockImplementation(() => {}); + const url = 'https://patternfly.org/path?private_token=abc123'; + const options = getOptions(); + const fetchOptions = { + ...options, + mode: 'test' as const, + modeOptions: {}, + xhrFetch: { + ...options.xhrFetch, + sanitizeMessages: false + } + }; + const { get } = setFetch(fetchOptions as any); + + await expect(get(url)).rejects.toThrow(FetchError); + expect(warnSpy).toHaveBeenCalledWith( + `Network access blocked for ${url}: server is in 'test' mode and 'modeOptions.test.baseUrl' is not configured.`, + `Configure 'modeOptions.test.baseUrl' to allow outbound fetches.` + ); + + warnSpy.mockRestore(); + }); + it('should allow matching fixture URL and reject external URLs in test mode with baseUrl configured', async () => { const warnSpy = jest.spyOn(log, 'warn').mockImplementation(() => {}); @@ -450,4 +649,34 @@ describe('setFetch', () => { warnSpy.mockRestore(); }); + + it.each([ + { + description: 'sanitize unknown errors by default', + sanitizeMessages: undefined, + expectedMessage: 'lorem ipsum private_token=[REDACTED]' + }, + { + description: 'retain unknown errors when sanitization is disabled', + sanitizeMessages: false, + expectedMessage: 'lorem ipsum private_token=abc123' + } + ])('should $description', async ({ sanitizeMessages, expectedMessage }) => { + (global.fetch as jest.Mock).mockRejectedValue(new Error('lorem ipsum private_token=abc123')); + const options = getOptions(); + const fetchOptions = sanitizeMessages === undefined + ? undefined + : { + ...options, + xhrFetch: { + ...options.xhrFetch, + sanitizeMessages + } + }; + const { get } = setFetch(fetchOptions); + + await expect(get('https://patternfly.org/data')).rejects.toMatchObject({ + message: expect.stringContaining(expectedMessage) + }); + }); }); diff --git a/src/logger.helpers.ts b/src/logger.helpers.ts index 406dfa12..5ffc622d 100644 --- a/src/logger.helpers.ts +++ b/src/logger.helpers.ts @@ -16,24 +16,19 @@ const SECRET_PARAM_REGEXP = /(access[_-]?token|private[_-]?token|token|auth|auth const BEARER_HEADER_REGEXP = /(authorization\s*:\s*)(bearer\s+)([^\s"'}]+)(\s*)/gi; /** - * Match for private token assignments. - */ -const PRIVATE_TOKEN_REGEXP = /(private[_-]?token\s*[:=]\s*)([^\s&]+)/gi; - -/** - * Sanitize header-like content. + * Sanitize header-like content and key-value assignments. * * @param input - String to sanitize. * @param [options] - Configurable options. * @param [options.redacted] - Redact default value. * @param [options.bearerHeaderRegExp] - Bearer header regex. - * @param [options.privateTokenRegExp] - Private token regex. + * @param [options.secretParamRegExp] - Secret param regex. */ const sanitizeHeaderContent = (input?: unknown, { redacted = REDACTED, bearerHeaderRegExp = BEARER_HEADER_REGEXP, - privateTokenRegExp = PRIVATE_TOKEN_REGEXP -}: { redacted?: string; bearerHeaderRegExp?: RegExp; privateTokenRegExp?: RegExp } = {}) => { + secretParamRegExp = SECRET_PARAM_REGEXP +}: { redacted?: string; bearerHeaderRegExp?: RegExp; secretParamRegExp?: RegExp } = {}) => { if (typeof input !== 'string') { return undefined; } @@ -43,12 +38,63 @@ const sanitizeHeaderContent = (input?: unknown, { (_m, g1, g2, _g3, suffix) => `${g1}${g2}${redacted}${suffix}` ); - return withBearerRedaction.replace(privateTokenRegExp, (_m, g1) => `${g1}${redacted}`); + const tokens = withBearerRedaction.split(/(\s+|&)/); + + for (let i = 0; i < tokens.length; i += 1) { + const token = tokens[i]; + + if (!token) { + continue; + } + + const eqIdx = token.indexOf('='); + + if (eqIdx > 0) { + const key = token.substring(0, eqIdx); + const val = token.substring(eqIdx + 1); + + if (secretParamRegExp.test(key) && val.length > 0 && val !== redacted) { + tokens[i] = `${key}=${redacted}`; + } + + continue; + } + + const colonIdx = token.indexOf(':'); + + if (colonIdx > 0) { + const key = token.substring(0, colonIdx); + const rest = token.substring(colonIdx + 1); + + if (/^[a-zA-Z0-9_-]+$/.test(key) && key.toLowerCase() !== 'unauthorized' && secretParamRegExp.test(key)) { + if (rest.length > 0 && rest !== redacted && rest.toLowerCase() !== 'bearer') { + tokens[i] = `${key}:${redacted}`; + } else if (rest.length === 0 && i + 2 < tokens.length) { + const nextVal = tokens[i + 2]; + + if (nextVal && nextVal !== redacted && nextVal.toLowerCase() !== 'bearer') { + tokens[i + 2] = redacted; + } + } + } + } + } + + return tokens.join(''); }; /** * Sanitize a URL string. * + * @note **IMPORTANT! Keep matched URL candidates intact.** Do not filter, trim, or strip trailing + * delimiter characters (such as `)`, `]`, or `>`) based on surrounding prose. It is ambiguous + * and unreliable to determine whether trailing punctuation belongs to the URL or surrounding + * text in arbitrary prose; attempting to filter them introduces fragile heuristics and false + * positives. Candidates are kept intact and delegated directly to `parseUrl`. + * + * @note URL query and basic-auth redaction applies only to candidates accepted by `parseUrl`. Malformed URL-like + * text is returned unchanged; this helper is not a general-purpose `key=value` secret scanner. + * * @param input - String to sanitize. * @param [options] - Configurable options. * @param [options.redacted] - Redact default value. @@ -87,7 +133,9 @@ const sanitizeUrlContent = ( return parsed.toString(); }; - return input.replace(/https?:\/\/[^\s)]+/gi, (urlCandidate: string) => + // IMPORTANT: Keep matched URL candidates intact. Do not filter or strip trailing delimiters (e.g., ')', ']', '>') + // based on surrounding prose. Delimiter classification in unstructured text is unreliable. + return input.replace(/https?:\/\/[^\s]+/gi, (urlCandidate: string) => sanitize(urlCandidate) || urlCandidate); }; @@ -128,11 +176,14 @@ const sanitizeTokenContent = ( }; /** - * Sanitize arbitrary text for logs by redacting: - * - Authorization/Private-Token header patterns - * - Secrets embedded in URLs (query params, basic auth) via sanitizeUrl + * Best-effort sanitization of log text by redacting: + * - Authorization header patterns and secret key/token assignments + * - Secrets in parseable URL candidates (query params, basic auth) via sanitizeUrlContent * - Long token-like substrings (hex/base64-like) * + * @note This helper does not guarantee redaction of malformed URL-like text or unrecognized + * short key/value patterns, it is a best-effort attempt. + * * @param input */ const sanitizeMessage = ( diff --git a/src/options.defaults.ts b/src/options.defaults.ts index 30004e3d..2e4bde63 100644 --- a/src/options.defaults.ts +++ b/src/options.defaults.ts @@ -337,12 +337,14 @@ interface WhitelistOptions { * @property maxSizeBytes Maximum size of a single request (bytes). * @property timeoutMs Timeout for XHR and Fetch requests (ms). * @property preflightHead Perform a preflight HEAD request before the actual request. + * @property sanitizeMessages Sanitize messages before logging. */ interface XhrFetchOptions { allowBinary: boolean; maxSizeBytes: number; timeoutMs: number; preflightHead: boolean; + sanitizeMessages: boolean; } /** @@ -513,7 +515,8 @@ const XHR_FETCH_OPTIONS: XhrFetchOptions = { allowBinary: false, maxSizeBytes: 1024 * 1024 * 5, timeoutMs: 15_000, - preflightHead: false + preflightHead: false, + sanitizeMessages: true }; /** diff --git a/src/server.fetch.ts b/src/server.fetch.ts index 4b7add81..f9eee323 100644 --- a/src/server.fetch.ts +++ b/src/server.fetch.ts @@ -2,6 +2,7 @@ import { Readable } from 'node:stream'; import { type ReadableStream } from 'node:stream/web'; import { getOptions } from './options.context'; import { formatUnknownError, log } from './logger'; +import { sanitizeMessage } from './logger.helpers'; import { memo } from './server.caching'; import { assertInputUrlWhiteListed } from './server.assertions'; import { isUrl } from './server.helpers'; @@ -202,6 +203,8 @@ class FetchError extends Error { * @param options.statusText - HTTP status text. * @param options.cause - Cause of the error. * @param options.cancelled - Indicates if the fetch operation was canceled. + * @param options.sanitize - Applies best-effort sanitization to `message` and `statusText`. The `cause` is + * preserved unchanged. */ constructor(options: { message: string; @@ -209,11 +212,14 @@ class FetchError extends Error { statusText?: string | undefined; cause?: unknown | undefined; cancelled?: boolean | undefined; + sanitize?: boolean | undefined; }) { - super(options.message); + const updatedMessage = options.sanitize ? sanitizeMessage(options.message) : options.message; + + super(updatedMessage); this.status = options.status; - this.statusText = options.statusText; + this.statusText = options.sanitize && options.statusText ? sanitizeMessage(options.statusText) : options.statusText; this.cause = options.cause; this.cancelled = options.cancelled ?? false; } @@ -266,7 +272,11 @@ const parsePayload = async ( data: JSON.parse(decoded.text) }; } catch (err) { - throw new FetchError({ message: 'Invalid JSON payload.', cause: err }); + throw new FetchError({ + message: 'Invalid JSON payload.', + cause: err, + sanitize: xhrFetch.sanitizeMessages + }); } } @@ -280,7 +290,10 @@ const parsePayload = async ( }; } - throw new FetchError({ message: `Binary data is not allowed (${mime}).` }); + throw new FetchError({ + message: `Binary data is not allowed (${mime}).`, + sanitize: xhrFetch.sanitizeMessages + }); }; /** @@ -310,6 +323,7 @@ const parsePayload = async ( * - `bytes` {number} - The number of bytes processed so far. * - `progress` {number | undefined} - The percentage progress of the stream (optional, depends on having * `totalSize`). + * @param [params.sanitizeMessages] - Activates message sanitizing. * @returns A promise resolving to an object containing either: * - `{ kind: 'text', text: string }` if the stream is decoded as text. * - `{ kind: 'bytes', chunks: Uint8Array[] }` if the stream is decoded as binary data. @@ -317,13 +331,14 @@ const parsePayload = async ( * @throws {FetchError} If accumulated size exceeds `maxSizeBytes`. */ const decodeStream = async ({ - stream, mimeType, totalSize, maxSizeBytes, onProgress + stream, mimeType, totalSize, maxSizeBytes, onProgress, sanitizeMessages }: { stream: Readable; mimeType: string; totalSize?: number | undefined; maxSizeBytes: number; onProgress: (bytes: number, progress?: number | undefined) => void; + sanitizeMessages?: boolean | undefined; }): Promise => { const asText = isTextMime(mimeType); const decoder = asText ? new TextDecoder('utf-8') : undefined; @@ -338,7 +353,10 @@ const decodeStream = async ({ if (maxSizeBytes && totalBytes > maxSizeBytes) { stream.destroy(); - throw new FetchError({ message: 'File download aborted: Size exceeded maximum limit.' }); + throw new FetchError({ + message: 'File download aborted: Size exceeded maximum limit.', + sanitize: sanitizeMessages + }); } if (decoder) { @@ -371,8 +389,6 @@ const decodeStream = async ({ /** * Opt-in HEAD preflight. * - * @param url - * @param signal * @note Off by default. When enabled via `options.xhrFetch.preflightHead`, * we HEAD the URL to catch oversized / disallowed-binary responses without * opening a body stream. Returns `null` (and callers fall back to GET-only @@ -384,6 +400,9 @@ const decodeStream = async ({ * Header values from HEAD are advisory. Authoritative size enforcement * still lives in `decodeStream`. Authoritative type enforcement still * lives in `parsePayload`. HEAD is a fast-fail hint, not a gate. + * + * @param url + * @param signal */ const preflight = async ( url: string, @@ -508,7 +527,10 @@ const setFetch = (options = getOptions()): SetFetch => { return inflight.promise; } - return Promise.reject(new FetchError({ message: 'Fetch already in progress. Create a new setFetch.' })); + return Promise.reject(new FetchError({ + message: 'Fetch already in progress. Create a new setFetch.', + sanitize: xhrFetch.sanitizeMessages + })); } const promise = startFetch().finally(() => { @@ -529,8 +551,10 @@ const setFetch = (options = getOptions()): SetFetch => { */ const executeFetch = async (url: string, settings: RequestInit = {}): Promise => { if (isTestModeFetchBlocked) { + const displayUrl = xhrFetch.sanitizeMessages ? sanitizeMessage(url) : url; + log.warn( - `Network access blocked for ${url}: server is in 'test' mode and 'modeOptions.test.baseUrl' is not configured.`, + `Network access blocked for ${displayUrl}: server is in 'test' mode and 'modeOptions.test.baseUrl' is not configured.`, `Configure 'modeOptions.test.baseUrl' to allow outbound fetches.` ); } @@ -549,7 +573,7 @@ const setFetch = (options = getOptions()): SetFetch => { assertInputUrlWhiteListed(url, updatedWhitelist, { allowedProtocols: whitelist.protocols, inputDisplayName: 'setFetch URL', - codeOrError: (message, cause) => new FetchError({ message, cause }) + codeOrError: (message, cause) => new FetchError({ message, cause, sanitize: xhrFetch.sanitizeMessages }) }); if (xhrFetch.preflightHead) { @@ -558,13 +582,15 @@ const setFetch = (options = getOptions()): SetFetch => { if (hint) { if (hint.contentLength && xhrFetch.maxSizeBytes && hint.contentLength > xhrFetch.maxSizeBytes) { throw new FetchError({ - message: `File blocked (preflight): content-length ${hint.contentLength} exceeds ${xhrFetch.maxSizeBytes}.` + message: `File blocked (preflight): content-length ${hint.contentLength} exceeds ${xhrFetch.maxSizeBytes}.`, + sanitize: xhrFetch.sanitizeMessages }); } if (hint.contentType && isBinaryMime(hint.contentType) && !xhrFetch.allowBinary) { throw new FetchError({ - message: `Binary data is not allowed (preflight: ${normalizeMime(hint.contentType)}).` + message: `Binary data is not allowed (preflight: ${normalizeMime(hint.contentType)}).`, + sanitize: xhrFetch.sanitizeMessages }); } } @@ -578,7 +604,7 @@ const setFetch = (options = getOptions()): SetFetch => { await Promise.resolve().then(() => assertInputUrlWhiteListed(response.url, updatedWhitelist, { allowedProtocols: whitelist.protocols, inputDisplayName: 'setFetch URL', - codeOrError: (message, cause) => new FetchError({ message, cause }) + codeOrError: (message, cause) => new FetchError({ message, cause, sanitize: xhrFetch.sanitizeMessages }) })).catch(error => { response.body?.cancel?.().catch(() => {}); throw error; @@ -586,10 +612,15 @@ const setFetch = (options = getOptions()): SetFetch => { } if (!response.ok) { + const displayUrl = xhrFetch.sanitizeMessages ? sanitizeMessage(url) : url; + const displayStatusText = xhrFetch.sanitizeMessages ? sanitizeMessage(response.statusText) : response.statusText; + throw new FetchError({ - message: `Failed to fetch ${url}: ${response.status} ${response.statusText}`, + message: `Failed to fetch ${displayUrl}: ${response.status} ${displayStatusText}`, status: response.status, - statusText: response.statusText + statusText: displayStatusText, + // When enabled, sanitize values before the status delimiter is added + sanitize: false }); } @@ -603,7 +634,8 @@ const setFetch = (options = getOptions()): SetFetch => { throw new FetchError({ message, status: response.status, - statusText: response.statusText + statusText: response.statusText, + sanitize: xhrFetch.sanitizeMessages }); }; @@ -623,6 +655,7 @@ const setFetch = (options = getOptions()): SetFetch => { mimeType, totalSize, maxSizeBytes: xhrFetch.maxSizeBytes, + sanitizeMessages: xhrFetch.sanitizeMessages, onProgress: (bytesReceived, progress) => updateState({ bytesReceived, progress }) }) : ({ kind: 'text', text: '' } as const); @@ -643,7 +676,13 @@ const setFetch = (options = getOptions()): SetFetch => { const fetchError = error instanceof FetchError ? error - : new FetchError({ message: formatUnknownError(error), cause: error, cancelled }); + : new FetchError({ + message: formatUnknownError(error, { sanitize: xhrFetch.sanitizeMessages }), + cause: error, + cancelled, + // Already sanitized by formatUnknownError; avoid redundant pass + sanitize: false + }); updateState({ phase: cancelled ? 'cancelled' : 'error', error: fetchError, message: fetchError.message }); throw fetchError; diff --git a/src/server.getResources.ts b/src/server.getResources.ts index f2490340..eab786bc 100644 --- a/src/server.getResources.ts +++ b/src/server.getResources.ts @@ -10,6 +10,7 @@ import { isUrl, isPath, createError } from './server.helpers'; import { FetchError, setFetch } from './server.fetch'; import { delay } from './server.task'; import { log, formatUnknownError } from './logger'; +import { sanitizeMessage } from './logger.helpers'; /** * Represents a successful document processing attempt. @@ -392,7 +393,7 @@ const processDocsFunction = async = Record), - content: `❌ Failed to load ${errorPath}: ${errorMessage}`, + content: `❌ Failed to load ${sanitizeMessage(errorPath)}: ${formatUnknownError(errorMessage)}`, path: errorPath, resolvedPath: errorResolvedPath, isSuccess: false