Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: [#1489] Include Access-Control, Origin headers for cross-origin requests #1492

Merged
merged 3 commits into from
Aug 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading