feat: allow setting the Origin header and Sec-Fetch-* headers in net.request() (#26135)
This commit is contained in:
parent
b8372fdc29
commit
e1cc78f275
5 changed files with 251 additions and 13 deletions
|
@ -47,6 +47,7 @@ following properties:
|
||||||
be aborted. When mode is `manual` the redirection will be cancelled unless
|
be aborted. When mode is `manual` the redirection will be cancelled unless
|
||||||
[`request.followRedirect`](#requestfollowredirect) is invoked synchronously
|
[`request.followRedirect`](#requestfollowredirect) is invoked synchronously
|
||||||
during the [`redirect`](#event-redirect) event. Defaults to `follow`.
|
during the [`redirect`](#event-redirect) event. Defaults to `follow`.
|
||||||
|
* `origin` String (optional) - The origin URL of the request.
|
||||||
|
|
||||||
`options` properties such as `protocol`, `host`, `hostname`, `port` and `path`
|
`options` properties such as `protocol`, `host`, `hostname`, `port` and `path`
|
||||||
strictly follow the Node.js model as described in the
|
strictly follow the Node.js model as described in the
|
||||||
|
|
|
@ -197,7 +197,7 @@ class ChunkedBodyStream extends Writable {
|
||||||
|
|
||||||
type RedirectPolicy = 'manual' | 'follow' | 'error';
|
type RedirectPolicy = 'manual' | 'follow' | 'error';
|
||||||
|
|
||||||
function parseOptions (optionsIn: ClientRequestConstructorOptions | string): NodeJS.CreateURLLoaderOptions & { redirectPolicy: RedirectPolicy, extraHeaders: Record<string, string> } {
|
function parseOptions (optionsIn: ClientRequestConstructorOptions | string): NodeJS.CreateURLLoaderOptions & { redirectPolicy: RedirectPolicy, headers: Record<string, { name: string, value: string | string[] }> } {
|
||||||
const options: any = typeof optionsIn === 'string' ? url.parse(optionsIn) : { ...optionsIn };
|
const options: any = typeof optionsIn === 'string' ? url.parse(optionsIn) : { ...optionsIn };
|
||||||
|
|
||||||
let urlStr: string = options.url;
|
let urlStr: string = options.url;
|
||||||
|
@ -249,22 +249,26 @@ function parseOptions (optionsIn: ClientRequestConstructorOptions | string): Nod
|
||||||
throw new TypeError('headers must be an object');
|
throw new TypeError('headers must be an object');
|
||||||
}
|
}
|
||||||
|
|
||||||
const urlLoaderOptions: NodeJS.CreateURLLoaderOptions & { redirectPolicy: RedirectPolicy, extraHeaders: Record<string, string | string[]> } = {
|
const urlLoaderOptions: NodeJS.CreateURLLoaderOptions & { redirectPolicy: RedirectPolicy, headers: Record<string, { name: string, value: string | string[] }> } = {
|
||||||
method: (options.method || 'GET').toUpperCase(),
|
method: (options.method || 'GET').toUpperCase(),
|
||||||
url: urlStr,
|
url: urlStr,
|
||||||
redirectPolicy,
|
redirectPolicy,
|
||||||
extraHeaders: options.headers || {},
|
headers: {},
|
||||||
body: null as any,
|
body: null as any,
|
||||||
useSessionCookies: options.useSessionCookies,
|
useSessionCookies: options.useSessionCookies,
|
||||||
credentials: options.credentials
|
credentials: options.credentials,
|
||||||
|
origin: options.origin
|
||||||
};
|
};
|
||||||
for (const [name, value] of Object.entries(urlLoaderOptions.extraHeaders!)) {
|
const headers: Record<string, string | string[]> = options.headers || {};
|
||||||
|
for (const [name, value] of Object.entries(headers)) {
|
||||||
if (!isValidHeaderName(name)) {
|
if (!isValidHeaderName(name)) {
|
||||||
throw new Error(`Invalid header name: '${name}'`);
|
throw new Error(`Invalid header name: '${name}'`);
|
||||||
}
|
}
|
||||||
if (!isValidHeaderValue(value.toString())) {
|
if (!isValidHeaderValue(value.toString())) {
|
||||||
throw new Error(`Invalid value for header '${name}': '${value}'`);
|
throw new Error(`Invalid value for header '${name}': '${value}'`);
|
||||||
}
|
}
|
||||||
|
const key = name.toLowerCase();
|
||||||
|
urlLoaderOptions.headers[key] = { name, value };
|
||||||
}
|
}
|
||||||
if (options.session) {
|
if (options.session) {
|
||||||
// Weak check, but it should be enough to catch 99% of accidental misuses.
|
// Weak check, but it should be enough to catch 99% of accidental misuses.
|
||||||
|
@ -289,7 +293,7 @@ export class ClientRequest extends Writable implements Electron.ClientRequest {
|
||||||
_aborted: boolean = false;
|
_aborted: boolean = false;
|
||||||
_chunkedEncoding: boolean | undefined;
|
_chunkedEncoding: boolean | undefined;
|
||||||
_body: Writable | undefined;
|
_body: Writable | undefined;
|
||||||
_urlLoaderOptions: NodeJS.CreateURLLoaderOptions & { extraHeaders: Record<string, string> };
|
_urlLoaderOptions: NodeJS.CreateURLLoaderOptions & { headers: Record<string, { name: string, value: string | string[] }> };
|
||||||
_redirectPolicy: RedirectPolicy;
|
_redirectPolicy: RedirectPolicy;
|
||||||
_followRedirectCb?: () => void;
|
_followRedirectCb?: () => void;
|
||||||
_uploadProgress?: { active: boolean, started: boolean, current: number, total: number };
|
_uploadProgress?: { active: boolean, started: boolean, current: number, total: number };
|
||||||
|
@ -350,7 +354,7 @@ export class ClientRequest extends Writable implements Electron.ClientRequest {
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = name.toLowerCase();
|
const key = name.toLowerCase();
|
||||||
this._urlLoaderOptions.extraHeaders[key] = value;
|
this._urlLoaderOptions.headers[key] = { name, value };
|
||||||
}
|
}
|
||||||
|
|
||||||
getHeader (name: string) {
|
getHeader (name: string) {
|
||||||
|
@ -359,7 +363,8 @@ export class ClientRequest extends Writable implements Electron.ClientRequest {
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = name.toLowerCase();
|
const key = name.toLowerCase();
|
||||||
return this._urlLoaderOptions.extraHeaders[key];
|
const header = this._urlLoaderOptions.headers[key];
|
||||||
|
return header && header.value as any;
|
||||||
}
|
}
|
||||||
|
|
||||||
removeHeader (name: string) {
|
removeHeader (name: string) {
|
||||||
|
@ -372,7 +377,7 @@ export class ClientRequest extends Writable implements Electron.ClientRequest {
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = name.toLowerCase();
|
const key = name.toLowerCase();
|
||||||
delete this._urlLoaderOptions.extraHeaders[key];
|
delete this._urlLoaderOptions.headers[key];
|
||||||
}
|
}
|
||||||
|
|
||||||
_write (chunk: Buffer, encoding: BufferEncoding, callback: () => void) {
|
_write (chunk: Buffer, encoding: BufferEncoding, callback: () => void) {
|
||||||
|
@ -401,15 +406,20 @@ export class ClientRequest extends Writable implements Electron.ClientRequest {
|
||||||
|
|
||||||
_startRequest () {
|
_startRequest () {
|
||||||
this._started = true;
|
this._started = true;
|
||||||
const stringifyValues = (obj: Record<string, any>) => {
|
const stringifyValues = (obj: Record<string, { name: string, value: string | string[] }>) => {
|
||||||
const ret: Record<string, string> = {};
|
const ret: Record<string, string> = {};
|
||||||
for (const k of Object.keys(obj)) {
|
for (const k of Object.keys(obj)) {
|
||||||
ret[k] = obj[k].toString();
|
const kv = obj[k];
|
||||||
|
ret[kv.name] = kv.value.toString();
|
||||||
}
|
}
|
||||||
return ret;
|
return ret;
|
||||||
};
|
};
|
||||||
this._urlLoaderOptions.referrer = this._urlLoaderOptions.extraHeaders.referer || '';
|
this._urlLoaderOptions.referrer = this.getHeader('referer') || '';
|
||||||
const opts = { ...this._urlLoaderOptions, extraHeaders: stringifyValues(this._urlLoaderOptions.extraHeaders) };
|
this._urlLoaderOptions.origin = this._urlLoaderOptions.origin || this.getHeader('origin') || '';
|
||||||
|
this._urlLoaderOptions.hasUserActivation = this.getHeader('sec-fetch-user') === '?1';
|
||||||
|
this._urlLoaderOptions.mode = this.getHeader('sec-fetch-mode') || '';
|
||||||
|
this._urlLoaderOptions.destination = this.getHeader('sec-fetch-dest') || '';
|
||||||
|
const opts = { ...this._urlLoaderOptions, extraHeaders: stringifyValues(this._urlLoaderOptions.headers) };
|
||||||
this._urlLoader = createURLLoader(opts);
|
this._urlLoader = createURLLoader(opts);
|
||||||
this._urlLoader.on('response-started', (event, finalUrl, responseHead) => {
|
this._urlLoader.on('response-started', (event, finalUrl, responseHead) => {
|
||||||
const response = this._response = new IncomingMessage(responseHead);
|
const response = this._response = new IncomingMessage(responseHead);
|
||||||
|
|
|
@ -376,6 +376,75 @@ gin::Handle<SimpleURLLoaderWrapper> SimpleURLLoaderWrapper::Create(
|
||||||
opts.Get("method", &request->method);
|
opts.Get("method", &request->method);
|
||||||
opts.Get("url", &request->url);
|
opts.Get("url", &request->url);
|
||||||
opts.Get("referrer", &request->referrer);
|
opts.Get("referrer", &request->referrer);
|
||||||
|
std::string origin;
|
||||||
|
opts.Get("origin", &origin);
|
||||||
|
if (!origin.empty()) {
|
||||||
|
request->request_initiator = url::Origin::Create(GURL(origin));
|
||||||
|
}
|
||||||
|
bool has_user_activation;
|
||||||
|
if (opts.Get("hasUserActivation", &has_user_activation)) {
|
||||||
|
request->trusted_params = network::ResourceRequest::TrustedParams();
|
||||||
|
request->trusted_params->has_user_activation = has_user_activation;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string mode;
|
||||||
|
if (opts.Get("mode", &mode) && !mode.empty()) {
|
||||||
|
if (mode == "navigate") {
|
||||||
|
request->mode = network::mojom::RequestMode::kNavigate;
|
||||||
|
} else if (mode == "cors") {
|
||||||
|
request->mode = network::mojom::RequestMode::kCors;
|
||||||
|
} else if (mode == "no-cors") {
|
||||||
|
request->mode = network::mojom::RequestMode::kNoCors;
|
||||||
|
} else if (mode == "same-origin") {
|
||||||
|
request->mode = network::mojom::RequestMode::kSameOrigin;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string destination;
|
||||||
|
if (opts.Get("destination", &destination) && !destination.empty()) {
|
||||||
|
if (destination == "empty") {
|
||||||
|
request->destination = network::mojom::RequestDestination::kEmpty;
|
||||||
|
} else if (destination == "audio") {
|
||||||
|
request->destination = network::mojom::RequestDestination::kAudio;
|
||||||
|
} else if (destination == "audioworklet") {
|
||||||
|
request->destination = network::mojom::RequestDestination::kAudioWorklet;
|
||||||
|
} else if (destination == "document") {
|
||||||
|
request->destination = network::mojom::RequestDestination::kDocument;
|
||||||
|
} else if (destination == "embed") {
|
||||||
|
request->destination = network::mojom::RequestDestination::kEmbed;
|
||||||
|
} else if (destination == "font") {
|
||||||
|
request->destination = network::mojom::RequestDestination::kFont;
|
||||||
|
} else if (destination == "frame") {
|
||||||
|
request->destination = network::mojom::RequestDestination::kFrame;
|
||||||
|
} else if (destination == "iframe") {
|
||||||
|
request->destination = network::mojom::RequestDestination::kIframe;
|
||||||
|
} else if (destination == "image") {
|
||||||
|
request->destination = network::mojom::RequestDestination::kImage;
|
||||||
|
} else if (destination == "manifest") {
|
||||||
|
request->destination = network::mojom::RequestDestination::kManifest;
|
||||||
|
} else if (destination == "object") {
|
||||||
|
request->destination = network::mojom::RequestDestination::kObject;
|
||||||
|
} else if (destination == "paintworklet") {
|
||||||
|
request->destination = network::mojom::RequestDestination::kPaintWorklet;
|
||||||
|
} else if (destination == "report") {
|
||||||
|
request->destination = network::mojom::RequestDestination::kReport;
|
||||||
|
} else if (destination == "script") {
|
||||||
|
request->destination = network::mojom::RequestDestination::kScript;
|
||||||
|
} else if (destination == "serviceworker") {
|
||||||
|
request->destination = network::mojom::RequestDestination::kServiceWorker;
|
||||||
|
} else if (destination == "style") {
|
||||||
|
request->destination = network::mojom::RequestDestination::kStyle;
|
||||||
|
} else if (destination == "track") {
|
||||||
|
request->destination = network::mojom::RequestDestination::kTrack;
|
||||||
|
} else if (destination == "video") {
|
||||||
|
request->destination = network::mojom::RequestDestination::kVideo;
|
||||||
|
} else if (destination == "worker") {
|
||||||
|
request->destination = network::mojom::RequestDestination::kWorker;
|
||||||
|
} else if (destination == "xslt") {
|
||||||
|
request->destination = network::mojom::RequestDestination::kXslt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
bool credentials_specified =
|
bool credentials_specified =
|
||||||
opts.Get("credentials", &request->credentials_mode);
|
opts.Get("credentials", &request->credentials_mode);
|
||||||
std::vector<std::pair<std::string, std::string>> extra_headers;
|
std::vector<std::pair<std::string, std::string>> extra_headers;
|
||||||
|
|
|
@ -475,6 +475,26 @@ describe('net module', () => {
|
||||||
await collectStreamBody(response);
|
await collectStreamBody(response);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should not change the case of header name', async () => {
|
||||||
|
const customHeaderName = 'X-Header-Name';
|
||||||
|
const customHeaderValue = 'value';
|
||||||
|
const serverUrl = await respondOnce.toSingleURL((request, response) => {
|
||||||
|
expect(request.headers[customHeaderName.toLowerCase()]).to.equal(customHeaderValue.toString());
|
||||||
|
expect(request.rawHeaders.includes(customHeaderName)).to.equal(true);
|
||||||
|
response.statusCode = 200;
|
||||||
|
response.statusMessage = 'OK';
|
||||||
|
response.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
const urlRequest = net.request(serverUrl);
|
||||||
|
urlRequest.setHeader(customHeaderName, customHeaderValue);
|
||||||
|
expect(urlRequest.getHeader(customHeaderName)).to.equal(customHeaderValue);
|
||||||
|
urlRequest.write('');
|
||||||
|
const response = await getResponse(urlRequest);
|
||||||
|
expect(response.statusCode).to.equal(200);
|
||||||
|
await collectStreamBody(response);
|
||||||
|
});
|
||||||
|
|
||||||
it('should not be able to set a custom HTTP request header after first write', async () => {
|
it('should not be able to set a custom HTTP request header after first write', async () => {
|
||||||
const customHeaderName = 'Some-Custom-Header-Name';
|
const customHeaderName = 'Some-Custom-Header-Name';
|
||||||
const customHeaderValue = 'Some-Customer-Header-Value';
|
const customHeaderValue = 'Some-Customer-Header-Value';
|
||||||
|
@ -777,6 +797,140 @@ describe('net module', () => {
|
||||||
it('should not store cookies');
|
it('should not store cookies');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should set sec-fetch-site to same-origin for request from same origin', async () => {
|
||||||
|
const serverUrl = await respondOnce.toSingleURL((request, response) => {
|
||||||
|
expect(request.headers['sec-fetch-site']).to.equal('same-origin');
|
||||||
|
response.statusCode = 200;
|
||||||
|
response.statusMessage = 'OK';
|
||||||
|
response.end();
|
||||||
|
});
|
||||||
|
const urlRequest = net.request({
|
||||||
|
url: serverUrl,
|
||||||
|
origin: serverUrl
|
||||||
|
});
|
||||||
|
await collectStreamBody(await getResponse(urlRequest));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set sec-fetch-site to same-origin for request with the same origin header', async () => {
|
||||||
|
const serverUrl = await respondOnce.toSingleURL((request, response) => {
|
||||||
|
expect(request.headers['sec-fetch-site']).to.equal('same-origin');
|
||||||
|
response.statusCode = 200;
|
||||||
|
response.statusMessage = 'OK';
|
||||||
|
response.end();
|
||||||
|
});
|
||||||
|
const urlRequest = net.request({
|
||||||
|
url: serverUrl
|
||||||
|
});
|
||||||
|
urlRequest.setHeader('Origin', serverUrl);
|
||||||
|
await collectStreamBody(await getResponse(urlRequest));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set sec-fetch-site to cross-site for request from other origin', async () => {
|
||||||
|
const serverUrl = await respondOnce.toSingleURL((request, response) => {
|
||||||
|
expect(request.headers['sec-fetch-site']).to.equal('cross-site');
|
||||||
|
response.statusCode = 200;
|
||||||
|
response.statusMessage = 'OK';
|
||||||
|
response.end();
|
||||||
|
});
|
||||||
|
const urlRequest = net.request({
|
||||||
|
url: serverUrl,
|
||||||
|
origin: 'https://not-exists.com'
|
||||||
|
});
|
||||||
|
await collectStreamBody(await getResponse(urlRequest));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not send sec-fetch-user header by default', async () => {
|
||||||
|
const serverUrl = await respondOnce.toSingleURL((request, response) => {
|
||||||
|
expect(request.headers).not.to.have.property('sec-fetch-user');
|
||||||
|
response.statusCode = 200;
|
||||||
|
response.statusMessage = 'OK';
|
||||||
|
response.end();
|
||||||
|
});
|
||||||
|
const urlRequest = net.request({
|
||||||
|
url: serverUrl
|
||||||
|
});
|
||||||
|
await collectStreamBody(await getResponse(urlRequest));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set sec-fetch-user to ?1 if requested', async () => {
|
||||||
|
const serverUrl = await respondOnce.toSingleURL((request, response) => {
|
||||||
|
expect(request.headers['sec-fetch-user']).to.equal('?1');
|
||||||
|
response.statusCode = 200;
|
||||||
|
response.statusMessage = 'OK';
|
||||||
|
response.end();
|
||||||
|
});
|
||||||
|
const urlRequest = net.request({
|
||||||
|
url: serverUrl
|
||||||
|
});
|
||||||
|
urlRequest.setHeader('sec-fetch-user', '?1');
|
||||||
|
await collectStreamBody(await getResponse(urlRequest));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set sec-fetch-mode to no-cors by default', async () => {
|
||||||
|
const serverUrl = await respondOnce.toSingleURL((request, response) => {
|
||||||
|
expect(request.headers['sec-fetch-mode']).to.equal('no-cors');
|
||||||
|
response.statusCode = 200;
|
||||||
|
response.statusMessage = 'OK';
|
||||||
|
response.end();
|
||||||
|
});
|
||||||
|
const urlRequest = net.request({
|
||||||
|
url: serverUrl
|
||||||
|
});
|
||||||
|
await collectStreamBody(await getResponse(urlRequest));
|
||||||
|
});
|
||||||
|
|
||||||
|
['navigate', 'cors', 'no-cors', 'same-origin'].forEach((mode) => {
|
||||||
|
it(`should set sec-fetch-mode to ${mode} if requested`, async () => {
|
||||||
|
const serverUrl = await respondOnce.toSingleURL((request, response) => {
|
||||||
|
expect(request.headers['sec-fetch-mode']).to.equal(mode);
|
||||||
|
response.statusCode = 200;
|
||||||
|
response.statusMessage = 'OK';
|
||||||
|
response.end();
|
||||||
|
});
|
||||||
|
const urlRequest = net.request({
|
||||||
|
url: serverUrl,
|
||||||
|
origin: serverUrl
|
||||||
|
});
|
||||||
|
urlRequest.setHeader('sec-fetch-mode', mode);
|
||||||
|
await collectStreamBody(await getResponse(urlRequest));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set sec-fetch-dest to empty by default', async () => {
|
||||||
|
const serverUrl = await respondOnce.toSingleURL((request, response) => {
|
||||||
|
expect(request.headers['sec-fetch-dest']).to.equal('empty');
|
||||||
|
response.statusCode = 200;
|
||||||
|
response.statusMessage = 'OK';
|
||||||
|
response.end();
|
||||||
|
});
|
||||||
|
const urlRequest = net.request({
|
||||||
|
url: serverUrl
|
||||||
|
});
|
||||||
|
await collectStreamBody(await getResponse(urlRequest));
|
||||||
|
});
|
||||||
|
|
||||||
|
[
|
||||||
|
'empty', 'audio', 'audioworklet', 'document', 'embed', 'font',
|
||||||
|
'frame', 'iframe', 'image', 'manifest', 'object', 'paintworklet',
|
||||||
|
'report', 'script', 'serviceworker', 'style', 'track', 'video',
|
||||||
|
'worker', 'xslt'
|
||||||
|
].forEach((dest) => {
|
||||||
|
it(`should set sec-fetch-dest to ${dest} if requested`, async () => {
|
||||||
|
const serverUrl = await respondOnce.toSingleURL((request, response) => {
|
||||||
|
expect(request.headers['sec-fetch-dest']).to.equal(dest);
|
||||||
|
response.statusCode = 200;
|
||||||
|
response.statusMessage = 'OK';
|
||||||
|
response.end();
|
||||||
|
});
|
||||||
|
const urlRequest = net.request({
|
||||||
|
url: serverUrl,
|
||||||
|
origin: serverUrl
|
||||||
|
});
|
||||||
|
urlRequest.setHeader('sec-fetch-dest', dest);
|
||||||
|
await collectStreamBody(await getResponse(urlRequest));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should be able to abort an HTTP request before first write', async () => {
|
it('should be able to abort an HTTP request before first write', async () => {
|
||||||
const serverUrl = await respondOnce.toSingleURL((request, response) => {
|
const serverUrl = await respondOnce.toSingleURL((request, response) => {
|
||||||
response.end();
|
response.end();
|
||||||
|
|
4
typings/internal-ambient.d.ts
vendored
4
typings/internal-ambient.d.ts
vendored
|
@ -119,6 +119,10 @@ declare namespace NodeJS {
|
||||||
session?: Electron.Session;
|
session?: Electron.Session;
|
||||||
partition?: string;
|
partition?: string;
|
||||||
referrer?: string;
|
referrer?: string;
|
||||||
|
origin?: string;
|
||||||
|
hasUserActivation?: boolean;
|
||||||
|
mode?: string;
|
||||||
|
destination?: string;
|
||||||
};
|
};
|
||||||
type ResponseHead = {
|
type ResponseHead = {
|
||||||
statusCode: number;
|
statusCode: number;
|
||||||
|
|
Loading…
Reference in a new issue