fix(fetch): preserve case in header names (#21543)

Fixes #21492.
This commit is contained in:
Dmitry Gozman 2023-03-10 08:58:12 -08:00 committed by GitHub
parent dab61df451
commit 0794cb1486
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
2 changed files with 66 additions and 29 deletions

View file

@ -50,10 +50,12 @@ type FetchRequestOptions = {
baseURL?: string;
};
type HeadersObject = Readonly<{ [name: string]: string }>;
export type APIRequestEvent = {
url: URL,
method: string,
headers: { [name: string]: string },
headers: HeadersObject,
cookies: channels.NameValue[],
postData?: Buffer
};
@ -72,6 +74,7 @@ export type APIRequestFinishedEvent = {
type SendRequestOptions = https.RequestOptions & {
maxRedirects: number,
deadline: number,
headers: HeadersObject,
__testHookLookup?: (hostname: string) => LookupAddress[]
};
@ -130,20 +133,21 @@ export abstract class APIRequestContext extends SdkObject {
}
async fetch(params: channels.APIRequestContextFetchParams, metadata: CallMetadata): Promise<channels.APIResponse> {
const headers: { [name: string]: string } = {};
const defaults = this._defaultOptions();
headers['user-agent'] = defaults.userAgent;
headers['accept'] = '*/*';
headers['accept-encoding'] = 'gzip,deflate,br';
const headers: HeadersObject = {
'user-agent': defaults.userAgent,
'accept': '*/*',
'accept-encoding': 'gzip,deflate,br',
};
if (defaults.extraHTTPHeaders) {
for (const { name, value } of defaults.extraHTTPHeaders)
headers[name.toLowerCase()] = value;
setHeader(headers, name, value);
}
if (params.headers) {
for (const { name, value } of params.headers)
headers[name.toLowerCase()] = value;
setHeader(headers, name, value);
}
const method = params.method?.toUpperCase() || 'GET';
@ -188,7 +192,7 @@ export abstract class APIRequestContext extends SdkObject {
const postData = serializePostData(params, headers);
if (postData)
headers['content-length'] = String(postData.byteLength);
setHeader(headers, 'content-length', String(postData.byteLength));
const controller = new ProgressController(metadata, this);
const fetchResponse = await controller.run(progress => {
return this._sendRequest(progress, requestUrl, options, postData);
@ -227,27 +231,27 @@ export abstract class APIRequestContext extends SdkObject {
return cookies;
}
private async _updateRequestCookieHeader(url: URL, options: http.RequestOptions) {
if (options.headers!['cookie'] !== undefined)
private async _updateRequestCookieHeader(url: URL, headers: HeadersObject) {
if (getHeader(headers, 'cookie') !== undefined)
return;
const cookies = await this._cookies(url);
if (cookies.length) {
const valueArray = cookies.map(c => `${c.name}=${c.value}`);
options.headers!['cookie'] = valueArray.join('; ');
setHeader(headers, 'cookie', valueArray.join('; '));
}
}
private async _sendRequest(progress: Progress, url: URL, options: SendRequestOptions, postData?: Buffer): Promise<Omit<channels.APIResponse, 'fetchUid'> & { body: Buffer }>{
await this._updateRequestCookieHeader(url, options);
await this._updateRequestCookieHeader(url, options.headers);
const requestCookies = (options.headers!['cookie'] as (string | undefined))?.split(';').map(p => {
const requestCookies = getHeader(options.headers, 'cookie')?.split(';').map(p => {
const [name, value] = p.split('=').map(v => v.trim());
return { name, value };
}) || [];
const requestEvent: APIRequestEvent = {
url,
method: options.method!,
headers: options.headers as { [name: string]: string },
headers: options.headers,
cookies: requestCookies,
postData
};
@ -287,8 +291,8 @@ export abstract class APIRequestContext extends SdkObject {
request.destroy();
return;
}
const headers = { ...options.headers };
delete headers[`cookie`];
const headers: HeadersObject = { ...options.headers };
removeHeader(headers, `cookie`);
// HTTP-redirect fetch step 13 (https://fetch.spec.whatwg.org/#http-redirect-fetch)
const status = response.statusCode!;
@ -297,11 +301,11 @@ export abstract class APIRequestContext extends SdkObject {
status === 303 && !['GET', 'HEAD'].includes(method)) {
method = 'GET';
postData = undefined;
delete headers[`content-encoding`];
delete headers[`content-language`];
delete headers[`content-length`];
delete headers[`content-location`];
delete headers[`content-type`];
removeHeader(headers, `content-encoding`);
removeHeader(headers, `content-language`);
removeHeader(headers, `content-length`);
removeHeader(headers, `content-location`);
removeHeader(headers, `content-type`);
}
const redirectOptions: SendRequestOptions = {
@ -333,13 +337,13 @@ export abstract class APIRequestContext extends SdkObject {
return;
}
}
if (response.statusCode === 401 && !options.headers!['authorization']) {
if (response.statusCode === 401 && !getHeader(options.headers, 'authorization')) {
const auth = response.headers['www-authenticate'];
const credentials = this._defaultOptions().httpCredentials;
if (auth?.trim().startsWith('Basic') && credentials) {
const { username, password } = credentials;
const encoded = Buffer.from(`${username || ''}:${password || ''}`).toString('base64');
options.headers!['authorization'] = `Basic ${encoded}`;
setHeader(options.headers, 'authorization', `Basic ${encoded}`);
notifyRequestFinished();
fulfill(this._sendRequest(progress, url, options, postData));
request.destroy();
@ -651,17 +655,17 @@ function isJsonParsable(value: any) {
}
}
function serializePostData(params: channels.APIRequestContextFetchParams, headers: { [name: string]: string }): Buffer | undefined {
function serializePostData(params: channels.APIRequestContextFetchParams, headers: HeadersObject): Buffer | undefined {
assert((params.postData ? 1 : 0) + (params.jsonData ? 1 : 0) + (params.formData ? 1 : 0) + (params.multipartData ? 1 : 0) <= 1, `Only one of 'data', 'form' or 'multipart' can be specified`);
if (params.jsonData !== undefined) {
const json = isJsonParsable(params.jsonData) ? params.jsonData : JSON.stringify(params.jsonData);
headers['content-type'] ??= 'application/json';
setHeader(headers, 'content-type', 'application/json', true);
return Buffer.from(json, 'utf8');
} else if (params.formData) {
const searchParams = new URLSearchParams();
for (const { name, value } of params.formData)
searchParams.append(name, value);
headers['content-type'] ??= 'application/x-www-form-urlencoded';
setHeader(headers, 'content-type', 'application/x-www-form-urlencoded', true);
return Buffer.from(searchParams.toString(), 'utf8');
} else if (params.multipartData) {
const formData = new MultipartFormData();
@ -671,11 +675,28 @@ function serializePostData(params: channels.APIRequestContextFetchParams, header
else if (field.value)
formData.addField(field.name, field.value);
}
headers['content-type'] ??= formData.contentTypeHeader();
setHeader(headers, 'content-type', formData.contentTypeHeader(), true);
return formData.finish();
} else if (params.postData !== undefined) {
headers['content-type'] ??= 'application/octet-stream';
setHeader(headers, 'content-type', 'application/octet-stream', true);
return params.postData;
}
return undefined;
}
function setHeader(headers: { [name: string]: string }, name: string, value: string, keepExisting = false) {
const existing = Object.entries(headers).find(pair => pair[0].toLowerCase() === name.toLowerCase());
if (!existing)
headers[name] = value;
else if (!keepExisting)
headers[existing[0]] = value;
}
function getHeader(headers: HeadersObject, name: string) {
const existing = Object.entries(headers).find(pair => pair[0].toLowerCase() === name.toLowerCase());
return existing ? existing[1] : undefined;
}
function removeHeader(headers: { [name: string]: string }, name: string) {
delete headers[name];
}

View file

@ -400,4 +400,20 @@ it('should throw an error when maxRedirects is less than 0', async ({ playwright
for (const method of ['GET', 'PUT', 'POST', 'OPTIONS', 'HEAD', 'PATCH'])
await expect(async () => request.fetch(`${server.PREFIX}/a/redirect1`, { method, maxRedirects: -1 })).rejects.toThrow(`'maxRedirects' should be greater than or equal to '0'`);
await request.dispose();
});
});
it('should keep headers capitalization', async ({ playwright, server }) => {
const request = await playwright.request.newContext();
const [serverRequest, response] = await Promise.all([
server.waitForRequest('/empty.html'),
request.get(server.EMPTY_PAGE, {
headers: {
'X-fOo': 'vaLUE',
}
}),
]);
expect(response.ok()).toBeTruthy();
expect(serverRequest.rawHeaders).toContain('X-fOo');
expect(serverRequest.rawHeaders).toContain('vaLUE');
await request.dispose();
});