From f6cc01fd45b8810f9ffd5041f56db0319e7fd80c Mon Sep 17 00:00:00 2001 From: Espen Hovlandsdal Date: Sat, 20 Jul 2024 11:43:35 -0700 Subject: [PATCH] fix: [#1489] Include `Access-Control`, `Origin` headers for cross-origin requests Fixes #1489 --- packages/happy-dom/src/fetch/Fetch.ts | 33 ++++--- packages/happy-dom/src/fetch/SyncFetch.ts | 32 +++++-- .../utilities/FetchRequestHeaderUtility.ts | 12 ++- packages/happy-dom/test/fetch/Fetch.test.ts | 74 +++++++++++++++ .../happy-dom/test/fetch/SyncFetch.test.ts | 81 ++++++++++++++++ .../xml-http-request/XMLHttpRequest.test.ts | 93 +++++++++++++++++++ 6 files changed, 303 insertions(+), 22 deletions(-) diff --git a/packages/happy-dom/src/fetch/Fetch.ts b/packages/happy-dom/src/fetch/Fetch.ts index 16856d0a2..85ce972af 100644 --- a/packages/happy-dom/src/fetch/Fetch.ts +++ b/packages/happy-dom/src/fetch/Fetch.ts @@ -57,6 +57,7 @@ export default class Fetch { private disableCrossOriginPolicy: boolean; #browserFrame: IBrowserFrame; #window: BrowserWindow; + #unfilteredHeaders: Headers | null = null; /** * Constructor. @@ -70,6 +71,7 @@ export default class Fetch { * @param [options.contentType] Content Type. * @param [options.disableCache] Disables the use of cached responses. It will still store the response in the cache. * @param [options.disableCrossOriginPolicy] Disables the Cross-Origin policy. + * @param [options.unfilteredHeaders] Unfiltered headers - necessary for preflight requests. */ constructor(options: { browserFrame: IBrowserFrame; @@ -80,9 +82,11 @@ export default class Fetch { contentType?: string; disableCache?: boolean; disableCrossOriginPolicy?: boolean; + unfilteredHeaders?: Headers; }) { this.#browserFrame = options.browserFrame; this.#window = options.window; + this.#unfilteredHeaders = options.unfilteredHeaders ?? null; this.request = typeof options.url === 'string' || options.url instanceof URL ? new options.browserFrame.window.Request(options.url, options.init) @@ -276,22 +280,29 @@ export default class Fetch { const requestHeaders = []; for (const [header] of this.request.headers) { - requestHeaders.push(header); + requestHeaders.push(header.toLowerCase()); + } + + const corsHeaders = new Headers({ + 'Access-Control-Request-Method': this.request.method, + Origin: this.#window.location.origin + }); + + if (requestHeaders.length > 0) { + // This intentionally does not use "combine" (comma + space), as the spec dictates. + // See https://fetch.spec.whatwg.org/#cors-preflight-fetch for more details. + // Sorting the headers is not required, but can optimize cache hits. + corsHeaders.set('Access-Control-Request-Headers', requestHeaders.slice().sort().join(',')); } const fetch = new Fetch({ browserFrame: this.#browserFrame, window: this.#window, url: this.request.url, - init: { - method: 'OPTIONS', - headers: new Headers({ - 'Access-Control-Request-Method': this.request.method, - 'Access-Control-Request-Headers': requestHeaders.join(', ') - }) - }, + init: { method: 'OPTIONS' }, disableCache: true, - disableCrossOriginPolicy: true + disableCrossOriginPolicy: true, + unfilteredHeaders: corsHeaders }); const response = await fetch.send(); @@ -373,13 +384,13 @@ export default class Fetch { this.request.signal.addEventListener('abort', this.listeners.onSignalAbort); const send = (this.request[PropertySymbol.url].protocol === 'https:' ? HTTPS : HTTP).request; - this.nodeRequest = send(this.request[PropertySymbol.url].href, { method: this.request.method, headers: FetchRequestHeaderUtility.getRequestHeaders({ browserFrame: this.#browserFrame, window: this.#window, - request: this.request + request: this.request, + baseHeaders: this.#unfilteredHeaders }), agent: false, rejectUnauthorized: true, diff --git a/packages/happy-dom/src/fetch/SyncFetch.ts b/packages/happy-dom/src/fetch/SyncFetch.ts index 1991d14e8..cb42b1e52 100644 --- a/packages/happy-dom/src/fetch/SyncFetch.ts +++ b/packages/happy-dom/src/fetch/SyncFetch.ts @@ -42,6 +42,7 @@ export default class SyncFetch { private disableCrossOriginPolicy: boolean; #browserFrame: IBrowserFrame; #window: BrowserWindow; + #unfilteredHeaders: Headers | null = null; /** * Constructor. @@ -55,6 +56,7 @@ export default class SyncFetch { * @param [options.contentType] Content Type. * @param [options.disableCache] Disables the use of cached responses. It will still store the response in the cache. * @param [options.disableCrossOriginPolicy] Disables the Cross-Origin policy. + * @param [options.unfilteredHeaders] Unfiltered headers - necessary for preflight requests. */ constructor(options: { browserFrame: IBrowserFrame; @@ -65,9 +67,11 @@ export default class SyncFetch { contentType?: string; disableCache?: boolean; disableCrossOriginPolicy?: boolean; + unfilteredHeaders?: Headers; }) { this.#browserFrame = options.browserFrame; this.#window = options.window; + this.#unfilteredHeaders = options.unfilteredHeaders ?? null; this.request = typeof options.url === 'string' || options.url instanceof URL ? new options.browserFrame.window.Request(options.url, options.init) @@ -261,22 +265,29 @@ export default class SyncFetch { const requestHeaders = []; for (const [header] of this.request.headers) { - requestHeaders.push(header); + requestHeaders.push(header.toLowerCase()); + } + + const corsHeaders = new Headers({ + 'Access-Control-Request-Method': this.request.method, + Origin: this.#window.location.origin + }); + + if (requestHeaders.length > 0) { + // This intentionally does not use "combine" (comma + space), as the spec dictates. + // See https://fetch.spec.whatwg.org/#cors-preflight-fetch for more details. + // Sorting the headers is not required, but can optimize cache hits. + corsHeaders.set('Access-Control-Request-Headers', requestHeaders.slice().sort().join(',')); } const fetch = new SyncFetch({ browserFrame: this.#browserFrame, window: this.#window, url: this.request.url, - init: { - method: 'OPTIONS', - headers: new Headers({ - 'Access-Control-Request-Method': this.request.method, - 'Access-Control-Request-Headers': requestHeaders.join(', ') - }) - }, + init: { method: 'OPTIONS' }, disableCache: true, - disableCrossOriginPolicy: true + disableCrossOriginPolicy: true, + unfilteredHeaders: corsHeaders }); const response = fetch.send(); @@ -334,7 +345,8 @@ export default class SyncFetch { headers: FetchRequestHeaderUtility.getRequestHeaders({ browserFrame: this.#browserFrame, window: this.#window, - request: this.request + request: this.request, + baseHeaders: this.#unfilteredHeaders }), body: this.request[PropertySymbol.bodyBuffer] }); diff --git a/packages/happy-dom/src/fetch/utilities/FetchRequestHeaderUtility.ts b/packages/happy-dom/src/fetch/utilities/FetchRequestHeaderUtility.ts index fa6922d9c..165054b15 100644 --- a/packages/happy-dom/src/fetch/utilities/FetchRequestHeaderUtility.ts +++ b/packages/happy-dom/src/fetch/utilities/FetchRequestHeaderUtility.ts @@ -69,14 +69,20 @@ export default class FetchRequestHeaderUtility { * @param options.browserFrame Browser frame. * @param options.window Window. * @param options.request Request. + * @param [options.baseHeaders] Any base headers (may be overwritten by browser/window headers). * @returns Headers. */ public static getRequestHeaders(options: { browserFrame: IBrowserFrame; window: BrowserWindow; request: Request; + baseHeaders?: Headers; }): { [key: string]: string } { - const headers = new Headers(options.request.headers); + const headers = new Headers(options.baseHeaders); + options.request.headers.forEach((value, key) => { + headers.set(key, value); + }); + const originURL = new URL(options.window.location.href); const isCORS = FetchCORSUtility.isCORS(originURL, options.request[PropertySymbol.url]); @@ -125,6 +131,10 @@ export default class FetchRequestHeaderUtility { headers.set('Content-Type', options.request[PropertySymbol.contentType]); } + if (isCORS) { + headers.set('Origin', originURL.origin); + } + // We need to convert the headers to Node request headers. const httpRequestHeaders = {}; diff --git a/packages/happy-dom/test/fetch/Fetch.test.ts b/packages/happy-dom/test/fetch/Fetch.test.ts index 78fcd187b..f5c1b60c0 100644 --- a/packages/happy-dom/test/fetch/Fetch.test.ts +++ b/packages/happy-dom/test/fetch/Fetch.test.ts @@ -498,6 +498,78 @@ describe('Fetch', () => { }); }); + it('Includes Origin + Access-Control headers on cross-origin requests.', async () => { + const originURL = 'http://localhost:8080'; + const window = new Window({ url: originURL }); + const url = 'http://other.origin.com/some/path'; + + let requestedUrl: string | null = null; + let postRequestHeaders: { [k: string]: string } | null = null; + let optionsRequestHeaders: { [k: string]: string } | null = null; + + mockModule('http', { + request: (url, options) => { + requestedUrl = url; + if (options.method === 'OPTIONS') { + optionsRequestHeaders = options.headers; + } else if (options.method === 'POST') { + postRequestHeaders = options.headers; + } + + return { + end: () => {}, + on: (event: string, callback: (response: HTTP.IncomingMessage) => void) => { + if (event === 'response') { + async function* generate(): AsyncGenerator {} + + const response = Stream.Readable.from(generate()); + + response.headers = {}; + response.rawHeaders = + options.method === 'OPTIONS' ? ['Access-Control-Allow-Origin', '*'] : []; + + callback(response); + } + }, + setTimeout: () => {} + }; + } + }); + + await window.fetch(url, { + method: 'POST', + body: '{"foo": "bar"}', + headers: { + 'X-Custom-Header': 'yes', + 'Content-Type': 'application/json' + } + }); + + expect(requestedUrl).toBe(url); + expect(optionsRequestHeaders).toEqual({ + Accept: '*/*', + 'Access-Control-Request-Method': 'POST', + 'Access-Control-Request-Headers': 'content-type,x-custom-header', + Connection: 'close', + 'User-Agent': window.navigator.userAgent, + 'Accept-Encoding': 'gzip, deflate, br', + Origin: originURL, + Referer: originURL + '/' + }); + + expect(postRequestHeaders).toEqual({ + Accept: '*/*', + Connection: 'close', + 'Content-Type': 'application/json', + 'Content-Length': '14', + 'User-Agent': window.navigator.userAgent, + 'Accept-Encoding': 'gzip, deflate, br', + Origin: originURL, + Referer: originURL + '/', + 'X-Custom-Header': 'yes' + }); + }); + for (const httpCode of [301, 302, 303, 307, 308]) { for (const method of ['GET', 'POST', 'PATCH']) { it(`Should follow ${method} request redirect code ${httpCode}.`, async () => { @@ -1102,6 +1174,7 @@ describe('Fetch', () => { Connection: 'close', 'User-Agent': window.navigator.userAgent, 'Accept-Encoding': 'gzip, deflate, br', + Origin: originURL, Referer: originURL + '/' }, agent: false, @@ -1257,6 +1330,7 @@ describe('Fetch', () => { Connection: 'close', 'User-Agent': window.navigator.userAgent, 'Accept-Encoding': 'gzip, deflate, br', + Origin: originURL, Referer: originURL + '/', Cookie: cookies, authorization: 'authorization', diff --git a/packages/happy-dom/test/fetch/SyncFetch.test.ts b/packages/happy-dom/test/fetch/SyncFetch.test.ts index def747b47..b1c682f04 100644 --- a/packages/happy-dom/test/fetch/SyncFetch.test.ts +++ b/packages/happy-dom/test/fetch/SyncFetch.test.ts @@ -380,6 +380,85 @@ describe('SyncFetch', () => { expect(response.body.toString()).toBe(responseText); }); + it('Includes Origin + Access-Control headers on cross-origin requests.', async () => { + const originURL = 'http://localhost:8080'; + browserFrame.url = originURL; + const url = 'http://other.origin.com/some/path'; + const body = '{"foo": "bar"}'; + + const requestArgs: string[] = []; + + mockModule('child_process', { + execFileSync: (_command: string, args: string[]) => { + requestArgs.push(args[1]); + return JSON.stringify({ + error: null, + incomingMessage: { + statusCode: 200, + statusMessage: 'OK', + rawHeaders: ['Access-Control-Allow-Origin', '*'], + data: '' + } + }); + } + }); + + new SyncFetch({ + browserFrame, + window, + url, + init: { + method: 'POST', + body, + headers: { + 'X-Custom-Header': 'yes', + 'Content-Type': 'application/json' + } + } + }).send(); + + expect(requestArgs.length, 'preflight + post request').toBe(2); + + // Access-Control headers should only be on preflight request, so expect to find them once + const [optionsRequestArgs, postRequestArgs] = requestArgs; + + expect(optionsRequestArgs).toBe( + SyncFetchScriptBuilder.getScript({ + url: new URL(url), + method: 'OPTIONS', + headers: { + Accept: '*/*', + 'Access-Control-Request-Method': 'POST', + 'Access-Control-Request-Headers': 'content-type,x-custom-header', + Connection: 'close', + 'User-Agent': window.navigator.userAgent, + 'Accept-Encoding': 'gzip, deflate, br', + Origin: originURL, + Referer: originURL + '/' + } + }) + ); + + expect(postRequestArgs).toBe( + SyncFetchScriptBuilder.getScript({ + url: new URL(url), + method: 'POST', + headers: { + Accept: '*/*', + Connection: 'close', + 'Content-Length': `${body.length}`, + 'Content-Type': 'application/json', + 'User-Agent': window.navigator.userAgent, + 'Accept-Encoding': 'gzip, deflate, br', + Origin: originURL, + Referer: originURL + '/', + 'X-Custom-Header': 'yes' + }, + body: Buffer.from(body) + }) + ); + }); + for (const httpCode of [301, 302, 303, 307, 308]) { for (const method of ['GET', 'POST', 'PATCH']) { it(`Should follow ${method} request redirect code ${httpCode}.`, () => { @@ -954,6 +1033,7 @@ describe('SyncFetch', () => { Connection: 'close', 'User-Agent': window.navigator.userAgent, 'Accept-Encoding': 'gzip, deflate, br', + Origin: originURL, Referer: originURL + '/' }, body: null @@ -1099,6 +1179,7 @@ describe('SyncFetch', () => { Connection: 'close', 'User-Agent': window.navigator.userAgent, 'Accept-Encoding': 'gzip, deflate, br', + Origin: originURL, Referer: originURL + '/', Cookie: cookies, authorization: 'authorization', diff --git a/packages/happy-dom/test/xml-http-request/XMLHttpRequest.test.ts b/packages/happy-dom/test/xml-http-request/XMLHttpRequest.test.ts index ab1b6110d..4c64db067 100644 --- a/packages/happy-dom/test/xml-http-request/XMLHttpRequest.test.ts +++ b/packages/happy-dom/test/xml-http-request/XMLHttpRequest.test.ts @@ -5,6 +5,8 @@ import XMLHttpResponseTypeEnum from '../../src/xml-http-request/XMLHttpResponseT import ProgressEvent from '../../src/event/events/ProgressEvent.js'; import Blob from '../../src/file/Blob.js'; import Document from '../../src/nodes/document/Document.js'; +import type { IncomingMessage } from 'http'; +import Stream from 'stream'; import { beforeEach, afterEach, describe, it, expect, vi } from 'vitest'; import SyncFetch from '../../src/fetch/SyncFetch.js'; import Response from '../../src/fetch/Response.js'; @@ -17,7 +19,9 @@ import { ReadableStream } from 'stream/web'; import * as PropertySymbol from '../../src/PropertySymbol.js'; const WINDOW_URL = 'https://localhost:8080'; +const WINDOW_ORIGIN = new URL(WINDOW_URL).origin; const REQUEST_URL = '/path/to/resource/'; +const CORS_REQUEST_URL = 'https://other.origin' + REQUEST_URL; const FORBIDDEN_REQUEST_METHODS = ['TRACE', 'TRACK', 'CONNECT']; const FORBIDDEN_REQUEST_HEADERS = [ 'accept-charset', @@ -1029,6 +1033,95 @@ describe('XMLHttpRequest', () => { }); }); + it('Performs an asynchronous cross-origin POST request with the HTTPS protocol.', async () => { + await new Promise((resolve) => { + const body = '{"foo": "bar"}'; + const responseText = 'http.request.body'; + + let requestedUrl: string | null = null; + let postRequestHeaders: { [k: string]: string } | null = null; + let optionsRequestHeaders: { [k: string]: string } | null = null; + + mockModule('https', { + request: (url, options) => { + requestedUrl = url; + if (options.method === 'OPTIONS') { + optionsRequestHeaders = options.headers; + } else if (options.method === 'POST') { + postRequestHeaders = options.headers; + } + + return { + end: () => {}, + on: (event: string, callback: (response: IncomingMessage) => void) => { + if (event === 'response') { + const response = Stream.Readable.from(responseText); + const baseHeaders = ['Access-Control-Allow-Origin', WINDOW_ORIGIN]; + const headers = [ + ...baseHeaders, + ...(options.method === 'POST' + ? ['Content-Length', `${responseText.length}`, 'Content-Type', 'text/html'] + : []) + ]; + + response.headers = {}; + response.rawHeaders = headers; + + callback(response); + } + }, + setTimeout: () => {} + }; + } + }); + + request.open('POST', CORS_REQUEST_URL, true); + request.setRequestHeader('Content-Type', 'application/json'); + request.setRequestHeader('X-Custom-Header', 'yes'); + + let isProgressTriggered = false; + + request.addEventListener('progress', (event) => { + isProgressTriggered = true; + expect((event).lengthComputable).toBe(true); + expect((event).loaded).toBe(responseText.length); + expect((event).total).toBe(responseText.length); + expect(request.readyState).toBe(XMLHttpRequestReadyStateEnum.headersRecieved); + }); + + request.addEventListener('load', () => { + expect(requestedUrl).toBe(CORS_REQUEST_URL); + expect(optionsRequestHeaders).toEqual({ + Accept: '*/*', + 'Access-Control-Request-Method': 'POST', + 'Access-Control-Request-Headers': 'content-type,x-custom-header', + Connection: 'close', + 'User-Agent': window.navigator.userAgent, + 'Accept-Encoding': 'gzip, deflate, br', + Origin: WINDOW_ORIGIN, + Referer: WINDOW_URL + '/' + }); + expect(postRequestHeaders).toEqual({ + Accept: '*/*', + Connection: 'close', + 'Content-Type': 'application/json', + 'User-Agent': window.navigator.userAgent, + 'Accept-Encoding': 'gzip, deflate, br', + Origin: WINDOW_ORIGIN, + Referer: WINDOW_URL + '/', + 'X-Custom-Header': 'yes' + }); + expect(request.responseText).toBe(responseText); + expect(request.readyState).toBe(XMLHttpRequestReadyStateEnum.done); + expect(isProgressTriggered).toBe(true); + + resolve(null); + }); + + request.send(body); + }); + }); + it('Handles error in request when performing an asynchronous request.', async () => { await new Promise((resolve) => { vi.spyOn(Fetch.prototype, 'send').mockImplementation(async function () {