feat: [net] add "credentials" option to net.request (#25284)

* feat: [net] add "credentials" option to net.request

* remove debugging log

* add tests
This commit is contained in:
Jeremy Rose 2020-09-29 09:03:33 -07:00 committed by GitHub
parent 8970c80520
commit 0e7d59dd79
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 388 additions and 266 deletions

View file

@ -20,23 +20,33 @@ form with the protocol scheme specified as http or https.
which the request is associated.
* `partition` String (optional) - The name of the [`partition`](session.md)
with which the request is associated. Defaults to the empty string. The
`session` option prevails on `partition`. Thus if a `session` is explicitly
`session` option supersedes `partition`. Thus if a `session` is explicitly
specified, `partition` is ignored.
* `credentials` String (optional) - Can be `include` or `omit`. Whether to
send [credentials](https://fetch.spec.whatwg.org/#credentials) with this
request. If set to `include`, credentials from the session associated with
the request will be used. If set to `omit`, credentials will not be sent
with the request (and the `'login'` event will not be triggered in the
event of a 401). This matches the behavior of the
[fetch](https://fetch.spec.whatwg.org/#concept-request-credentials-mode)
option of the same name. If this option is not specified, authentication
data from the session will be sent, and cookies will not be sent (unless
`useSessionCookies` is set).
* `useSessionCookies` Boolean (optional) - Whether to send cookies with this
request from the provided session. This will make the `net` request's
cookie behavior match a `fetch` request. Default is `false`.
* `protocol` String (optional) - The protocol scheme in the form 'scheme:'.
Currently supported values are 'http:' or 'https:'. Defaults to 'http:'.
request from the provided session. If `credentials` is specified, this
option has no effect. Default is `false`.
* `protocol` String (optional) - Can be `http:` or `https:`. The protocol
scheme in the form 'scheme:'. Defaults to 'http:'.
* `host` String (optional) - The server host provided as a concatenation of
the hostname and the port number 'hostname:port'.
* `hostname` String (optional) - The server host name.
* `port` Integer (optional) - The server's listening port number.
* `path` String (optional) - The path part of the request URL.
* `redirect` String (optional) - The redirect mode for this request. Should be
one of `follow`, `error` or `manual`. Defaults to `follow`. When mode is `error`,
any redirection will be aborted. When mode is `manual` the redirection will be
cancelled unless [`request.followRedirect`](#requestfollowredirect) is invoked
synchronously during the [`redirect`](#event-redirect) event.
* `redirect` String (optional) - Can be `follow`, `error` or `manual`. The
redirect mode for this request. When mode is `error`, any redirection will
be aborted. When mode is `manual` the redirection will be cancelled unless
[`request.followRedirect`](#requestfollowredirect) is invoked synchronously
during the [`redirect`](#event-redirect) event. Defaults to `follow`.
`options` properties such as `protocol`, `host`, `hostname`, `port` and `path`
strictly follow the Node.js model as described in the

View file

@ -2,6 +2,7 @@ import * as url from 'url';
import { Readable, Writable } from 'stream';
import { app } from 'electron/main';
import type { ClientRequestConstructorOptions, UploadProgress } from 'electron/main';
const {
isValidHeaderName,
isValidHeaderValue,
@ -243,7 +244,8 @@ function parseOptions (optionsIn: ClientRequestConstructorOptions | string): Nod
redirectPolicy,
extraHeaders: options.headers || {},
body: null as any,
useSessionCookies: options.useSessionCookies || false
useSessionCookies: options.useSessionCookies,
credentials: options.credentials
};
for (const [name, value] of Object.entries(urlLoaderOptions.extraHeaders!)) {
if (!isValidHeaderName(name)) {

View file

@ -47,6 +47,27 @@ struct Converter<network::mojom::HttpRawHeaderPairPtr> {
}
};
template <>
struct Converter<network::mojom::CredentialsMode> {
static bool FromV8(v8::Isolate* isolate,
v8::Local<v8::Value> val,
network::mojom::CredentialsMode* out) {
std::string mode;
if (!ConvertFromV8(isolate, val, &mode))
return false;
if (mode == "omit")
*out = network::mojom::CredentialsMode::kOmit;
else if (mode == "include")
*out = network::mojom::CredentialsMode::kInclude;
else
// "same-origin" is technically a member of this enum as well, but it
// doesn't make sense in the context of `net.request()`, so don't convert
// it.
return false;
return true;
}
}; // namespace gin
} // namespace gin
namespace electron {
@ -355,6 +376,8 @@ gin::Handle<SimpleURLLoaderWrapper> SimpleURLLoaderWrapper::Create(
opts.Get("method", &request->method);
opts.Get("url", &request->url);
opts.Get("referrer", &request->referrer);
bool credentials_specified =
opts.Get("credentials", &request->credentials_mode);
std::map<std::string, std::string> extra_headers;
if (opts.Get("extraHeaders", &extra_headers)) {
for (const auto& it : extra_headers) {
@ -370,7 +393,10 @@ gin::Handle<SimpleURLLoaderWrapper> SimpleURLLoaderWrapper::Create(
bool use_session_cookies = false;
opts.Get("useSessionCookies", &use_session_cookies);
int options = 0;
if (!use_session_cookies) {
if (!credentials_specified && !use_session_cookies) {
// This is the default case, as well as the case when credentials is not
// specified and useSessionCoookies is false. credentials_mode will be
// kInclude, but cookies will be blocked.
request->credentials_mode = network::mojom::CredentialsMode::kInclude;
options |= network::mojom::kURLLoadOptionBlockAllCookies;
}

View file

@ -1,5 +1,5 @@
import { expect } from 'chai';
import { net, session, ClientRequest, BrowserWindow } from 'electron/main';
import { net, session, ClientRequest, BrowserWindow, ClientRequestConstructorOptions } from 'electron/main';
import * as http from 'http';
import * as url from 'url';
import { AddressInfo, Socket } from 'net';
@ -215,6 +215,8 @@ describe('net module', () => {
expect(chunkIndex).to.be.equal(chunkCount);
});
for (const extraOptions of [{}, { credentials: 'include' }, { useSessionCookies: false, credentials: 'include' }] as ClientRequestConstructorOptions[]) {
describe(`authentication when ${JSON.stringify(extraOptions)}`, () => {
it('should emit the login event when 401', async () => {
const [user, pass] = ['user', 'pass'];
const serverUrl = await respondOnce.toSingleURL((request, response) => {
@ -224,7 +226,7 @@ describe('net module', () => {
response.writeHead(200).end('ok');
});
let loginAuthInfo: Electron.AuthInfo;
const request = net.request({ method: 'GET', url: serverUrl });
const request = net.request({ method: 'GET', url: serverUrl, ...extraOptions });
request.on('login', (authInfo, cb) => {
loginAuthInfo = authInfo;
cb(user, pass);
@ -235,7 +237,7 @@ describe('net module', () => {
expect(loginAuthInfo!.scheme).to.equal('basic');
});
it('should response when cancelling authentication', async () => {
it('should receive 401 response when cancelling authentication', async () => {
const serverUrl = await respondOnce.toSingleURL((request, response) => {
if (!request.headers.authorization) {
response.writeHead(401, { 'WWW-Authenticate': 'Basic realm="Foo"' });
@ -244,12 +246,13 @@ describe('net module', () => {
response.writeHead(200).end('ok');
}
});
const request = net.request({ method: 'GET', url: serverUrl });
const request = net.request({ method: 'GET', url: serverUrl, ...extraOptions });
request.on('login', (authInfo, cb) => {
cb();
});
const response = await getResponse(request);
const body = await collectStreamBody(response);
expect(response.statusCode).to.equal(401);
expect(body).to.equal('unauthenticated');
});
@ -268,7 +271,7 @@ describe('net module', () => {
});
await bw.loadURL(serverUrl);
bw.close();
const request = net.request({ method: 'GET', url: serverUrl });
const request = net.request({ method: 'GET', url: serverUrl, ...extraOptions });
let logInCount = 0;
request.on('login', () => {
logInCount++;
@ -295,7 +298,7 @@ describe('net module', () => {
});
await bw.loadURL('http://127.0.0.1:9999');
bw.close();
const request = net.request({ method: 'GET', url: 'http://127.0.0.1:9999', session: customSession });
const request = net.request({ method: 'GET', url: 'http://127.0.0.1:9999', session: customSession, ...extraOptions });
let logInCount = 0;
request.on('login', () => {
logInCount++;
@ -318,7 +321,7 @@ describe('net module', () => {
request.on('end', () => response.end());
});
const requestData = randomString(kOneKiloByte);
const request = net.request({ method: 'GET', url: serverUrl });
const request = net.request({ method: 'GET', url: serverUrl, ...extraOptions });
request.on('login', (authInfo, cb) => {
cb(user, pass);
});
@ -328,6 +331,77 @@ describe('net module', () => {
expect(responseData).to.equal(requestData);
});
});
}
describe('authentication when {"credentials":"omit"}', () => {
it('should not emit the login event when 401', async () => {
const serverUrl = await respondOnce.toSingleURL((request, response) => {
if (!request.headers.authorization) {
return response.writeHead(401, { 'WWW-Authenticate': 'Basic realm="Foo"' }).end();
}
response.writeHead(200).end('ok');
});
const request = net.request({ method: 'GET', url: serverUrl, credentials: 'omit' });
request.on('login', () => {
expect.fail('unexpected login event');
});
const response = await getResponse(request);
expect(response.statusCode).to.equal(401);
expect(response.headers['www-authenticate']).to.equal('Basic realm="Foo"');
});
it('should not share credentials with WebContents', async () => {
const [user, pass] = ['user', 'pass'];
const serverUrl = await respondNTimes.toSingleURL((request, response) => {
if (!request.headers.authorization) {
return response.writeHead(401, { 'WWW-Authenticate': 'Basic realm="Foo"' }).end();
}
return response.writeHead(200).end('ok');
}, 2);
const bw = new BrowserWindow({ show: false });
bw.webContents.on('login', (event, details, authInfo, cb) => {
event.preventDefault();
cb(user, pass);
});
await bw.loadURL(serverUrl);
bw.close();
const request = net.request({ method: 'GET', url: serverUrl, credentials: 'omit' });
request.on('login', () => {
expect.fail();
});
const response = await getResponse(request);
expect(response.statusCode).to.equal(401);
expect(response.headers['www-authenticate']).to.equal('Basic realm="Foo"');
});
it('should share proxy credentials with WebContents', async () => {
const [user, pass] = ['user', 'pass'];
const proxyUrl = await respondNTimes((request, response) => {
if (!request.headers['proxy-authorization']) {
return response.writeHead(407, { 'Proxy-Authenticate': 'Basic realm="Foo"' }).end();
}
return response.writeHead(200).end('ok');
}, 2);
const customSession = session.fromPartition(`net-proxy-test-${Math.random()}`);
await customSession.setProxy({ proxyRules: proxyUrl.replace('http://', ''), proxyBypassRules: '<-loopback>' });
const bw = new BrowserWindow({ show: false, webPreferences: { session: customSession } });
bw.webContents.on('login', (event, details, authInfo, cb) => {
event.preventDefault();
cb(user, pass);
});
await bw.loadURL('http://127.0.0.1:9999');
bw.close();
const request = net.request({ method: 'GET', url: 'http://127.0.0.1:9999', session: customSession, credentials: 'omit' });
request.on('login', () => {
expect.fail();
});
const response = await getResponse(request);
const body = await collectStreamBody(response);
expect(response.statusCode).to.equal(200);
expect(body).to.equal('ok');
});
});
});
describe('ClientRequest API', () => {
it('request/response objects should emit expected events', async () => {
@ -466,7 +540,7 @@ describe('net module', () => {
it('should be able to set cookie header line', async () => {
const cookieHeaderName = 'Cookie';
const cookieHeaderValue = 'test=12345';
const customSession = session.fromPartition('test-cookie-header');
const customSession = session.fromPartition(`test-cookie-header-${Math.random()}`);
const serverUrl = await respondOnce.toSingleURL((request, response) => {
expect(request.headers[cookieHeaderName.toLowerCase()]).to.equal(cookieHeaderValue);
response.statusCode = 200;
@ -511,7 +585,7 @@ describe('net module', () => {
response.setHeader('x-cookie', `${request.headers.cookie!}`);
response.end();
});
const sess = session.fromPartition('cookie-tests-1');
const sess = session.fromPartition(`cookie-tests-${Math.random()}`);
const cookieVal = `${Date.now()}`;
await sess.cookies.set({
url: serverUrl,
@ -526,6 +600,8 @@ describe('net module', () => {
expect(response.headers['x-cookie']).to.equal('undefined');
});
for (const extraOptions of [{ useSessionCookies: true }, { credentials: 'include' }] as ClientRequestConstructorOptions[]) {
describe(`when ${JSON.stringify(extraOptions)}`, () => {
it('should be able to use the sessions cookie store', async () => {
const serverUrl = await respondOnce.toSingleURL((request, response) => {
response.statusCode = 200;
@ -533,7 +609,7 @@ describe('net module', () => {
response.setHeader('x-cookie', request.headers.cookie!);
response.end();
});
const sess = session.fromPartition('cookie-tests-2');
const sess = session.fromPartition(`cookie-tests-${Math.random()}`);
const cookieVal = `${Date.now()}`;
await sess.cookies.set({
url: serverUrl,
@ -543,7 +619,7 @@ describe('net module', () => {
const urlRequest = net.request({
url: serverUrl,
session: sess,
useSessionCookies: true
...extraOptions
});
const response = await getResponse(urlRequest);
expect(response.headers['x-cookie']).to.equal(`wild_cookie=${cookieVal}`);
@ -556,13 +632,13 @@ describe('net module', () => {
response.setHeader('set-cookie', 'foo=bar');
response.end();
});
const sess = session.fromPartition('cookie-tests-3');
const sess = session.fromPartition(`cookie-tests-${Math.random()}`);
let cookies = await sess.cookies.get({});
expect(cookies).to.have.lengthOf(0);
const urlRequest = net.request({
url: serverUrl,
session: sess,
useSessionCookies: true
...extraOptions
});
await collectStreamBody(await getResponse(urlRequest));
cookies = await sess.cookies.get({});
@ -589,13 +665,13 @@ describe('net module', () => {
response.setHeader('x-cookie', `${request.headers.cookie}`);
response.end();
}, 2);
const sess = session.fromPartition(`cookie-tests-same-site-${mode}`);
const sess = session.fromPartition(`cookie-tests-${Math.random()}`);
let cookies = await sess.cookies.get({});
expect(cookies).to.have.lengthOf(0);
const urlRequest = net.request({
url: serverUrl,
session: sess,
useSessionCookies: true
...extraOptions
});
const response = await getResponse(urlRequest);
expect(response.headers['x-cookie']).to.equal('undefined');
@ -616,7 +692,7 @@ describe('net module', () => {
const urlRequest2 = net.request({
url: serverUrl,
session: sess,
useSessionCookies: true
...extraOptions
});
const response2 = await getResponse(urlRequest2);
expect(response2.headers['x-cookie']).to.equal('same=site');
@ -637,7 +713,7 @@ describe('net module', () => {
response.setHeader('location', newUrl.replace('127.0.0.1', 'localhost'));
response.end();
});
const sess = session.fromPartition('cookie-tests-4');
const sess = session.fromPartition(`cookie-tests-${Math.random()}`);
const cookie127Val = `${Date.now()}-127`;
const cookieLocalVal = `${Date.now()}-local`;
const localhostUrl = serverUrl.replace('127.0.0.1', 'localhost');
@ -656,7 +732,7 @@ describe('net module', () => {
const urlRequest = net.request({
url: serverUrl,
session: sess,
useSessionCookies: true
...extraOptions
});
urlRequest.on('redirect', (status, method, url, headers) => {
// The initial redirect response should have received the 127 value here
@ -672,6 +748,13 @@ describe('net module', () => {
// and attach the cookies for the new target domain
expect(response.headers['x-cookie']).to.equal(`wild_cookie=${cookieLocalVal}`);
});
});
}
describe('when {"credentials":"omit"}', () => {
it('should not send cookies');
it('should not store cookies');
});
it('should be able to abort an HTTP request before first write', async () => {
const serverUrl = await respondOnce.toSingleURL((request, response) => {
@ -916,7 +999,7 @@ describe('net module', () => {
it('should to able to create and intercept a request using a custom session object', async () => {
const requestUrl = '/requestUrl';
const redirectUrl = '/redirectUrl';
const customPartitionName = 'custom-partition';
const customPartitionName = `custom-partition-${Math.random()}`;
let requestIsRedirected = false;
const serverUrl = await respondOnce.toURL(redirectUrl, (request, response) => {
requestIsRedirected = true;
@ -957,7 +1040,7 @@ describe('net module', () => {
it('should to able to create and intercept a request using a custom partition name', async () => {
const requestUrl = '/requestUrl';
const redirectUrl = '/redirectUrl';
const customPartitionName = 'custom-partition';
const customPartitionName = `custom-partition-${Math.random()}`;
let requestIsRedirected = false;
const serverUrl = await respondOnce.toURL(redirectUrl, (request, response) => {
requestIsRedirected = true;

View file

@ -94,6 +94,7 @@ declare namespace NodeJS {
url: string;
extraHeaders?: Record<string, string>;
useSessionCookies?: boolean;
credentials?: 'include' | 'omit';
body: Uint8Array | BodyFunc;
session?: Electron.Session;
partition?: string;