diff --git a/README.md b/README.md index e1199f246d7ed..022213142207b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 🎭 Playwright -[![npm version](https://img.shields.io/npm/v/playwright.svg)](https://www.npmjs.com/package/playwright) [![Chromium version](https://img.shields.io/badge/chromium-144.0.7559.20-blue.svg?logo=google-chrome)](https://www.chromium.org/Home) [![Firefox version](https://img.shields.io/badge/firefox-145.0.2-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/) [![WebKit version](https://img.shields.io/badge/webkit-26.0-blue.svg?logo=safari)](https://webkit.org/) [![Join Discord](https://img.shields.io/badge/join-discord-informational)](https://aka.ms/playwright/discord) +[![npm version](https://img.shields.io/npm/v/playwright.svg)](https://www.npmjs.com/package/playwright) [![Chromium version](https://img.shields.io/badge/chromium-144.0.7559.20-blue.svg?logo=google-chrome)](https://www.chromium.org/Home) [![Firefox version](https://img.shields.io/badge/firefox-146.0.1-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/) [![WebKit version](https://img.shields.io/badge/webkit-26.0-blue.svg?logo=safari)](https://webkit.org/) [![Join Discord](https://img.shields.io/badge/join-discord-informational)](https://aka.ms/playwright/discord) ## [Documentation](https://playwright.dev) | [API reference](https://playwright.dev/docs/api/class-playwright) @@ -10,7 +10,7 @@ Playwright is a framework for Web Testing and Automation. It allows testing [Chr | :--- | :---: | :---: | :---: | | Chromium 144.0.7559.20 | :white_check_mark: | :white_check_mark: | :white_check_mark: | | WebKit 26.0 | :white_check_mark: | :white_check_mark: | :white_check_mark: | -| Firefox 145.0.2 | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| Firefox 146.0.1 | :white_check_mark: | :white_check_mark: | :white_check_mark: | Headless execution is supported for all browsers on all platforms. Check out [system requirements](https://playwright.dev/docs/intro#system-requirements) for details. diff --git a/browser_patches/firefox/juggler/NetworkObserver.js b/browser_patches/firefox/juggler/NetworkObserver.js index 2719cc1023b47..7fdc05a01a1e9 100644 --- a/browser_patches/firefox/juggler/NetworkObserver.js +++ b/browser_patches/firefox/juggler/NetworkObserver.js @@ -810,6 +810,56 @@ function overrideRequestHeaders(httpChannel, headers) { appendExtraHTTPHeaders(httpChannel, headers); } +// Forbidden request headers according to https://developer.mozilla.org/en-US/docs/Glossary/Forbidden_request_header +// These headers cannot be set or modified programmatically. +const FORBIDDEN_HEADER_NAMES = new Set([ + 'accept-charset', + 'accept-encoding', + 'access-control-request-headers', + 'access-control-request-method', + 'connection', + 'content-length', + 'cookie', + 'date', + 'dnt', + 'expect', + 'host', + 'keep-alive', + 'origin', + 'referer', + 'set-cookie', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', + 'via', +]); + +// Forbidden method names for X-HTTP-Method-* headers +const FORBIDDEN_METHODS = new Set(['CONNECT', 'TRACE', 'TRACK']); + +function isForbiddenHeader(name, value) { + const lowerName = name.toLowerCase(); + + if (FORBIDDEN_HEADER_NAMES.has(lowerName)) + return true; + + if (lowerName.startsWith('proxy-')) + return true; + + if (lowerName.startsWith('sec-')) + return true; + + if (lowerName === 'x-http-method' || + lowerName === 'x-http-method-override' || + lowerName === 'x-method-override') { + if (value && FORBIDDEN_METHODS.has(value.toUpperCase())) + return true; + } + + return false; +} + const redirectStatus = [301, 302, 303, 307, 308]; function filterHeadersForRedirect(headers, requestMethod, status) { diff --git a/docs/src/api/class-route.md b/docs/src/api/class-route.md index dbbe12149614b..5bbe4b3f8374b 100644 --- a/docs/src/api/class-route.md +++ b/docs/src/api/class-route.md @@ -107,7 +107,10 @@ The [`option: headers`] option applies to both the routed request and any redire [`method: Route.continue`] will immediately send the request to the network, other matching handlers won't be invoked. Use [`method: Route.fallback`] If you want next matching handler in the chain to be invoked. :::warning -The `Cookie` header cannot be overridden using this method. If a value is provided, it will be ignored, and the cookie will be loaded from the browser's cookie store. To set custom cookies, use [`method: BrowserContext.addCookies`]. +Some request headers are **forbidden** and cannot be overridden (for example, `Cookie`, `Host`, `Content-Length` and others, see [this MDN page](https://developer.mozilla.org/en-US/docs/Glossary/Forbidden_request_header) for full list). +If an override is provided for a forbidden header, it will be ignored and the original request header will be used. + +To set custom cookies, use [`method: BrowserContext.addCookies`]. ::: ### option: Route.continue.url diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index 644abf75bbe7e..c9a974c18796c 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -21121,8 +21121,12 @@ export interface Route { * [route.fallback([options])](https://playwright.dev/docs/api/class-route#route-fallback) If you want next matching * handler in the chain to be invoked. * - * **NOTE** The `Cookie` header cannot be overridden using this method. If a value is provided, it will be ignored, - * and the cookie will be loaded from the browser's cookie store. To set custom cookies, use + * **NOTE** Some request headers are **forbidden** and cannot be overridden (for example, `Cookie`, `Host`, + * `Content-Length` and others, see + * [this MDN page](https://developer.mozilla.org/en-US/docs/Glossary/Forbidden_request_header) for full list). If an + * override is provided for a forbidden header, it will be ignored and the original request header will be used. + * + * To set custom cookies, use * [browserContext.addCookies(cookies)](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-cookies). * * @param options diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index abbcc68a796bb..768dfb891bbcb 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -31,14 +31,14 @@ }, { "name": "firefox", - "revision": "1507", + "revision": "1509", "installByDefault": true, - "browserVersion": "145.0.2", + "browserVersion": "146.0.1", "title": "Firefox" }, { "name": "firefox-beta", - "revision": "1502", + "revision": "1504", "installByDefault": false, "browserVersion": "146.0b8", "title": "Firefox Beta" diff --git a/packages/playwright-core/src/server/chromium/crNetworkManager.ts b/packages/playwright-core/src/server/chromium/crNetworkManager.ts index 9ded7210f1447..f6d33847091c9 100644 --- a/packages/playwright-core/src/server/chromium/crNetworkManager.ts +++ b/packages/playwright-core/src/server/chromium/crNetworkManager.ts @@ -340,6 +340,10 @@ export class CRNetworkManager { if (redirectedFrom || (!this._userRequestInterceptionEnabled && this._protocolRequestInterceptionEnabled)) { // Chromium does not preserve header overrides between redirects, so we have to do it ourselves. headersOverride = redirectedFrom?._originalRequestRoute?._alreadyContinuedParams?.headers; + if (headersOverride) { + const originalHeaders = Object.entries(requestPausedEvent.request.headers).map(([name, value]) => ({ name, value })); + headersOverride = network.applyHeadersOverrides(originalHeaders, headersOverride); + } requestPausedSessionInfo!.session._sendMayFail('Fetch.continueRequest', { requestId: requestPausedEvent.requestId, headers: headersOverride }); } else { route = new RouteImpl(requestPausedSessionInfo!.session, requestPausedEvent.requestId); diff --git a/packages/playwright-core/src/server/deviceDescriptorsSource.json b/packages/playwright-core/src/server/deviceDescriptorsSource.json index 00306245d184e..a8dcaf7e2acd6 100644 --- a/packages/playwright-core/src/server/deviceDescriptorsSource.json +++ b/packages/playwright-core/src/server/deviceDescriptorsSource.json @@ -1702,7 +1702,7 @@ "defaultBrowserType": "chromium" }, "Desktop Firefox HiDPI": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:145.0.2) Gecko/20100101 Firefox/145.0.2", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:146.0.1) Gecko/20100101 Firefox/146.0.1", "screen": { "width": 1792, "height": 1120 @@ -1762,7 +1762,7 @@ "defaultBrowserType": "chromium" }, "Desktop Firefox": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:145.0.2) Gecko/20100101 Firefox/145.0.2", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:146.0.1) Gecko/20100101 Firefox/146.0.1", "screen": { "width": 1920, "height": 1080 diff --git a/packages/playwright-core/src/server/network.ts b/packages/playwright-core/src/server/network.ts index 644e3fea7cd9e..5a45644d3f3cd 100644 --- a/packages/playwright-core/src/server/network.ts +++ b/packages/playwright-core/src/server/network.ts @@ -55,6 +55,62 @@ export function isLocalHostname(hostname: string): boolean { return hostname === 'localhost' || hostname.endsWith('.localhost'); } +// Forbidden request headers according to https://developer.mozilla.org/en-US/docs/Glossary/Forbidden_request_header +// These headers cannot be set or modified programmatically. +const FORBIDDEN_HEADER_NAMES = new Set([ + 'accept-charset', + 'accept-encoding', + 'access-control-request-headers', + 'access-control-request-method', + 'connection', + 'content-length', + 'cookie', + 'date', + 'dnt', + 'expect', + 'host', + 'keep-alive', + 'origin', + 'referer', + 'set-cookie', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', + 'via', +]); + +// Forbidden method names for X-HTTP-Method-* headers +const FORBIDDEN_METHODS = new Set(['CONNECT', 'TRACE', 'TRACK']); + +function isForbiddenHeader(name: string, value?: string): boolean { + const lowerName = name.toLowerCase(); + + if (FORBIDDEN_HEADER_NAMES.has(lowerName)) + return true; + + if (lowerName.startsWith('proxy-')) + return true; + + if (lowerName.startsWith('sec-')) + return true; + + if (lowerName === 'x-http-method' || + lowerName === 'x-http-method-override' || + lowerName === 'x-method-override') { + if (value && FORBIDDEN_METHODS.has(value.toUpperCase())) + return true; + } + + return false; +} + +export function applyHeadersOverrides(original: HeadersArray, overrides: HeadersArray): HeadersArray { + const forbiddenHeaders = original.filter(header => isForbiddenHeader(header.name, header.value)); + const allowedHeaders = overrides.filter(header => !isForbiddenHeader(header.name, header.value)); + return mergeHeaders([allowedHeaders, forbiddenHeaders]); +} + // Rollover to 5-digit year: // 253402300799 == Fri, 31 Dec 9999 23:59:59 +0000 (UTC) // 253402300800 == Sat, 1 Jan 1000 00:00:00 +0000 (UTC) @@ -369,10 +425,9 @@ export class Route extends SdkObject { throw new Error('New URL must have same protocol as overridden URL'); } if (overrides.headers) { - overrides.headers = overrides.headers?.filter(header => { - const headerName = header.name.toLowerCase(); - return headerName !== 'cookie' && headerName !== 'host'; - }); + // Filter out forbidden headers from overrides - they cannot be overridden + // and will be passed as-is from the original request + overrides.headers = applyHeadersOverrides(this._request._headers, overrides.headers); } overrides = this._request._applyOverrides(overrides); diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 644abf75bbe7e..c9a974c18796c 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -21121,8 +21121,12 @@ export interface Route { * [route.fallback([options])](https://playwright.dev/docs/api/class-route#route-fallback) If you want next matching * handler in the chain to be invoked. * - * **NOTE** The `Cookie` header cannot be overridden using this method. If a value is provided, it will be ignored, - * and the cookie will be loaded from the browser's cookie store. To set custom cookies, use + * **NOTE** Some request headers are **forbidden** and cannot be overridden (for example, `Cookie`, `Host`, + * `Content-Length` and others, see + * [this MDN page](https://developer.mozilla.org/en-US/docs/Glossary/Forbidden_request_header) for full list). If an + * override is provided for a forbidden header, it will be ignored and the original request header will be used. + * + * To set custom cookies, use * [browserContext.addCookies(cookies)](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-cookies). * * @param options diff --git a/tests/page/page-request-continue.spec.ts b/tests/page/page-request-continue.spec.ts index 53f98cd42e8bd..496732a4c3732 100644 --- a/tests/page/page-request-continue.spec.ts +++ b/tests/page/page-request-continue.spec.ts @@ -48,24 +48,16 @@ it('should not allow to override unsafe HTTP headers', async ({ page, server, br const serverRequestPromise = server.waitForRequest('/empty.html'); page.goto(server.EMPTY_PAGE).catch(() => {}); const route = await routePromise; - const error = await route.continue({ + await route.continue({ headers: { ...route.request().headers(), host: 'bar', trailer: 'baz', } - }).catch(e => e); - if (browserName === 'chromium' || isElectron) { - expect(error.message).toContain('Unsafe header'); - serverRequestPromise.catch(() => {}); - } else { - expect(error).toBeFalsy(); - // These lines just document current behavior in FF and WK, - // we don't necessarily want to maintain this behavior. - const serverRequest = await serverRequestPromise; - expect(serverRequest.headers['trailer']).toBe('baz'); - expect(serverRequest.headers['host']).toBe(new URL(server.EMPTY_PAGE).host); - } + }); + const serverRequest = await serverRequestPromise; + expect(serverRequest.headers['trailer']).toBe(undefined); + expect(serverRequest.headers['host']).toBe(new URL(server.EMPTY_PAGE).host); }); it('should delete header with undefined value', async ({ page, server, browserName }) => { @@ -349,20 +341,19 @@ it('should work with Cross-Origin-Opener-Policy', async ({ page, server, browser expect(response.request().failure()).toBeNull(); }); -it('should delete the origin header', async ({ page, server, isAndroid, browserName }) => { +it('should not delete the origin header', async ({ page, server, isAndroid }) => { it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/13106' }); it.skip(isAndroid, 'No cross-process on Android'); - it.fail(browserName === 'webkit', 'Does not delete origin in webkit'); await page.goto(server.PREFIX + '/empty.html'); server.setRoute('/something', (request, response) => { response.writeHead(200, { 'Access-Control-Allow-Origin': '*' }); response.end('done'); }); - let interceptedRequest; + let interceptedOrigin; await page.route(server.CROSS_PROCESS_PREFIX + '/something', async (route, request) => { - interceptedRequest = request; const headers = await request.allHeaders(); + interceptedOrigin = headers.origin; delete headers['origin']; void route.continue({ headers }); }); @@ -375,11 +366,11 @@ it('should delete the origin header', async ({ page, server, isAndroid, browserN server.waitForRequest('/something') ]); expect(text).toBe('done'); - expect(interceptedRequest.headers()['origin']).toEqual(undefined); - expect(serverRequest.headers.origin).toBeFalsy(); + expect(interceptedOrigin).toEqual(server.PREFIX); + expect(serverRequest.headers.origin).toBe(server.PREFIX); }); -it('should continue preload link requests', async ({ page, server, browserName }) => { +it('should continue preload link requests', async ({ page, server }) => { it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/16745' }); let intercepted = false; await page.route('**/one-style.css', route => { @@ -404,7 +395,7 @@ it('should continue preload link requests', async ({ page, server, browserName } it('should respect set-cookie in redirect response', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/35154' } -}, async ({ page, server, browserName }) => { +}, async ({ page, server }) => { await page.goto(server.EMPTY_PAGE); await page.setContent('Set cookie'); server.setRoute('/set-cookie-redirect', (request, response) => {