From 8e712f692a250d5a545926f4a4aaa0f55093c9ae Mon Sep 17 00:00:00 2001 From: Ninad Sheth Date: Mon, 5 Oct 2026 12:21:44 +0000 Subject: [PATCH 1/2] fix(core): keep browser font body when direct font fetch fails (PER-10978) Fonts are always re-fetched from Node (makeDirectRequest) and the browser response body is discarded. When that Node-side fetch throws (e.g. a private host the discovery browser reaches through a proxy/tunnel but Node cannot resolve: getaddrinfo ENOTFOUND), the whole font was dropped, so the render fell back to the default font. Fall back to the body the browser already loaded instead; the cloud-metadata SSRF guard still drops the resource. Co-Authored-By: Claude Opus 5.5 --- packages/core/src/network.js | 14 ++++- packages/core/test/discovery.test.js | 81 ++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 2 deletions(-) diff --git a/packages/core/src/network.js b/packages/core/src/network.js index d5ef21f5b..88a7e53d0 100644 --- a/packages/core/src/network.js +++ b/packages/core/src/network.js @@ -1114,8 +1114,18 @@ async function saveResponseResource(network, request, session) { // so request them directly. if (mimeType?.includes('font') || (detectedMime && detectedMime.includes('font'))) { log.debug('- Requesting asset directly', meta); - ({ body } = await makeDirectRequest(network, request, session)); - log.debug('- Got direct response', meta); + try { + ({ body } = await makeDirectRequest(network, request, session)); + log.debug('- Got direct response', meta); + } catch (error) { + // The SSRF metadata guard must still drop the resource. + if (error instanceof MetadataBlockedError) throw error; + // The direct fetch runs from Node, outside the browser's network stack, so it + // can fail where the browser succeeded (e.g. a private host the browser reaches + // through a proxy/tunnel, or DNS only the browser can resolve). Keep the body + // the browser already loaded instead of dropping the font entirely. + log.debug(`- Direct request failed, using browser response: ${error.message}`, meta); + } } resource = createResource(url, body, mimeType, { diff --git a/packages/core/test/discovery.test.js b/packages/core/test/discovery.test.js index 4d64606ce..5516d9c79 100644 --- a/packages/core/test/discovery.test.js +++ b/packages/core/test/discovery.test.js @@ -2505,6 +2505,87 @@ describe('Discovery', () => { ])); }); + describe('when the direct font request fails', () => { + const fontDOM = dedent` + + + + + +

Hello Percy!

+ ${' '.repeat(1000)} + + + `; + + beforeEach(() => { + percy.loglevel('debug'); + server.reply('/direct-fail/font.woff', () => [200, 'font/woff', '']); + }); + + it('falls back to the browser response body', async () => { + // The browser can load the font, but Node's direct fetch cannot (e.g. a host + // only the browser can resolve through its proxy/tunnel). + spyOn(Network.prototype, 'directFetch').and.callFake(async function(request) { + if (request.url.endsWith('/direct-fail/font.woff')) { + throw new Error('getaddrinfo ENOTFOUND private.example'); + } + return Network.prototype.directFetch.and.originalFn.apply(this, arguments); + }); + + await percy.snapshot({ + name: 'direct fail font snapshot', + url: 'http://localhost:8000', + domSnapshot: fontDOM + }); + + await percy.idle(); + + expect(logger.stderr).toContain( + '[percy:core:discovery] - Direct request failed, using browser response: ' + + 'getaddrinfo ENOTFOUND private.example' + ); + expect(captured[0]).toEqual(jasmine.arrayContaining([ + jasmine.objectContaining({ + attributes: jasmine.objectContaining({ + 'resource-url': 'http://localhost:8000/direct-fail/font.woff' + }) + }) + ])); + }); + + it('still drops the font when the metadata guard blocks it', async () => { + let origDirectFetch = Network.prototype.directFetch; + spyOn(Network.prototype, 'directFetch').and.callFake(async function(request, session) { + let result = await origDirectFetch.call(this, request, session); + if (request.url.endsWith('/direct-fail/font.woff')) { + return { ...result, remoteAddresses: ['169.254.169.254'] }; + } + return result; + }); + + await percy.snapshot({ + name: 'direct fail metadata font snapshot', + url: 'http://localhost:8000', + domSnapshot: fontDOM + }); + + await percy.idle(); + + expect(logger.stderr).not.toContain(jasmine.stringMatching( + /Direct request failed, using browser response/ + )); + expect(captured[0]).not.toContain(jasmine.objectContaining({ + attributes: jasmine.objectContaining({ + 'resource-url': 'http://localhost:8000/direct-fail/font.woff' + }) + })); + }); + }); + it('captures fonts with valid username basic auth', async () => { percy.loglevel('debug'); From 446751f354c9863903d25e38d0261cdd4f7e2da0 Mon Sep 17 00:00:00 2001 From: Ninad Sheth Date: Mon, 5 Oct 2026 12:55:08 +0000 Subject: [PATCH 2/2] test(core): update direct-font-failure spec for browser-body fallback (PER-10978) Co-Authored-By: Claude Opus 5.5 --- packages/core/test/discovery.test.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/core/test/discovery.test.js b/packages/core/test/discovery.test.js index 5516d9c79..64fc0f6ad 100644 --- a/packages/core/test/discovery.test.js +++ b/packages/core/test/discovery.test.js @@ -3452,7 +3452,7 @@ describe('Discovery', () => { ])); }); - it('logs gracefully when direct font request fails', async () => { + it('falls back to the browser body when direct font request fails', async () => { server.reply('/style.css', () => [200, 'text/css', [ '@font-face { font-family: "test"; src: url("/font.woff") format("woff"); }', 'body { font-family: "test", "sans-serif"; }' @@ -3475,8 +3475,18 @@ describe('Discovery', () => { await percy.idle(); expect(logger.stderr).toEqual(jasmine.arrayContaining([ + jasmine.stringMatching('- Direct request failed, using browser response:') + ])); + expect(logger.stderr).not.toEqual(jasmine.arrayContaining([ jasmine.stringMatching('Encountered an error processing resource: http://localhost:8000/font.woff') ])); + expect(captured[0]).toEqual(jasmine.arrayContaining([ + jasmine.objectContaining({ + attributes: jasmine.objectContaining({ + 'resource-url': 'http://localhost:8000/font.woff' + }) + }) + ])); }); it('continues responses gracefully when the request is untracked', async () => {