From 5734c18ef8d323cf1ba7335dc41866654b95ae41 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Fri, 25 Mar 2022 14:56:57 -0700 Subject: [PATCH] feat(route): add cors header in route.fulfill (#12943) --- docs/src/api/class-route.md | 9 ++ .../playwright-core/src/client/network.ts | 3 +- .../playwright-core/src/protocol/channels.ts | 2 + .../playwright-core/src/protocol/protocol.yml | 5 + .../playwright-core/src/protocol/validator.ts | 1 + .../playwright-core/src/server/network.ts | 18 +++- packages/playwright-core/types/types.d.ts | 9 ++ tests/page/page-network-request.spec.ts | 58 ++++++++++++ tests/page/page-route.spec.ts | 93 +++++++++++++++++++ 9 files changed, 195 insertions(+), 3 deletions(-) diff --git a/docs/src/api/class-route.md b/docs/src/api/class-route.md index 61dd9e3ad4..afae387bf2 100644 --- a/docs/src/api/class-route.md +++ b/docs/src/api/class-route.md @@ -226,6 +226,15 @@ is resolved relative to the current working directory. [APIResponse] to fulfill route's request with. Individual fields of the response (such as headers) can be overridden using fulfill options. +### option: Route.fulfill.cors +- `cors` <[CorsMode]<"allow"|"none">> + +Wheb set to "allow" or omitted, the fulfilled response will have +["Access-Control-Allow-Origin"](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin) +header set to request's origin. If the option is set to "none" then +[CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) headers won't be added to the response. +Note that all CORS headers configured via `headers` option will take precedence. + ## method: Route.request - returns: <[Request]> diff --git a/packages/playwright-core/src/client/network.ts b/packages/playwright-core/src/client/network.ts index 372eb3b6e4..e069954748 100644 --- a/packages/playwright-core/src/client/network.ts +++ b/packages/playwright-core/src/client/network.ts @@ -239,7 +239,7 @@ export class Route extends ChannelOwner implements api.Ro await this._raceWithPageClose(this._channel.abort({ errorCode })); } - async fulfill(options: { response?: api.APIResponse, status?: number, headers?: Headers, contentType?: string, body?: string | Buffer, path?: string } = {}) { + async fulfill(options: { response?: api.APIResponse, status?: number, headers?: Headers, contentType?: string, cors?: 'allow' | 'none', body?: string | Buffer, path?: string } = {}) { let fetchResponseUid; let { status: statusOption, headers: headersOption, body } = options; if (options.response) { @@ -282,6 +282,7 @@ export class Route extends ChannelOwner implements api.Ro await this._raceWithPageClose(this._channel.fulfill({ status: statusOption || 200, headers: headersObjectToArray(headers), + cors: options.cors, body, isBase64, fetchResponseUid diff --git a/packages/playwright-core/src/protocol/channels.ts b/packages/playwright-core/src/protocol/channels.ts index 35c8ba1bd4..87341a81c7 100644 --- a/packages/playwright-core/src/protocol/channels.ts +++ b/packages/playwright-core/src/protocol/channels.ts @@ -3133,6 +3133,7 @@ export type RouteContinueResult = void; export type RouteFulfillParams = { status?: number, headers?: NameValue[], + cors?: 'allow' | 'none', body?: string, isBase64?: boolean, fetchResponseUid?: string, @@ -3140,6 +3141,7 @@ export type RouteFulfillParams = { export type RouteFulfillOptions = { status?: number, headers?: NameValue[], + cors?: 'allow' | 'none', body?: string, isBase64?: boolean, fetchResponseUid?: string, diff --git a/packages/playwright-core/src/protocol/protocol.yml b/packages/playwright-core/src/protocol/protocol.yml index 137ef4aac2..98adfb568e 100644 --- a/packages/playwright-core/src/protocol/protocol.yml +++ b/packages/playwright-core/src/protocol/protocol.yml @@ -2449,6 +2449,11 @@ Route: headers: type: array? items: NameValue + cors: + type: enum? + literals: + - allow + - none body: string? isBase64: boolean? fetchResponseUid: string? diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 6c66e7ba2e..f6f0fd1560 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -1167,6 +1167,7 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { scheme.RouteFulfillParams = tObject({ status: tOptional(tNumber), headers: tOptional(tArray(tType('NameValue'))), + cors: tOptional(tEnum(['allow', 'none'])), body: tOptional(tString), isBase64: tOptional(tBoolean), fetchResponseUid: tOptional(tString), diff --git a/packages/playwright-core/src/server/network.ts b/packages/playwright-core/src/server/network.ts index bc65e24b53..57759f7c89 100644 --- a/packages/playwright-core/src/server/network.ts +++ b/packages/playwright-core/src/server/network.ts @@ -16,6 +16,7 @@ import * as frames from './frames'; import * as types from './types'; +import * as channels from '../protocol/channels'; import { assert } from '../utils/utils'; import { ManualPromise } from '../utils/async'; import { SdkObject } from './instrumentation'; @@ -248,7 +249,7 @@ export class Route extends SdkObject { await this._delegate.abort(errorCode); } - async fulfill(overrides: { status?: number, headers?: types.HeadersArray, body?: string, isBase64?: boolean, useInterceptedResponseBody?: boolean, fetchResponseUid?: string }) { + async fulfill(overrides: channels.RouteFulfillParams) { this._startHandling(); let body = overrides.body; let isBase64 = overrides.isBase64 || false; @@ -264,9 +265,22 @@ export class Route extends SdkObject { isBase64 = false; } } + const headers = [...(overrides.headers || [])]; + if (overrides.cors !== 'none') { + const corsHeader = headers.find(({ name }) => name === 'access-control-allow-origin'); + // See https://github.com/microsoft/playwright/issues/12929 + if (!corsHeader) { + const origin = this._request.headerValue('origin'); + if (origin) { + headers.push({ name: 'access-control-allow-origin', value: origin }); + headers.push({ name: 'access-control-allow-credentials', value: 'true' }); + headers.push({ name: 'vary', value: 'Origin' }); + } + } + } await this._delegate.fulfill({ status: overrides.status || 200, - headers: overrides.headers || [], + headers, body, isBase64, }); diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index a37bb0aeeb..0fc5024195 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -14722,6 +14722,15 @@ export interface Route { */ contentType?: string; + /** + * Wheb set to "allow" or omitted, the fulfilled response will have + * ["Access-Control-Allow-Origin"](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin) + * header set to request's origin. If the option is set to "none" then + * [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) headers won't be added to the response. Note that all + * CORS headers configured via `headers` option will take precedence. + */ + cors?: "allow"|"none"; + /** * Response headers. Header values will be converted to a string. */ diff --git a/tests/page/page-network-request.spec.ts b/tests/page/page-network-request.spec.ts index 94d24241a1..0ef7a4b436 100644 --- a/tests/page/page-network-request.spec.ts +++ b/tests/page/page-network-request.spec.ts @@ -114,6 +114,64 @@ it('should get the same headers as the server CORS', async ({ page, server, brow expect(headers).toEqual(serverRequest.headers); }); +it('should not get preflight CORS requests when intercepting', async ({ page, server, browserName }) => { + await page.goto(server.PREFIX + '/empty.html'); + + const requests = []; + server.setRoute('/something', (request, response) => { + requests.push(request.method); + if (request.method === 'OPTIONS') { + response.writeHead(204, { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, GET, OPTIONS, DELETE', + 'Access-Control-Allow-Headers': '*', + 'Cache-Control': 'no-cache' + }); + response.end(); + return; + } + response.writeHead(200, { 'Access-Control-Allow-Origin': '*' }); + response.end('done'); + }); + // First check the browser will send preflight request when interception is OFF. + { + const text = await page.evaluate(async url => { + const data = await fetch(url, { + method: 'DELETE', + headers: { 'X-PINGOTHER': 'pingpong' } + }); + return data.text(); + }, server.CROSS_PROCESS_PREFIX + '/something'); + expect(text).toBe('done'); + expect(requests).toEqual(['OPTIONS', 'DELETE']); + } + + // Now check the browser will NOT send preflight request when interception is ON. + { + requests.length = 0; + const routed = []; + await page.route('**/something', route => { + routed.push(route.request().method()); + route.continue(); + }); + + const text = await page.evaluate(async url => { + const data = await fetch(url, { + method: 'DELETE', + headers: { 'X-PINGOTHER': 'pingpong' } + }); + return data.text(); + }, server.CROSS_PROCESS_PREFIX + '/something'); + expect(text).toBe('done'); + // Check that there was no preflight (OPTIONS) request. + expect(routed).toEqual(['DELETE']); + if (browserName === 'firefox') + expect(requests).toEqual(['OPTIONS', 'DELETE']); + else + expect(requests).toEqual(['DELETE']); + } +}); + it('should return postData', async ({ page, server, isAndroid }) => { it.fixme(isAndroid, 'Post data does not work'); diff --git a/tests/page/page-route.spec.ts b/tests/page/page-route.spec.ts index 9e04690b8f..5037ae0631 100644 --- a/tests/page/page-route.spec.ts +++ b/tests/page/page-route.spec.ts @@ -519,6 +519,7 @@ it('should support cors with GET', async ({ page, server, browserName }) => { const headers = request.url().endsWith('allow') ? { 'access-control-allow-origin': '*' } : {}; await route.fulfill({ contentType: 'application/json', + cors: 'none', headers, status: 200, body: JSON.stringify(['electric', 'gas']), @@ -547,6 +548,98 @@ it('should support cors with GET', async ({ page, server, browserName }) => { } }); +it('should add Access-Control-Allow-Origin by default when fulfill', async ({ page, server }) => { + await page.goto(server.EMPTY_PAGE); + await page.route('**/cars', async route => { + await route.fulfill({ + contentType: 'application/json', + status: 200, + body: JSON.stringify(['electric', 'gas']), + }); + }); + + const [result, response] = await Promise.all([ + page.evaluate(async () => { + const response = await fetch('https://example.com/cars', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + mode: 'cors', + body: JSON.stringify({ 'number': 1 }) + }); + return response.json(); + }), + page.waitForResponse('https://example.com/cars') + ]); + expect(result).toEqual(['electric', 'gas']); + expect(await response.headerValue('Access-Control-Allow-Origin')).toBe(server.PREFIX); +}); + +it('should respect cors false', async ({ page, server, browserName }) => { + server.setRoute('/something', (request, response) => { + if (request.method === 'OPTIONS') { + response.writeHead(204, { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, GET, OPTIONS, DELETE', + 'Access-Control-Allow-Headers': '*', + 'Cache-Control': 'no-cache' + }); + response.end(); + return; + } + response.writeHead(404, { 'Access-Control-Allow-Origin': '*' }); + response.end('NOT FOUND'); + }); + // First check the browser will send preflight request when interception is OFF. + { + await page.route('**/something', async route => { + await route.fulfill({ + contentType: 'text/plain', + status: 200, + body: 'done', + }); + }); + + const [response, text] = await Promise.all([ + page.waitForResponse(server.CROSS_PROCESS_PREFIX + '/something'), + page.evaluate(async url => { + const data = await fetch(url, { + method: 'GET', + headers: { 'X-PINGOTHER': 'pingpong' } + }); + return data.text(); + }, server.CROSS_PROCESS_PREFIX + '/something') + ]); + expect(text).toBe('done'); + expect(await response.headerValue('Access-Control-Allow-Origin')).toBe('null'); + } + + // Fetch request should when CORS headers are missing on the response. + { + await page.route('**/something', async route => { + await route.fulfill({ + contentType: 'text/plain', + status: 200, + cors: 'none', + body: 'done', + }); + }); + + const error = await page.evaluate(async url => { + const data = await fetch(url, { + method: 'GET', + headers: { 'X-PINGOTHER': 'pingpong' } + }); + return data.text(); + }, server.CROSS_PROCESS_PREFIX + '/something').catch(e => e); + if (browserName === 'chromium') + expect(error.message).toContain('Failed to fetch'); + else if (browserName === 'webkit') + expect(error.message).toContain('Load failed'); + else if (browserName === 'firefox') + expect(error.message).toContain('NetworkError when attempting to fetch resource.'); + } +}); + it('should support cors with POST', async ({ page, server }) => { await page.goto(server.EMPTY_PAGE); await page.route('**/cars', async route => {