From 1625d4080339190682bc76bbe79ea26132accfda Mon Sep 17 00:00:00 2001 From: Olavi Sau Date: Wed, 6 Nov 2024 00:40:03 +0200 Subject: [PATCH] feat: [#1553] Adds setting disableSameOriginPolicy, to make it possible to bypass the same-origin policy (CORS) (#1554) * feat: [1553] Add disableCrossOriginPolicy setting to disable cors in the browser * chore: [#1553] Adds unit tests and changes name on setting for disabling same-origin policy * chore: [#1553] Adds unit tests and changes name on setting for disabling same-origin policy --------- Co-authored-by: David Ortner --- .../src/browser/BrowserSettingsFactory.ts | 4 ++ .../src/browser/DefaultBrowserSettings.ts | 3 + .../src/browser/types/IBrowserSettings.ts | 16 ++++- .../browser/types/IOptionalBrowserSettings.ts | 12 ++++ packages/happy-dom/src/fetch/Fetch.ts | 25 ++++--- packages/happy-dom/src/fetch/ResourceFetch.ts | 4 +- packages/happy-dom/src/fetch/SyncFetch.ts | 25 ++++--- packages/happy-dom/test/fetch/Fetch.test.ts | 64 ++++++++++++++++++ .../happy-dom/test/fetch/SyncFetch.test.ts | 65 ++++++++++++++++++- 9 files changed, 194 insertions(+), 24 deletions(-) diff --git a/packages/happy-dom/src/browser/BrowserSettingsFactory.ts b/packages/happy-dom/src/browser/BrowserSettingsFactory.ts index 0c5b3a1ee..438de64e3 100644 --- a/packages/happy-dom/src/browser/BrowserSettingsFactory.ts +++ b/packages/happy-dom/src/browser/BrowserSettingsFactory.ts @@ -29,6 +29,10 @@ export default class BrowserSettingsFactory { ...DefaultBrowserSettings.timer, ...settings?.timer }, + fetch: { + ...DefaultBrowserSettings.fetch, + ...settings?.fetch + }, device: { ...DefaultBrowserSettings.device, ...settings?.device diff --git a/packages/happy-dom/src/browser/DefaultBrowserSettings.ts b/packages/happy-dom/src/browser/DefaultBrowserSettings.ts index 31e53e97a..79a0de663 100644 --- a/packages/happy-dom/src/browser/DefaultBrowserSettings.ts +++ b/packages/happy-dom/src/browser/DefaultBrowserSettings.ts @@ -18,6 +18,9 @@ export default { maxIntervalIterations: -1, preventTimerLoops: false }, + fetch: { + disableSameOriginPolicy: false + }, navigation: { disableMainFrameNavigation: false, disableChildFrameNavigation: false, diff --git a/packages/happy-dom/src/browser/types/IBrowserSettings.ts b/packages/happy-dom/src/browser/types/IBrowserSettings.ts index ed8bb17ee..f7a6f0280 100644 --- a/packages/happy-dom/src/browser/types/IBrowserSettings.ts +++ b/packages/happy-dom/src/browser/types/IBrowserSettings.ts @@ -20,7 +20,9 @@ export default interface IBrowserSettings { /** Handle disabled resource loading as success */ handleDisabledFileLoadingAsSuccess: boolean; - /** Settings for timers */ + /** + * Settings for timers + */ timer: { maxTimeout: number; maxIntervalTime: number; @@ -28,6 +30,18 @@ export default interface IBrowserSettings { preventTimerLoops: boolean; }; + /** + * Settings for fetch + */ + fetch: { + /** + * Disables same-origin policy (CORS) + * + * @see https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy + */ + disableSameOriginPolicy: boolean; + }; + /** * Disables error capturing. * diff --git a/packages/happy-dom/src/browser/types/IOptionalBrowserSettings.ts b/packages/happy-dom/src/browser/types/IOptionalBrowserSettings.ts index 33d314eea..bd58fdb8b 100644 --- a/packages/happy-dom/src/browser/types/IOptionalBrowserSettings.ts +++ b/packages/happy-dom/src/browser/types/IOptionalBrowserSettings.ts @@ -24,6 +24,18 @@ export default interface IOptionalBrowserSettings { maxIntervalIterations?: number; }; + /** + * Settings for fetch + */ + fetch?: { + /** + * Disables same-origin policy (CORS) + * + * @see https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy + */ + disableSameOriginPolicy?: boolean; + }; + /** * Disables error capturing. * diff --git a/packages/happy-dom/src/fetch/Fetch.ts b/packages/happy-dom/src/fetch/Fetch.ts index 153e1824d..3aa304e13 100644 --- a/packages/happy-dom/src/fetch/Fetch.ts +++ b/packages/happy-dom/src/fetch/Fetch.ts @@ -53,7 +53,7 @@ export default class Fetch { private request: Request; private redirectCount = 0; private disableCache: boolean; - private disableCrossOriginPolicy: boolean; + private disableSameOriginPolicy: boolean; #browserFrame: IBrowserFrame; #window: BrowserWindow; #unfilteredHeaders: Headers | null = null; @@ -69,7 +69,7 @@ export default class Fetch { * @param [options.redirectCount] Redirect count. * @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.disableSameOriginPolicy] Disables the Same-Origin policy. * @param [options.unfilteredHeaders] Unfiltered headers - necessary for preflight requests. */ constructor(options: { @@ -80,7 +80,7 @@ export default class Fetch { redirectCount?: number; contentType?: string; disableCache?: boolean; - disableCrossOriginPolicy?: boolean; + disableSameOriginPolicy?: boolean; unfilteredHeaders?: Headers; }) { this.#browserFrame = options.browserFrame; @@ -95,7 +95,10 @@ export default class Fetch { } this.redirectCount = options.redirectCount ?? 0; this.disableCache = options.disableCache ?? false; - this.disableCrossOriginPolicy = options.disableCrossOriginPolicy ?? false; + this.disableSameOriginPolicy = + options.disableSameOriginPolicy ?? + this.#browserFrame.page.context.browser.settings.fetch.disableSameOriginPolicy ?? + false; } /** @@ -128,7 +131,11 @@ export default class Fetch { this.#window.location.protocol === 'https:' ) { throw new this.#window.DOMException( - `Mixed Content: The page at '${this.#window.location.href}' was loaded over HTTPS, but requested an insecure XMLHttpRequest endpoint '${this.request.url}'. This request has been blocked; the content must be served over HTTPS.`, + `Mixed Content: The page at '${ + this.#window.location.href + }' was loaded over HTTPS, but requested an insecure XMLHttpRequest endpoint '${ + this.request.url + }'. This request has been blocked; the content must be served over HTTPS.`, DOMExceptionNameEnum.securityError ); } @@ -141,7 +148,7 @@ export default class Fetch { } } - if (!this.disableCrossOriginPolicy) { + if (!this.disableSameOriginPolicy) { const compliesWithCrossOriginPolicy = await this.compliesWithCrossOriginPolicy(); if (!compliesWithCrossOriginPolicy) { @@ -192,7 +199,7 @@ export default class Fetch { url: this.request.url, init: { headers, method: cachedResponse.request.method }, disableCache: true, - disableCrossOriginPolicy: true + disableSameOriginPolicy: true }); if (cachedResponse.etag || !cachedResponse.staleWhileRevalidate) { @@ -251,7 +258,7 @@ export default class Fetch { */ private async compliesWithCrossOriginPolicy(): Promise { if ( - this.disableCrossOriginPolicy || + this.disableSameOriginPolicy || !FetchCORSUtility.isCORS(this.#window.location.href, this.request[PropertySymbol.url]) ) { return true; @@ -303,7 +310,7 @@ export default class Fetch { url: this.request.url, init: { method: 'OPTIONS' }, disableCache: true, - disableCrossOriginPolicy: true, + disableSameOriginPolicy: true, unfilteredHeaders: corsHeaders }); diff --git a/packages/happy-dom/src/fetch/ResourceFetch.ts b/packages/happy-dom/src/fetch/ResourceFetch.ts index 70a33fa16..2e63cef74 100644 --- a/packages/happy-dom/src/fetch/ResourceFetch.ts +++ b/packages/happy-dom/src/fetch/ResourceFetch.ts @@ -34,7 +34,7 @@ export default class ResourceFetch { browserFrame: this.#browserFrame, window: this.window, url, - disableCrossOriginPolicy: true + disableSameOriginPolicy: true }); const response = await fetch.send(); @@ -60,7 +60,7 @@ export default class ResourceFetch { browserFrame: this.#browserFrame, window: this.window, url, - disableCrossOriginPolicy: true + disableSameOriginPolicy: true }); const response = fetch.send(); diff --git a/packages/happy-dom/src/fetch/SyncFetch.ts b/packages/happy-dom/src/fetch/SyncFetch.ts index 1bcd24c4d..e04c0c156 100644 --- a/packages/happy-dom/src/fetch/SyncFetch.ts +++ b/packages/happy-dom/src/fetch/SyncFetch.ts @@ -38,7 +38,7 @@ export default class SyncFetch { private request: Request; private redirectCount = 0; private disableCache: boolean; - private disableCrossOriginPolicy: boolean; + private disableSameOriginPolicy: boolean; #browserFrame: IBrowserFrame; #window: BrowserWindow; #unfilteredHeaders: Headers | null = null; @@ -54,7 +54,7 @@ export default class SyncFetch { * @param [options.redirectCount] Redirect count. * @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.disableSameOriginPolicy] Disables the Same-Origin policy. * @param [options.unfilteredHeaders] Unfiltered headers - necessary for preflight requests. */ constructor(options: { @@ -65,7 +65,7 @@ export default class SyncFetch { redirectCount?: number; contentType?: string; disableCache?: boolean; - disableCrossOriginPolicy?: boolean; + disableSameOriginPolicy?: boolean; unfilteredHeaders?: Headers; }) { this.#browserFrame = options.browserFrame; @@ -80,7 +80,10 @@ export default class SyncFetch { } this.redirectCount = options.redirectCount ?? 0; this.disableCache = options.disableCache ?? false; - this.disableCrossOriginPolicy = options.disableCrossOriginPolicy ?? false; + this.disableSameOriginPolicy = + options.disableSameOriginPolicy ?? + this.#browserFrame.page.context.browser.settings.fetch.disableSameOriginPolicy ?? + false; } /** @@ -118,7 +121,11 @@ export default class SyncFetch { this.#window.location.protocol === 'https:' ) { throw new this.#window.DOMException( - `Mixed Content: The page at '${this.#window.location.href}' was loaded over HTTPS, but requested an insecure XMLHttpRequest endpoint '${this.request.url}'. This request has been blocked; the content must be served over HTTPS.`, + `Mixed Content: The page at '${ + this.#window.location.href + }' was loaded over HTTPS, but requested an insecure XMLHttpRequest endpoint '${ + this.request.url + }'. This request has been blocked; the content must be served over HTTPS.`, DOMExceptionNameEnum.securityError ); } @@ -177,7 +184,7 @@ export default class SyncFetch { url: this.request.url, init: { headers, method: cachedResponse.request.method }, disableCache: true, - disableCrossOriginPolicy: true + disableSameOriginPolicy: true }); const validateResponse = fetch.send(); @@ -199,7 +206,7 @@ export default class SyncFetch { url: this.request.url, init: { headers, method: cachedResponse.request.method }, disableCache: true, - disableCrossOriginPolicy: true + disableSameOriginPolicy: true }); fetch.send().then((response) => { response.buffer().then((body: Buffer) => { @@ -236,7 +243,7 @@ export default class SyncFetch { */ private compliesWithCrossOriginPolicy(): boolean { if ( - this.disableCrossOriginPolicy || + this.disableSameOriginPolicy || !FetchCORSUtility.isCORS(this.#window.location.href, this.request[PropertySymbol.url]) ) { return true; @@ -288,7 +295,7 @@ export default class SyncFetch { url: this.request.url, init: { method: 'OPTIONS' }, disableCache: true, - disableCrossOriginPolicy: true, + disableSameOriginPolicy: true, unfilteredHeaders: corsHeaders }); diff --git a/packages/happy-dom/test/fetch/Fetch.test.ts b/packages/happy-dom/test/fetch/Fetch.test.ts index de829524c..bcabd9743 100644 --- a/packages/happy-dom/test/fetch/Fetch.test.ts +++ b/packages/happy-dom/test/fetch/Fetch.test.ts @@ -571,6 +571,70 @@ describe('Fetch', () => { }); }); + it('Allows cross-origin request if "Browser.settings.fetch.disableSameOriginPolicy" is set to "true".', async () => { + const originURL = 'http://localhost:8080'; + const window = new Window({ url: originURL }); + const url = 'http://other.origin.com/some/path'; + + window.happyDOM.settings.fetch.disableSameOriginPolicy = true; + + 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 = []; + + 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).toBeNull(); + + 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 () => { diff --git a/packages/happy-dom/test/fetch/SyncFetch.test.ts b/packages/happy-dom/test/fetch/SyncFetch.test.ts index f8e8e9a16..5a46dff41 100644 --- a/packages/happy-dom/test/fetch/SyncFetch.test.ts +++ b/packages/happy-dom/test/fetch/SyncFetch.test.ts @@ -2,13 +2,10 @@ import BrowserWindow from '../../src/window/BrowserWindow.js'; import Headers from '../../src/fetch/Headers.js'; import DOMException from '../../src/exception/DOMException.js'; import DOMExceptionNameEnum from '../../src/exception/DOMExceptionNameEnum.js'; -import AbortController from '../../src/fetch/AbortController.js'; -import Stream from 'stream'; import { ReadableStream } from 'stream/web'; import Zlib from 'zlib'; import { TextEncoder } from 'util'; import Blob from '../../src/file/Blob.js'; -import FormData from '../../src/form-data/FormData.js'; import { URLSearchParams } from 'url'; import { beforeEach, afterEach, describe, it, expect, vi } from 'vitest'; import SyncFetchScriptBuilder from '../../src/fetch/utilities/SyncFetchScriptBuilder.js'; @@ -459,6 +456,68 @@ describe('SyncFetch', () => { ); }); + it('Allows cross-origin request if "Browser.settings.fetch.disableSameOriginPolicy" is set to "true".', async () => { + const originURL = 'http://localhost:8080'; + + browserFrame.url = originURL; + browserFrame.page.context.browser.settings.fetch.disableSameOriginPolicy = true; + + 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).toBe(1); + + expect(requestArgs[0]).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}.`, () => {