Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions lib/urllib.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,44 @@ var TEXT_DATA_TYPES = [

var PROTO_RE = /^https?:\/\//i;

// Credential-bearing headers that must not be forwarded across an origin
// boundary when following a redirect, to avoid leaking them to a third party.
var CROSS_ORIGIN_SENSITIVE_HEADERS = [ 'authorization', 'cookie', 'proxy-authorization' ];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve proxy auth when reusing the same proxy

For proxied requests where the caller pins a proxy with args.proxy/URLLIB_PROXY and supplies Proxy-Authorization in the request headers, a redirect to another origin still goes through that same authenticated proxy, but this list removes the proxy credential before the recursive request. In that context the header authenticates the proxy rather than the redirected origin, so authenticated proxies can start returning 407 on any cross-origin redirect; only strip it when it is actually being sent as an origin header rather than while proxying.

Useful? React with 👍 / 👎.


function isCrossOriginRedirect(fromHref, toUrl) {
try {
if (URL) {
return new URL(fromHref).origin !== new URL(toUrl, fromHref).origin;
}
// Fallback for runtimes without the WHATWG URL global.
// urlutil.parse does not normalize default ports, so compare protocol +
// hostname + normalized port instead of the raw `host`.
var from = urlutil.parse(fromHref);
var to = urlutil.parse(urlutil.resolve(fromHref, toUrl));
var fromPort = from.port || (from.protocol === 'https:' ? '443' : '80');
var toPort = to.port || (to.protocol === 'https:' ? '443' : '80');
return from.protocol !== to.protocol || from.hostname !== to.hostname || fromPort !== toPort;
} catch (err) {
// Fail closed: if the origin cannot be determined, treat it as cross-origin
// so credentials are stripped rather than leaked.
return true;
}
}

function cleanCrossOriginHeaders(headers) {
var cleaned = {};
if (headers) {
var names = utility.getOwnEnumerables(headers, true);
for (var i = 0; i < names.length; i++) {
var name = names[i];
if (CROSS_ORIGIN_SENSITIVE_HEADERS.indexOf(name.toLowerCase()) === -1) {
cleaned[name] = headers[name];
}
}
}
return cleaned;
}

// Keep-Alive: timeout=5, max=100
var KEEP_ALIVE_RE = /^timeout=(\d+)/i;

Expand Down Expand Up @@ -688,6 +726,12 @@ function requestWithCallback(url, args, callback) {
options.headers.host = null;
args.headers = options.headers;
}
// do not forward credential-bearing headers / auth to a different origin
if (isCrossOriginRedirect(parsedUrl.href, newUrl)) {
Comment thread
fengmk2 marked this conversation as resolved.
Outdated
args.headers = cleanCrossOriginHeaders(args.headers || options.headers);
args.auth = null;
args.digestAuth = null;
Comment thread
fengmk2 marked this conversation as resolved.
Outdated
}
// avoid done will be execute in the future change.
var cb = callback;
callback = null;
Expand Down
120 changes: 120 additions & 0 deletions test/redirect-cross-origin.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
'use strict';

var assert = require('assert');
var http = require('http');
var urllib = require('../');

// On a cross-origin redirect, credential-bearing request headers
// (Authorization, Cookie, Proxy-Authorization) and the `auth` option must NOT
// be forwarded to the new origin. Same-origin redirects should keep them.
describe('test/redirect-cross-origin.test.js', function() {
var serverA;
var serverB;
var portA;
var portB;

before(function(done) {
serverB = http.createServer(function(req, res) {
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify(req.headers));
});
serverB.listen(0, function() {
portB = serverB.address().port;
serverA = http.createServer(function(req, res) {
if (req.url === '/start-cross') {
res.statusCode = 302;
res.setHeader('Location', 'http://127.0.0.1:' + portB + '/captured');
return res.end('redirect to B');
}
if (req.url === '/start-same') {
res.statusCode = 302;
res.setHeader('Location', '/captured');
return res.end('redirect to self');
}
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify(req.headers));
});
serverA.listen(0, function() {
portA = serverA.address().port;
done();
});
});
});

after(function() {
if (serverA) {
serverA.close();
}
if (serverB) {
serverB.close();
}
});

function credentialHeaders() {
return {
Authorization: 'Bearer LIVE-AUTH',
Cookie: 'session=LIVE-COOKIE',
'Proxy-Authorization': 'Bearer proxy-LIVE',
};
}

it('should NOT forward credential headers on cross-origin redirect', function(done) {
urllib.request('http://127.0.0.1:' + portA + '/start-cross', {
followRedirect: true,
headers: credentialHeaders(),
}, function(err, data, res) {
assert(!err);
assert.equal(res.statusCode, 200);
assert.equal(res.requestUrls.length, 2);
var received = JSON.parse(data.toString());
assert.equal(received.authorization, undefined);
assert.equal(received.cookie, undefined);
assert.equal(received['proxy-authorization'], undefined);
done();
});
});

it('should keep credential headers on same-origin redirect', function(done) {
urllib.request('http://127.0.0.1:' + portA + '/start-same', {
followRedirect: true,
headers: credentialHeaders(),
}, function(err, data, res) {
assert(!err);
assert.equal(res.statusCode, 200);
assert.equal(res.requestUrls.length, 2);
var received = JSON.parse(data.toString());
assert.equal(received.authorization, 'Bearer LIVE-AUTH');
assert.equal(received.cookie, 'session=LIVE-COOKIE');
assert.equal(received['proxy-authorization'], 'Bearer proxy-LIVE');
done();
});
});

it('should NOT re-inject Basic auth (options.auth) on cross-origin redirect', function(done) {
urllib.request('http://127.0.0.1:' + portA + '/start-cross', {
followRedirect: true,
auth: 'user:passwd',
}, function(err, data, res) {
assert(!err);
assert.equal(res.statusCode, 200);
var received = JSON.parse(data.toString());
assert.equal(received.authorization, undefined);
done();
});
});

it('should keep Basic auth (options.auth) on same-origin redirect', function(done) {
urllib.request('http://127.0.0.1:' + portA + '/start-same', {
followRedirect: true,
auth: 'user:passwd',
}, function(err, data, res) {
assert(!err);
assert.equal(res.statusCode, 200);
var received = JSON.parse(data.toString());
assert.equal(received.authorization, 'Basic ' + Buffer.from('user:passwd').toString('base64'));
done();
});
});
});
Loading