From 2d80b0bb3fb746ff77cfe604f21ef9e47352ece0 Mon Sep 17 00:00:00 2001 From: "Travis D. Warlick, Jr" Date: Fri, 5 Nov 2021 05:26:13 -0400 Subject: [PATCH] Add support for Referrer and Referrer Policy (#1057) * Support referrer and referrerPolicy * Test TS types for addition of referrer and referrerPolicy * Fix lint issues and merge error --- @types/index.d.ts | 17 ++ README.md | 2 - src/index.js | 11 +- src/request.js | 77 +++++- src/utils/referrer.js | 340 ++++++++++++++++++++++++++ test/referrer.js | 552 ++++++++++++++++++++++++++++++++++++++++++ test/utils/server.js | 14 ++ 7 files changed, 1008 insertions(+), 5 deletions(-) create mode 100644 src/utils/referrer.js create mode 100644 test/referrer.js diff --git a/@types/index.d.ts b/@types/index.d.ts index 6af37925c..7dbc05ef0 100644 --- a/@types/index.d.ts +++ b/@types/index.d.ts @@ -71,6 +71,14 @@ export interface RequestInit { * An AbortSignal to set request's signal. */ signal?: AbortSignal | null; + /** + * A string whose value is a same-origin URL, "about:client", or the empty string, to set request’s referrer. + */ + referrer?: string; + /** + * A referrer policy to set request’s referrerPolicy. + */ + referrerPolicy?: ReferrerPolicy; // Node-fetch extensions to the whatwg/fetch spec agent?: Agent | ((parsedUrl: URL) => Agent); @@ -118,6 +126,7 @@ declare class BodyMixin { export interface Body extends Pick {} export type RequestRedirect = 'error' | 'follow' | 'manual'; +export type ReferrerPolicy = '' | 'no-referrer' | 'no-referrer-when-downgrade' | 'same-origin' | 'origin' | 'strict-origin' | 'origin-when-cross-origin' | 'strict-origin-when-cross-origin' | 'unsafe-url'; export type RequestInfo = string | Request; export class Request extends BodyMixin { constructor(input: RequestInfo, init?: RequestInit); @@ -142,6 +151,14 @@ export class Request extends BodyMixin { * Returns the URL of request as a string. */ readonly url: string; + /** + * A string whose value is a same-origin URL, "about:client", or the empty string, to set request’s referrer. + */ + readonly referrer: string; + /** + * A referrer policy to set request’s referrerPolicy. + */ + readonly referrerPolicy: ReferrerPolicy; clone(): Request; } diff --git a/README.md b/README.md index 77127aa4c..2c1198f57 100644 --- a/README.md +++ b/README.md @@ -581,8 +581,6 @@ Due to the nature of Node.js, the following properties are not implemented at th - `type` - `destination` -- `referrer` -- `referrerPolicy` - `mode` - `credentials` - `cache` diff --git a/src/index.js b/src/index.js index 0a15c2796..f8686be43 100644 --- a/src/index.js +++ b/src/index.js @@ -19,6 +19,7 @@ import Request, {getNodeRequestOptions} from './request.js'; import {FetchError} from './errors/fetch-error.js'; import {AbortError} from './errors/abort-error.js'; import {isRedirect} from './utils/is-redirect.js'; +import {parseReferrerPolicyFromHeader} from './utils/referrer.js'; export {Headers, Request, Response, FetchError, AbortError, isRedirect}; @@ -168,7 +169,9 @@ export default async function fetch(url, options_) { method: request.method, body: clone(request), signal: request.signal, - size: request.size + size: request.size, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy }; // HTTP-redirect fetch step 9 @@ -185,6 +188,12 @@ export default async function fetch(url, options_) { requestOptions.headers.delete('content-length'); } + // HTTP-redirect fetch step 14 + const responseReferrerPolicy = parseReferrerPolicyFromHeader(headers); + if (responseReferrerPolicy) { + requestOptions.referrerPolicy = responseReferrerPolicy; + } + // HTTP-redirect fetch step 15 resolve(fetch(new Request(locationURL, requestOptions))); finalize(); diff --git a/src/request.js b/src/request.js index 318042749..6d6272cb7 100644 --- a/src/request.js +++ b/src/request.js @@ -12,6 +12,9 @@ import Headers from './headers.js'; import Body, {clone, extractContentType, getTotalBytes} from './body.js'; import {isAbortSignal} from './utils/is.js'; import {getSearch} from './utils/get-search.js'; +import { + validateReferrerPolicy, determineRequestsReferrer, DEFAULT_REFERRER_POLICY +} from './utils/referrer.js'; const INTERNALS = Symbol('Request internals'); @@ -93,12 +96,28 @@ export default class Request extends Body { throw new TypeError('Expected signal to be an instanceof AbortSignal or EventTarget'); } + // §5.4, Request constructor steps, step 15.1 + // eslint-disable-next-line no-eq-null, eqeqeq + let referrer = init.referrer == null ? input.referrer : init.referrer; + if (referrer === '') { + // §5.4, Request constructor steps, step 15.2 + referrer = 'no-referrer'; + } else if (referrer) { + // §5.4, Request constructor steps, step 15.3.1, 15.3.2 + const parsedReferrer = new URL(referrer); + // §5.4, Request constructor steps, step 15.3.3, 15.3.4 + referrer = /^about:(\/\/)?client$/.test(parsedReferrer) ? 'client' : parsedReferrer; + } else { + referrer = undefined; + } + this[INTERNALS] = { method, redirect: init.redirect || input.redirect || 'follow', headers, parsedURL, - signal + signal, + referrer }; // Node-fetch-only options @@ -108,6 +127,10 @@ export default class Request extends Body { this.agent = init.agent || input.agent; this.highWaterMark = init.highWaterMark || input.highWaterMark || 16384; this.insecureHTTPParser = init.insecureHTTPParser || input.insecureHTTPParser || false; + + // §5.4, Request constructor steps, step 16. + // Default is empty string per https://fetch.spec.whatwg.org/#concept-request-referrer-policy + this.referrerPolicy = init.referrerPolicy || input.referrerPolicy || ''; } get method() { @@ -130,6 +153,31 @@ export default class Request extends Body { return this[INTERNALS].signal; } + // https://fetch.spec.whatwg.org/#dom-request-referrer + get referrer() { + if (this[INTERNALS].referrer === 'no-referrer') { + return ''; + } + + if (this[INTERNALS].referrer === 'client') { + return 'about:client'; + } + + if (this[INTERNALS].referrer) { + return this[INTERNALS].referrer.toString(); + } + + return undefined; + } + + get referrerPolicy() { + return this[INTERNALS].referrerPolicy; + } + + set referrerPolicy(referrerPolicy) { + this[INTERNALS].referrerPolicy = validateReferrerPolicy(referrerPolicy); + } + /** * Clone this request * @@ -150,7 +198,9 @@ Object.defineProperties(Request.prototype, { headers: {enumerable: true}, redirect: {enumerable: true}, clone: {enumerable: true}, - signal: {enumerable: true} + signal: {enumerable: true}, + referrer: {enumerable: true}, + referrerPolicy: {enumerable: true} }); /** @@ -186,6 +236,29 @@ export const getNodeRequestOptions = request => { headers.set('Content-Length', contentLengthValue); } + // 4.1. Main fetch, step 2.6 + // > If request's referrer policy is the empty string, then set request's referrer policy to the + // > default referrer policy. + if (request.referrerPolicy === '') { + request.referrerPolicy = DEFAULT_REFERRER_POLICY; + } + + // 4.1. Main fetch, step 2.7 + // > If request's referrer is not "no-referrer", set request's referrer to the result of invoking + // > determine request's referrer. + if (request.referrer && request.referrer !== 'no-referrer') { + request[INTERNALS].referrer = determineRequestsReferrer(request); + } else { + request[INTERNALS].referrer = 'no-referrer'; + } + + // 4.5. HTTP-network-or-cache fetch, step 6.9 + // > If httpRequest's referrer is a URL, then append `Referer`/httpRequest's referrer, serialized + // > and isomorphic encoded, to httpRequest's header list. + if (request[INTERNALS].referrer instanceof URL) { + headers.set('Referer', request.referrer); + } + // HTTP-network-or-cache fetch step 2.11 if (!headers.has('User-Agent')) { headers.set('User-Agent', 'node-fetch'); diff --git a/src/utils/referrer.js b/src/utils/referrer.js new file mode 100644 index 000000000..f9b681763 --- /dev/null +++ b/src/utils/referrer.js @@ -0,0 +1,340 @@ +import {isIP} from 'net'; + +/** + * @external URL + * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/URL|URL} + */ + +/** + * @module utils/referrer + * @private + */ + +/** + * @see {@link https://w3c.github.io/webappsec-referrer-policy/#strip-url|Referrer Policy §8.4. Strip url for use as a referrer} + * @param {string} URL + * @param {boolean} [originOnly=false] + */ +export function stripURLForUseAsAReferrer(url, originOnly = false) { + // 1. If url is null, return no referrer. + if (url == null) { // eslint-disable-line no-eq-null, eqeqeq + return 'no-referrer'; + } + + url = new URL(url); + + // 2. If url's scheme is a local scheme, then return no referrer. + if (/^(about|blob|data):$/.test(url.protocol)) { + return 'no-referrer'; + } + + // 3. Set url's username to the empty string. + url.username = ''; + + // 4. Set url's password to null. + // Note: `null` appears to be a mistake as this actually results in the password being `"null"`. + url.password = ''; + + // 5. Set url's fragment to null. + // Note: `null` appears to be a mistake as this actually results in the fragment being `"#null"`. + url.hash = ''; + + // 6. If the origin-only flag is true, then: + if (originOnly) { + // 6.1. Set url's path to null. + // Note: `null` appears to be a mistake as this actually results in the path being `"/null"`. + url.pathname = ''; + + // 6.2. Set url's query to null. + // Note: `null` appears to be a mistake as this actually results in the query being `"?null"`. + url.search = ''; + } + + // 7. Return url. + return url; +} + +/** + * @see {@link https://w3c.github.io/webappsec-referrer-policy/#enumdef-referrerpolicy|enum ReferrerPolicy} + */ +export const ReferrerPolicy = new Set([ + '', + 'no-referrer', + 'no-referrer-when-downgrade', + 'same-origin', + 'origin', + 'strict-origin', + 'origin-when-cross-origin', + 'strict-origin-when-cross-origin', + 'unsafe-url' +]); + +/** + * @see {@link https://w3c.github.io/webappsec-referrer-policy/#default-referrer-policy|default referrer policy} + */ +export const DEFAULT_REFERRER_POLICY = 'strict-origin-when-cross-origin'; + +/** + * @see {@link https://w3c.github.io/webappsec-referrer-policy/#referrer-policies|Referrer Policy §3. Referrer Policies} + * @param {string} referrerPolicy + * @returns {string} referrerPolicy + */ +export function validateReferrerPolicy(referrerPolicy) { + if (!ReferrerPolicy.has(referrerPolicy)) { + throw new TypeError(`Invalid referrerPolicy: ${referrerPolicy}`); + } + + return referrerPolicy; +} + +/** + * @see {@link https://w3c.github.io/webappsec-secure-contexts/#is-origin-trustworthy|Referrer Policy §3.2. Is origin potentially trustworthy?} + * @param {external:URL} url + * @returns `true`: "Potentially Trustworthy", `false`: "Not Trustworthy" + */ +export function isOriginPotentiallyTrustworthy(url) { + // 1. If origin is an opaque origin, return "Not Trustworthy". + // Not applicable + + // 2. Assert: origin is a tuple origin. + // Not for implementations + + // 3. If origin's scheme is either "https" or "wss", return "Potentially Trustworthy". + if (/^(http|ws)s:$/.test(url.protocol)) { + return true; + } + + // 4. If origin's host component matches one of the CIDR notations 127.0.0.0/8 or ::1/128 [RFC4632], return "Potentially Trustworthy". + const hostIp = url.host.replace(/(^\[)|(]$)/g, ''); + const hostIPVersion = isIP(hostIp); + + if (hostIPVersion === 4 && /^127\./.test(hostIp)) { + return true; + } + + if (hostIPVersion === 6 && /^(((0+:){7})|(::(0+:){0,6}))0*1$/.test(hostIp)) { + return true; + } + + // 5. If origin's host component is "localhost" or falls within ".localhost", and the user agent conforms to the name resolution rules in [let-localhost-be-localhost], return "Potentially Trustworthy". + // We are returning FALSE here because we cannot ensure conformance to + // let-localhost-be-loalhost (https://tools.ietf.org/html/draft-west-let-localhost-be-localhost) + if (/^(.+\.)*localhost$/.test(url.host)) { + return false; + } + + // 6. If origin's scheme component is file, return "Potentially Trustworthy". + if (url.protocol === 'file:') { + return true; + } + + // 7. If origin's scheme component is one which the user agent considers to be authenticated, return "Potentially Trustworthy". + // Not supported + + // 8. If origin has been configured as a trustworthy origin, return "Potentially Trustworthy". + // Not supported + + // 9. Return "Not Trustworthy". + return false; +} + +/** + * @see {@link https://w3c.github.io/webappsec-secure-contexts/#is-url-trustworthy|Referrer Policy §3.3. Is url potentially trustworthy?} + * @param {external:URL} url + * @returns `true`: "Potentially Trustworthy", `false`: "Not Trustworthy" + */ +export function isUrlPotentiallyTrustworthy(url) { + // 1. If url is "about:blank" or "about:srcdoc", return "Potentially Trustworthy". + if (/^about:(blank|srcdoc)$/.test(url)) { + return true; + } + + // 2. If url's scheme is "data", return "Potentially Trustworthy". + if (url.protocol === 'data:') { + return true; + } + + // Note: The origin of blob: and filesystem: URLs is the origin of the context in which they were + // created. Therefore, blobs created in a trustworthy origin will themselves be potentially + // trustworthy. + if (/^(blob|filesystem):$/.test(url.protocol)) { + return true; + } + + // 3. Return the result of executing §3.2 Is origin potentially trustworthy? on url's origin. + return isOriginPotentiallyTrustworthy(url); +} + +/** + * Modifies the referrerURL to enforce any extra security policy considerations. + * @see {@link https://w3c.github.io/webappsec-referrer-policy/#determine-requests-referrer|Referrer Policy §8.3. Determine request's Referrer}, step 7 + * @callback module:utils/referrer~referrerURLCallback + * @param {external:URL} referrerURL + * @returns {external:URL} modified referrerURL + */ + +/** + * Modifies the referrerOrigin to enforce any extra security policy considerations. + * @see {@link https://w3c.github.io/webappsec-referrer-policy/#determine-requests-referrer|Referrer Policy §8.3. Determine request's Referrer}, step 7 + * @callback module:utils/referrer~referrerOriginCallback + * @param {external:URL} referrerOrigin + * @returns {external:URL} modified referrerOrigin + */ + +/** + * @see {@link https://w3c.github.io/webappsec-referrer-policy/#determine-requests-referrer|Referrer Policy §8.3. Determine request's Referrer} + * @param {Request} request + * @param {object} o + * @param {module:utils/referrer~referrerURLCallback} o.referrerURLCallback + * @param {module:utils/referrer~referrerOriginCallback} o.referrerOriginCallback + * @returns {external:URL} Request's referrer + */ +export function determineRequestsReferrer(request, {referrerURLCallback, referrerOriginCallback} = {}) { + // There are 2 notes in the specification about invalid pre-conditions. We return null, here, for + // these cases: + // > Note: If request's referrer is "no-referrer", Fetch will not call into this algorithm. + // > Note: If request's referrer policy is the empty string, Fetch will not call into this + // > algorithm. + if (request.referrer === 'no-referrer' || request.referrerPolicy === '') { + return null; + } + + // 1. Let policy be request's associated referrer policy. + const policy = request.referrerPolicy; + + // 2. Let environment be request's client. + // not applicable to node.js + + // 3. Switch on request's referrer: + if (request.referrer === 'about:client') { + return 'no-referrer'; + } + + // "a URL": Let referrerSource be request's referrer. + const referrerSource = request.referrer; + + // 4. Let request's referrerURL be the result of stripping referrerSource for use as a referrer. + let referrerURL = stripURLForUseAsAReferrer(referrerSource); + + // 5. Let referrerOrigin be the result of stripping referrerSource for use as a referrer, with the + // origin-only flag set to true. + let referrerOrigin = stripURLForUseAsAReferrer(referrerSource, true); + + // 6. If the result of serializing referrerURL is a string whose length is greater than 4096, set + // referrerURL to referrerOrigin. + if (referrerURL.toString().length > 4096) { + referrerURL = referrerOrigin; + } + + // 7. The user agent MAY alter referrerURL or referrerOrigin at this point to enforce arbitrary + // policy considerations in the interests of minimizing data leakage. For example, the user + // agent could strip the URL down to an origin, modify its host, replace it with an empty + // string, etc. + if (referrerURLCallback) { + referrerURL = referrerURLCallback(referrerURL); + } + + if (referrerOriginCallback) { + referrerOrigin = referrerOriginCallback(referrerOrigin); + } + + // 8.Execute the statements corresponding to the value of policy: + const currentURL = new URL(request.url); + + switch (policy) { + case 'no-referrer': + return 'no-referrer'; + + case 'origin': + return referrerOrigin; + + case 'unsafe-url': + return referrerURL; + + case 'strict-origin': + // 1. If referrerURL is a potentially trustworthy URL and request's current URL is not a + // potentially trustworthy URL, then return no referrer. + if (isUrlPotentiallyTrustworthy(referrerURL) && !isUrlPotentiallyTrustworthy(currentURL)) { + return 'no-referrer'; + } + + // 2. Return referrerOrigin. + return referrerOrigin.toString(); + + case 'strict-origin-when-cross-origin': + // 1. If the origin of referrerURL and the origin of request's current URL are the same, then + // return referrerURL. + if (referrerURL.origin === currentURL.origin) { + return referrerURL; + } + + // 2. If referrerURL is a potentially trustworthy URL and request's current URL is not a + // potentially trustworthy URL, then return no referrer. + if (isUrlPotentiallyTrustworthy(referrerURL) && !isUrlPotentiallyTrustworthy(currentURL)) { + return 'no-referrer'; + } + + // 3. Return referrerOrigin. + return referrerOrigin; + + case 'same-origin': + // 1. If the origin of referrerURL and the origin of request's current URL are the same, then + // return referrerURL. + if (referrerURL.origin === currentURL.origin) { + return referrerURL; + } + + // 2. Return no referrer. + return 'no-referrer'; + + case 'origin-when-cross-origin': + // 1. If the origin of referrerURL and the origin of request's current URL are the same, then + // return referrerURL. + if (referrerURL.origin === currentURL.origin) { + return referrerURL; + } + + // Return referrerOrigin. + return referrerOrigin; + + case 'no-referrer-when-downgrade': + // 1. If referrerURL is a potentially trustworthy URL and request's current URL is not a + // potentially trustworthy URL, then return no referrer. + if (isUrlPotentiallyTrustworthy(referrerURL) && !isUrlPotentiallyTrustworthy(currentURL)) { + return 'no-referrer'; + } + + // 2. Return referrerURL. + return referrerURL; + + default: + throw new TypeError(`Invalid referrerPolicy: ${policy}`); + } +} + +/** + * @see {@link https://w3c.github.io/webappsec-referrer-policy/#parse-referrer-policy-from-header|Referrer Policy §8.1. Parse a referrer policy from a Referrer-Policy header} + * @param {Headers} headers Response headers + * @returns {string} policy + */ +export function parseReferrerPolicyFromHeader(headers) { + // 1. Let policy-tokens be the result of extracting header list values given `Referrer-Policy` + // and response’s header list. + const policyTokens = (headers.get('referrer-policy') || '').split(/[,\s]+/); + + // 2. Let policy be the empty string. + let policy = ''; + + // 3. For each token in policy-tokens, if token is a referrer policy and token is not the empty + // string, then set policy to token. + // Note: This algorithm loops over multiple policy values to allow deployment of new policy + // values with fallbacks for older user agents, as described in § 11.1 Unknown Policy Values. + for (const token of policyTokens) { + if (token && ReferrerPolicy.has(token)) { + policy = token; + } + } + + // 4. Return policy. + return policy; +} diff --git a/test/referrer.js b/test/referrer.js new file mode 100644 index 000000000..35e6b93c5 --- /dev/null +++ b/test/referrer.js @@ -0,0 +1,552 @@ +import chai from 'chai'; + +import fetch, {Request, Headers} from '../src/index.js'; +import { + DEFAULT_REFERRER_POLICY, ReferrerPolicy, stripURLForUseAsAReferrer, validateReferrerPolicy, + isOriginPotentiallyTrustworthy, isUrlPotentiallyTrustworthy, determineRequestsReferrer, + parseReferrerPolicyFromHeader +} from '../src/utils/referrer.js'; +import TestServer from './utils/server.js'; + +const {expect} = chai; + +describe('fetch() with referrer and referrerPolicy', () => { + const local = new TestServer(); + let base; + + before(async () => { + await local.start(); + base = `http://${local.hostname}:${local.port}/`; + }); + + after(async () => { + return local.stop(); + }); + + it('should send request without a referrer by default', () => { + return fetch(`${base}inspect`).then(res => res.json()).then(res => { + expect(res.headers.referer).to.be.undefined; + }); + }); + + it('should send request with a referrer', () => { + return fetch(`${base}inspect`, { + referrer: base, + referrerPolicy: 'unsafe-url' + }).then(res => res.json()).then(res => { + expect(res.headers.referer).to.equal(base); + }); + }); + + it('should send request with referrerPolicy strict-origin-when-cross-origin by default', () => { + return Promise.all([ + fetch(`${base}inspect`, { + referrer: base + }).then(res => res.json()).then(res => { + expect(res.headers.referer).to.equal(base); + }), + fetch(`${base}inspect`, { + referrer: 'https://example.com' + }).then(res => res.json()).then(res => { + expect(res.headers.referer).to.be.undefined; + }) + ]); + }); + + it('should send request with a referrer and respect redirected referrer-policy', () => { + return Promise.all([ + fetch(`${base}redirect/referrer-policy`, { + referrer: base + }).then(res => res.json()).then(res => { + expect(res.headers.referer).to.equal(base); + }), + fetch(`${base}redirect/referrer-policy`, { + referrer: 'https://example.com' + }).then(res => res.json()).then(res => { + expect(res.headers.referer).to.be.undefined; + }), + fetch(`${base}redirect/referrer-policy`, { + referrer: 'https://example.com', + referrerPolicy: 'unsafe-url' + }).then(res => res.json()).then(res => { + expect(res.headers.referer).to.equal('https://example.com/'); + }), + fetch(`${base}redirect/referrer-policy/same-origin`, { + referrer: 'https://example.com', + referrerPolicy: 'unsafe-url' + }).then(res => res.json()).then(res => { + expect(res.headers.referer).to.undefined; + }) + ]); + }); +}); + +describe('Request constructor', () => { + describe('referrer', () => { + it('should leave referrer undefined by default', () => { + const req = new Request('http://example.com'); + expect(req.referrer).to.be.undefined; + }); + + it('should accept empty string referrer as no-referrer', () => { + const referrer = ''; + const req = new Request('http://example.com', {referrer}); + expect(req.referrer).to.equal(referrer); + }); + + it('should accept about:client referrer as client', () => { + const referrer = 'about:client'; + const req = new Request('http://example.com', {referrer}); + expect(req.referrer).to.equal(referrer); + }); + + it('should accept about://client referrer as client', () => { + const req = new Request('http://example.com', {referrer: 'about://client'}); + expect(req.referrer).to.equal('about:client'); + }); + + it('should accept a string URL referrer', () => { + const referrer = 'http://example.com/'; + const req = new Request('http://example.com', {referrer}); + expect(req.referrer).to.equal(referrer); + }); + + it('should accept a URL referrer', () => { + const referrer = new URL('http://example.com'); + const req = new Request('http://example.com', {referrer}); + expect(req.referrer).to.equal(referrer.toString()); + }); + + it('should accept a referrer from input', () => { + const referrer = 'http://example.com/'; + const req = new Request(new Request('http://example.com', {referrer})); + expect(req.referrer).to.equal(referrer.toString()); + }); + + it('should throw a TypeError for an invalid URL', () => { + expect(() => { + const req = new Request('http://example.com', {referrer: 'foobar'}); + expect.fail(req); + }).to.throw(TypeError, 'Invalid URL: foobar'); + }); + }); + + describe('referrerPolicy', () => { + it('should default refererPolicy to empty string', () => { + const req = new Request('http://example.com'); + expect(req.referrerPolicy).to.equal(''); + }); + + it('should accept refererPolicy', () => { + const referrerPolicy = 'unsafe-url'; + const req = new Request('http://example.com', {referrerPolicy}); + expect(req.referrerPolicy).to.equal(referrerPolicy); + }); + + it('should accept referrerPolicy from input', () => { + const referrerPolicy = 'unsafe-url'; + const req = new Request(new Request('http://example.com', {referrerPolicy})); + expect(req.referrerPolicy).to.equal(referrerPolicy); + }); + + it('should throw a TypeError for an invalid referrerPolicy', () => { + expect(() => { + const req = new Request('http://example.com', {referrerPolicy: 'foobar'}); + expect.fail(req); + }).to.throw(TypeError, 'Invalid referrerPolicy: foobar'); + }); + }); +}); + +describe('utils/referrer', () => { + it('default policy should be strict-origin-when-cross-origin', () => { + expect(DEFAULT_REFERRER_POLICY).to.equal('strict-origin-when-cross-origin'); + }); + + describe('stripURLForUseAsAReferrer', () => { + it('should return no-referrer for null/undefined URL', () => { + expect(stripURLForUseAsAReferrer(undefined)).to.equal('no-referrer'); + expect(stripURLForUseAsAReferrer(null)).to.equal('no-referrer'); + }); + + it('should return no-referrer for about:, blob:, and data: URLs', () => { + expect(stripURLForUseAsAReferrer('about:client')).to.equal('no-referrer'); + expect(stripURLForUseAsAReferrer('blob:theblog')).to.equal('no-referrer'); + expect(stripURLForUseAsAReferrer('data:,thedata')).to.equal('no-referrer'); + }); + + it('should strip the username, password, and hash', () => { + const urlStr = 'http://foo:bar@example.com/foo?q=search#theanchor'; + expect(stripURLForUseAsAReferrer(urlStr).toString()) + .to.equal('http://example.com/foo?q=search'); + }); + + it('should strip the pathname and query when origin-only', () => { + const urlStr = 'http://foo:bar@example.com/foo?q=search#theanchor'; + expect(stripURLForUseAsAReferrer(urlStr, true).toString()) + .to.equal('http://example.com/'); + }); + }); + + describe('validateReferrerPolicy', () => { + it('should return the referrer policy', () => { + for (const referrerPolicy of ReferrerPolicy) { + expect(validateReferrerPolicy(referrerPolicy)).to.equal(referrerPolicy); + } + }); + + it('should throw a TypeError for invalid referrer policies', () => { + expect(validateReferrerPolicy.bind(null, undefined)) + .to.throw(TypeError, 'Invalid referrerPolicy: undefined'); + expect(validateReferrerPolicy.bind(null, null)) + .to.throw(TypeError, 'Invalid referrerPolicy: null'); + expect(validateReferrerPolicy.bind(null, false)) + .to.throw(TypeError, 'Invalid referrerPolicy: false'); + expect(validateReferrerPolicy.bind(null, 0)) + .to.throw(TypeError, 'Invalid referrerPolicy: 0'); + expect(validateReferrerPolicy.bind(null, 'always')) + .to.throw(TypeError, 'Invalid referrerPolicy: always'); + }); + }); + + const testIsOriginPotentiallyTrustworthyStatements = func => { + it('should be potentially trustworthy for HTTPS and WSS URLs', () => { + expect(func(new URL('https://example.com'))).to.be.true; + expect(func(new URL('wss://example.com'))).to.be.true; + }); + + it('should be potentially trustworthy for loopback IP address URLs', () => { + expect(func(new URL('http://127.0.0.1'))).to.be.true; + expect(func(new URL('http://127.1.2.3'))).to.be.true; + expect(func(new URL('ws://[::1]'))).to.be.true; + }); + + it('should not be potentially trustworthy for "localhost" URLs', () => { + expect(func(new URL('http://localhost'))).to.be.false; + }); + + it('should be potentially trustworthy for file: URLs', () => { + expect(func(new URL('file://foo/bar'))).to.be.true; + }); + + it('should not be potentially trustworthy for all other origins', () => { + expect(func(new URL('http://example.com'))).to.be.false; + expect(func(new URL('ws://example.com'))).to.be.false; + }); + }; + + describe('isOriginPotentiallyTrustworthy', () => { + testIsOriginPotentiallyTrustworthyStatements(isOriginPotentiallyTrustworthy); + }); + + describe('isUrlPotentiallyTrustworthy', () => { + it('should be potentially trustworthy for about:blank and about:srcdoc', () => { + expect(isUrlPotentiallyTrustworthy(new URL('about:blank'))).to.be.true; + expect(isUrlPotentiallyTrustworthy(new URL('about:srcdoc'))).to.be.true; + }); + + it('should be potentially trustworthy for data: URLs', () => { + expect(isUrlPotentiallyTrustworthy(new URL('data:,thedata'))).to.be.true; + }); + + it('should be potentially trustworthy for blob: and filesystem: URLs', () => { + expect(isUrlPotentiallyTrustworthy(new URL('blob:theblob'))).to.be.true; + expect(isUrlPotentiallyTrustworthy(new URL('filesystem:thefilesystem'))).to.be.true; + }); + + testIsOriginPotentiallyTrustworthyStatements(isUrlPotentiallyTrustworthy); + }); + + describe('determineRequestsReferrer', () => { + it('should return null for no-referrer or empty referrerPolicy', () => { + expect(determineRequestsReferrer({referrer: 'no-referrer'})).to.be.null; + expect(determineRequestsReferrer({referrerPolicy: ''})).to.be.null; + }); + + it('should return no-referrer for about:client', () => { + expect(determineRequestsReferrer({ + referrer: 'about:client', + referrerPolicy: DEFAULT_REFERRER_POLICY + })).to.equal('no-referrer'); + }); + + it('should return just the origin for URLs over 4096 characters', () => { + expect(determineRequestsReferrer({ + url: 'http://foo:bar@example.com/foo?q=search#theanchor', + referrer: `http://example.com/${'0'.repeat(4096)}`, + referrerPolicy: DEFAULT_REFERRER_POLICY + }).toString()).to.equal('http://example.com/'); + }); + + it('should alter the referrer URL by callback', () => { + expect(determineRequestsReferrer({ + url: 'http://foo:bar@example.com/foo?q=search#theanchor', + referrer: 'http://foo:bar@example.com/foo?q=search#theanchor', + referrerPolicy: 'unsafe-url' + }, { + referrerURLCallback: referrerURL => { + return new URL(referrerURL.toString().replace(/^http:/, 'myprotocol:')); + } + }).toString()).to.equal('myprotocol://example.com/foo?q=search'); + }); + + it('should alter the referrer origin by callback', () => { + expect(determineRequestsReferrer({ + url: 'http://foo:bar@example.com/foo?q=search#theanchor', + referrer: 'http://foo:bar@example.com/foo?q=search#theanchor', + referrerPolicy: 'origin' + }, { + referrerOriginCallback: referrerOrigin => { + return new URL(referrerOrigin.toString().replace(/^http:/, 'myprotocol:')); + } + }).toString()).to.equal('myprotocol://example.com/'); + }); + + it('should throw a TypeError for an invalid policy', () => { + expect(() => { + determineRequestsReferrer({ + url: 'http://foo:bar@example.com/foo?q=search#theanchor', + referrer: 'http://foo:bar@example.com/foo?q=search#theanchor', + referrerPolicy: 'always' + }); + }).to.throw(TypeError, 'Invalid referrerPolicy: always'); + }); + + const referrerPolicyTestLabel = ({currentURLTrust, referrerURLTrust, sameOrigin}) => { + if (currentURLTrust === null && referrerURLTrust === null && sameOrigin === null) { + return 'Always'; + } + + const result = []; + + if (currentURLTrust !== null) { + result.push(`Current URL is ${currentURLTrust ? '' : 'not '}potentially trustworthy`); + } + + if (referrerURLTrust !== null) { + result.push(`Referrer URL is ${referrerURLTrust ? '' : 'not '}potentially trustworthy`); + } + + if (sameOrigin !== null) { + result.push(`Current URL & Referrer URL do ${sameOrigin ? '' : 'not '}have same origin`); + } + + return result.join(', '); + }; + + const referrerPolicyTests = (referrerPolicy, matrix) => { + describe(`Referrer policy: ${referrerPolicy}`, () => { + for (const {currentURLTrust, referrerURLTrust, sameOrigin, result} of matrix) { + describe(referrerPolicyTestLabel({currentURLTrust, referrerURLTrust, sameOrigin}), () => { + const requests = []; + + if (sameOrigin === true || sameOrigin === null) { + requests.push({ + referrerPolicy, + url: 'http://foo:bar@example.com/foo?q=search#theanchor', + referrer: 'http://foo:bar@example.com/foo?q=search#theanchor' + }); + } + + if (sameOrigin === false || sameOrigin === null) { + requests.push({ + referrerPolicy, + url: 'http://foo:bar@example2.com/foo?q=search#theanchor', + referrer: 'http://foo:bar@example.com/foo?q=search#theanchor' + }); + } + + let requestsLength = requests.length; + switch (currentURLTrust) { + case null: + for (let i = 0; i < requestsLength; i++) { + const req = requests[i]; + requests.push({...req, url: req.url.replace(/^http:/, 'https:')}); + } + + break; + + case true: + for (let i = 0; i < requestsLength; i++) { + const req = requests[i]; + req.url = req.url.replace(/^http:/, 'https:'); + } + + break; + + case false: + // nothing to do, default is not potentially trustworthy + break; + + default: + throw new TypeError(`Invalid currentURLTrust condition: ${currentURLTrust}`); + } + + requestsLength = requests.length; + switch (referrerURLTrust) { + case null: + for (let i = 0; i < requestsLength; i++) { + const req = requests[i]; + + if (sameOrigin) { + if (req.url.startsWith('https:')) { + requests.splice(i, 1); + } else { + continue; + } + } + + requests.push({...req, referrer: req.referrer.replace(/^http:/, 'https:')}); + } + + break; + + case true: + for (let i = 0; i < requestsLength; i++) { + const req = requests[i]; + req.referrer = req.referrer.replace(/^http:/, 'https:'); + } + + break; + + case false: + // nothing to do, default is not potentially trustworthy + break; + + default: + throw new TypeError(`Invalid referrerURLTrust condition: ${referrerURLTrust}`); + } + + it('should have tests', () => { + expect(requests).to.not.be.empty; + }); + + for (const req of requests) { + it(`should return ${result} for url: ${req.url}, referrer: ${req.referrer}`, () => { + if (result === 'no-referrer') { + return expect(determineRequestsReferrer(req).toString()) + .to.equal('no-referrer'); + } + + if (result === 'referrer-origin') { + const referrerOrigih = stripURLForUseAsAReferrer(req.referrer, true); + return expect(determineRequestsReferrer(req).toString()) + .to.equal(referrerOrigih.toString()); + } + + if (result === 'referrer-url') { + const referrerURL = stripURLForUseAsAReferrer(req.referrer); + return expect(determineRequestsReferrer(req).toString()) + .to.equal(referrerURL.toString()); + } + + throw new TypeError(`Invalid result: ${result}`); + }); + } + }); + } + }); + }; + + // 3.1 no-referrer + referrerPolicyTests('no-referrer', [ + {currentURLTrust: null, referrerURLTrust: null, sameOrigin: null, result: 'no-referrer'} + ]); + + // 3.2 no-referrer-when-downgrade + referrerPolicyTests('no-referrer-when-downgrade', [ + {currentURLTrust: false, referrerURLTrust: true, sameOrigin: null, result: 'no-referrer'}, + {currentURLTrust: null, referrerURLTrust: false, sameOrigin: null, result: 'referrer-url'}, + {currentURLTrust: true, referrerURLTrust: true, sameOrigin: null, result: 'referrer-url'} + ]); + + // 3.3 same-origin + referrerPolicyTests('same-origin', [ + {currentURLTrust: null, referrerURLTrust: null, sameOrigin: false, result: 'no-referrer'}, + {currentURLTrust: null, referrerURLTrust: null, sameOrigin: true, result: 'referrer-url'} + ]); + + // 3.4 origin + referrerPolicyTests('origin', [ + {currentURLTrust: null, referrerURLTrust: null, sameOrigin: null, result: 'referrer-origin'} + ]); + + // 3.5 strict-origin + referrerPolicyTests('strict-origin', [ + {currentURLTrust: false, referrerURLTrust: true, sameOrigin: null, result: 'no-referrer'}, + {currentURLTrust: null, referrerURLTrust: false, sameOrigin: null, result: 'referrer-origin'}, + {currentURLTrust: true, referrerURLTrust: true, sameOrigin: null, result: 'referrer-origin'} + ]); + + // 3.6 origin-when-cross-origin + referrerPolicyTests('origin-when-cross-origin', [ + {currentURLTrust: null, referrerURLTrust: null, sameOrigin: false, result: 'referrer-origin'}, + {currentURLTrust: null, referrerURLTrust: null, sameOrigin: true, result: 'referrer-url'} + ]); + + // 3.7 strict-origin-when-cross-origin + referrerPolicyTests('strict-origin-when-cross-origin', [ + {currentURLTrust: false, referrerURLTrust: true, sameOrigin: false, result: 'no-referrer'}, + {currentURLTrust: null, referrerURLTrust: false, sameOrigin: false, + result: 'referrer-origin'}, + {currentURLTrust: true, referrerURLTrust: true, sameOrigin: false, result: 'referrer-origin'}, + {currentURLTrust: null, referrerURLTrust: null, sameOrigin: true, result: 'referrer-url'} + ]); + + // 3.8 unsafe-url + referrerPolicyTests('unsafe-url', [ + {currentURLTrust: null, referrerURLTrust: null, sameOrigin: null, result: 'referrer-url'} + ]); + }); + + describe('parseReferrerPolicyFromHeader', () => { + it('should return an empty string when no referrer policy is found', () => { + expect(parseReferrerPolicyFromHeader(new Headers())).to.equal(''); + expect(parseReferrerPolicyFromHeader( + new Headers([['Referrer-Policy', '']]) + )).to.equal(''); + }); + + it('should return the last valid referrer policy', () => { + expect(parseReferrerPolicyFromHeader( + new Headers([['Referrer-Policy', 'no-referrer']]) + )).to.equal('no-referrer'); + expect(parseReferrerPolicyFromHeader( + new Headers([['Referrer-Policy', 'no-referrer unsafe-url']]) + )).to.equal('unsafe-url'); + expect(parseReferrerPolicyFromHeader( + new Headers([['Referrer-Policy', 'foo no-referrer bar']]) + )).to.equal('no-referrer'); + expect(parseReferrerPolicyFromHeader( + new Headers([['Referrer-Policy', 'foo no-referrer unsafe-url bar']]) + )).to.equal('unsafe-url'); + }); + + it('should use all Referrer-Policy headers', () => { + expect(parseReferrerPolicyFromHeader( + new Headers([ + ['Referrer-Policy', 'no-referrer'], + ['Referrer-Policy', ''] + ]) + )).to.equal('no-referrer'); + expect(parseReferrerPolicyFromHeader( + new Headers([ + ['Referrer-Policy', 'no-referrer'], + ['Referrer-Policy', 'unsafe-url'] + ]) + )).to.equal('unsafe-url'); + expect(parseReferrerPolicyFromHeader( + new Headers([ + ['Referrer-Policy', 'no-referrer foo'], + ['Referrer-Policy', 'bar unsafe-url wow'] + ]) + )).to.equal('unsafe-url'); + expect(parseReferrerPolicyFromHeader( + new Headers([ + ['Referrer-Policy', 'no-referrer unsafe-url'], + ['Referrer-Policy', 'foo bar'] + ]) + )).to.equal('unsafe-url'); + }); + }); +}); diff --git a/test/utils/server.js b/test/utils/server.js index 329a480d7..2a1e8e9b0 100644 --- a/test/utils/server.js +++ b/test/utils/server.js @@ -301,6 +301,20 @@ export default class TestServer { res.socket.end('\r\n'); } + if (p === '/redirect/referrer-policy') { + res.statusCode = 301; + res.setHeader('Location', '/inspect'); + res.setHeader('Referrer-Policy', 'foo unsafe-url bar'); + res.end(); + } + + if (p === '/redirect/referrer-policy/same-origin') { + res.statusCode = 301; + res.setHeader('Location', '/inspect'); + res.setHeader('Referrer-Policy', 'foo unsafe-url same-origin bar'); + res.end(); + } + if (p === '/redirect/chunked') { res.writeHead(301, { Location: '/inspect',