Skip to content

Commit

Permalink
fix: [#1489] Include Access-Control, Origin headers for cross-origin …
Browse files Browse the repository at this point in the history
…requests (#1492)

Fixes #1489

Co-authored-by: David Ortner <david@ortner.se>
  • Loading branch information
rexxars and capricorn86 authored Aug 30, 2024
1 parent 71d243a commit 86748db
Show file tree
Hide file tree
Showing 6 changed files with 303 additions and 22 deletions.
33 changes: 22 additions & 11 deletions packages/happy-dom/src/fetch/Fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export default class Fetch {
private disableCrossOriginPolicy: boolean;
#browserFrame: IBrowserFrame;
#window: BrowserWindow;
#unfilteredHeaders: Headers | null = null;

/**
* Constructor.
Expand All @@ -69,6 +70,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;
Expand All @@ -79,9 +81,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.window.Request(options.url, options.init)
Expand Down Expand Up @@ -278,22 +282,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 = <Response>await fetch.send();
Expand Down Expand Up @@ -375,13 +386,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,
Expand Down
32 changes: 22 additions & 10 deletions packages/happy-dom/src/fetch/SyncFetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export default class SyncFetch {
private disableCrossOriginPolicy: boolean;
#browserFrame: IBrowserFrame;
#window: BrowserWindow;
#unfilteredHeaders: Headers | null = null;

/**
* Constructor.
Expand All @@ -54,6 +55,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;
Expand All @@ -64,9 +66,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.window.Request(options.url, options.init)
Expand Down Expand Up @@ -263,22 +267,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();
Expand Down Expand Up @@ -336,7 +347,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]
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]);

Expand Down Expand Up @@ -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 = {};

Expand Down
74 changes: 74 additions & 0 deletions packages/happy-dom/test/fetch/Fetch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,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<string> {}

const response = <HTTP.IncomingMessage>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 () => {
Expand Down Expand Up @@ -1103,6 +1175,7 @@ describe('Fetch', () => {
Connection: 'close',
'User-Agent': window.navigator.userAgent,
'Accept-Encoding': 'gzip, deflate, br',
Origin: originURL,
Referer: originURL + '/'
},
agent: false,
Expand Down Expand Up @@ -1258,6 +1331,7 @@ describe('Fetch', () => {
Connection: 'close',
'User-Agent': window.navigator.userAgent,
'Accept-Encoding': 'gzip, deflate, br',
Origin: originURL,
Referer: originURL + '/',
Cookie: cookies,
authorization: 'authorization',
Expand Down
81 changes: 81 additions & 0 deletions packages/happy-dom/test/fetch/SyncFetch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}.`, () => {
Expand Down Expand Up @@ -954,6 +1033,7 @@ describe('SyncFetch', () => {
Connection: 'close',
'User-Agent': window.navigator.userAgent,
'Accept-Encoding': 'gzip, deflate, br',
Origin: originURL,
Referer: originURL + '/'
},
body: null
Expand Down Expand Up @@ -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',
Expand Down
Loading

0 comments on commit 86748db

Please sign in to comment.