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. 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;