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

@ -13,30 +13,40 @@ interface and is therefore an [EventEmitter][event-emitter].
the request URL. If it is an object, it is expected to fully specify an HTTP request via the the request URL. If it is an object, it is expected to fully specify an HTTP request via the
following properties: following properties:
* `method` String (optional) - The HTTP request method. Defaults to the GET * `method` String (optional) - The HTTP request method. Defaults to the GET
method. method.
* `url` String (optional) - The request URL. Must be provided in the absolute * `url` String (optional) - The request URL. Must be provided in the absolute
form with the protocol scheme specified as http or https. form with the protocol scheme specified as http or https.
* `session` Session (optional) - The [`Session`](session.md) instance with * `session` Session (optional) - The [`Session`](session.md) instance with
which the request is associated. which the request is associated.
* `partition` String (optional) - The name of the [`partition`](session.md) * `partition` String (optional) - The name of the [`partition`](session.md)
with which the request is associated. Defaults to the empty string. The 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. 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 * `useSessionCookies` Boolean (optional) - Whether to send cookies with this
request from the provided session. This will make the `net` request's request from the provided session. If `credentials` is specified, this
cookie behavior match a `fetch` request. Default is `false`. option has no effect. Default is `false`.
* `protocol` String (optional) - The protocol scheme in the form 'scheme:'. * `protocol` String (optional) - Can be `http:` or `https:`. The protocol
Currently supported values are 'http:' or 'https:'. Defaults to 'http:'. scheme in the form 'scheme:'. Defaults to 'http:'.
* `host` String (optional) - The server host provided as a concatenation of * `host` String (optional) - The server host provided as a concatenation of
the hostname and the port number 'hostname:port'. the hostname and the port number 'hostname:port'.
* `hostname` String (optional) - The server host name. * `hostname` String (optional) - The server host name.
* `port` Integer (optional) - The server's listening port number. * `port` Integer (optional) - The server's listening port number.
* `path` String (optional) - The path part of the request URL. * `path` String (optional) - The path part of the request URL.
* `redirect` String (optional) - The redirect mode for this request. Should be * `redirect` String (optional) - Can be `follow`, `error` or `manual`. The
one of `follow`, `error` or `manual`. Defaults to `follow`. When mode is `error`, redirect mode for this request. When mode is `error`, any redirection will
any redirection will be aborted. When mode is `manual` the redirection will be be aborted. When mode is `manual` the redirection will be cancelled unless
cancelled unless [`request.followRedirect`](#requestfollowredirect) is invoked [`request.followRedirect`](#requestfollowredirect) is invoked synchronously
synchronously during the [`redirect`](#event-redirect) event. during the [`redirect`](#event-redirect) event. Defaults to `follow`.
`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

View file

@ -2,6 +2,7 @@ import * as url from 'url';
import { Readable, Writable } from 'stream'; import { Readable, Writable } from 'stream';
import { app } from 'electron/main'; import { app } from 'electron/main';
import type { ClientRequestConstructorOptions, UploadProgress } from 'electron/main'; import type { ClientRequestConstructorOptions, UploadProgress } from 'electron/main';
const { const {
isValidHeaderName, isValidHeaderName,
isValidHeaderValue, isValidHeaderValue,
@ -243,7 +244,8 @@ function parseOptions (optionsIn: ClientRequestConstructorOptions | string): Nod
redirectPolicy, redirectPolicy,
extraHeaders: options.headers || {}, extraHeaders: options.headers || {},
body: null as any, body: null as any,
useSessionCookies: options.useSessionCookies || false useSessionCookies: options.useSessionCookies,
credentials: options.credentials
}; };
for (const [name, value] of Object.entries(urlLoaderOptions.extraHeaders!)) { for (const [name, value] of Object.entries(urlLoaderOptions.extraHeaders!)) {
if (!isValidHeaderName(name)) { 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 gin
namespace electron { namespace electron {
@ -355,6 +376,8 @@ 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);
bool credentials_specified =
opts.Get("credentials", &request->credentials_mode);
std::map<std::string, std::string> extra_headers; std::map<std::string, std::string> extra_headers;
if (opts.Get("extraHeaders", &extra_headers)) { if (opts.Get("extraHeaders", &extra_headers)) {
for (const auto& it : extra_headers) { for (const auto& it : extra_headers) {
@ -370,7 +393,10 @@ gin::Handle<SimpleURLLoaderWrapper> SimpleURLLoaderWrapper::Create(
bool use_session_cookies = false; bool use_session_cookies = false;
opts.Get("useSessionCookies", &use_session_cookies); opts.Get("useSessionCookies", &use_session_cookies);
int options = 0; 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; request->credentials_mode = network::mojom::CredentialsMode::kInclude;
options |= network::mojom::kURLLoadOptionBlockAllCookies; options |= network::mojom::kURLLoadOptionBlockAllCookies;
} }

View file

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

View file

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