diff --git a/packages/core/src/network.js b/packages/core/src/network.js index 07ec9bfa8..d123d41f3 100644 --- a/packages/core/src/network.js +++ b/packages/core/src/network.js @@ -668,6 +668,15 @@ async function sendResponseResource(network, request, session) { let meta = { ...network.meta, url }; let send = (method, params) => network.send(session, method, params); + // The resource cache is keyed by URL only, and cached bodies are GET responses. + // Serving one for an unsafe method (POST/PUT/PATCH/DELETE) would answer a + // state-changing request with stale content and stop it from ever reaching the + // origin — e.g. an in-page login POST issued from an execute script would be + // fulfilled from the cached login page and never authenticate (PER-9766). Only + // safe, cacheable methods may be served from cache; anything else (or an absent + // method) safely falls through to a real origin request. + let cacheableMethod = request.method === 'GET' || request.method === 'HEAD'; + try { let resource = network.intercept.getResource(url, network.intercept.currentWidth); network.log.debug(`Handling request: ${url}`, meta); @@ -684,7 +693,7 @@ async function sendResponseResource(network, request, session) { requestId: request.interceptId, errorReason: 'Aborted' }); - } else if (resource && (resource.root || resource.provided || !disableCache)) { + } else if (resource && cacheableMethod && (resource.root || resource.provided || !disableCache)) { // Don't rename the below log line as it is used in getting network logs in api log.debug(resource.root ? '- Serving root resource' : '- Resource cache hit', meta); diff --git a/packages/core/test/discovery.test.js b/packages/core/test/discovery.test.js index 14be2af75..1e4139ce7 100644 --- a/packages/core/test/discovery.test.js +++ b/packages/core/test/discovery.test.js @@ -2560,6 +2560,32 @@ describe('Discovery', () => { let nonRootResources = Array.from(percy[RESOURCE_CACHE_KEY].values()).filter(resource => !resource.root); expect(nonRootResources.length).toEqual(2); }); + + it('serves cached resources only for cacheable methods (PER-9766)', async () => { + // A resource is cached on the initial GET during discovery. An in-page POST to + // that same URL (e.g. a login form submitted from an execute script) must reach + // the origin instead of being answered from the URL-keyed cache — otherwise the + // request never authenticates and the snapshot captures unauthenticated content. + let methods = []; + server.reply('/cached.css', req => (methods.push(req.method), [200, 'text/css', '.a{}'])); + server.reply('/', () => [200, 'text/html', dedent` +
+auth
` + ]); + + await percy.snapshot({ + name: 'post bypasses cache', + url: 'http://localhost:8000', + // execute runs after initial discovery, so /cached.css is already cached here + execute: async () => { await fetch('/cached.css', { method: 'POST' }); } + }); + await percy.idle(); + + // GET is cached on first load; the POST must NOT be served from cache, so the + // origin sees it too. On the buggy path the POST was a cache hit and never arrived. + expect(methods).toContain('GET'); + expect(methods).toContain('POST'); + }); }); describe('with --max-cache-ram', () => {