diff --git a/src/.eslintrc.json b/src/.eslintrc.json index 6022ac25baa9..dc505098cfed 100644 --- a/src/.eslintrc.json +++ b/src/.eslintrc.json @@ -155,6 +155,7 @@ /* urlUtils.js */ "urlResolve": false, "urlIsSameOrigin": false, + "urlIsSameOriginAsBaseUrl": false, /* ng/controller.js */ "identifierForController": false, diff --git a/src/ng/sce.js b/src/ng/sce.js index acbfcea5cef5..e536bdc4d86b 100644 --- a/src/ng/sce.js +++ b/src/ng/sce.js @@ -227,7 +227,7 @@ function $SceDelegateProvider() { function matchUrl(matcher, parsedUrl) { if (matcher === 'self') { - return urlIsSameOrigin(parsedUrl); + return urlIsSameOrigin(parsedUrl) || urlIsSameOriginAsBaseUrl(parsedUrl); } else { // definitely a regex. See adjustMatchers() return !!matcher.exec(parsedUrl.href); diff --git a/src/ng/urlUtils.js b/src/ng/urlUtils.js index d8231ef078f3..0aa7d35b6fef 100644 --- a/src/ng/urlUtils.js +++ b/src/ng/urlUtils.js @@ -8,6 +8,7 @@ // service. var urlParsingNode = window.document.createElement('a'); var originUrl = urlResolve(window.location.href); +var baseUrlParsingNode; /** @@ -43,16 +44,16 @@ var originUrl = urlResolve(window.location.href); * @description Normalizes and parses a URL. * @returns {object} Returns the normalized URL as a dictionary. * - * | member name | Description | - * |---------------|----------------| + * | member name | Description | + * |---------------|------------------------------------------------------------------------| * | href | A normalized version of the provided URL if it was not an absolute URL | - * | protocol | The protocol including the trailing colon | + * | protocol | The protocol without the trailing colon | * | host | The host and port (if the port is non-default) of the normalizedUrl | * | search | The search params, minus the question mark | - * | hash | The hash string, minus the hash symbol - * | hostname | The hostname - * | port | The port, without ":" - * | pathname | The pathname, beginning with "/" + * | hash | The hash string, minus the hash symbol | + * | hostname | The hostname | + * | port | The port, without ":" | + * | pathname | The pathname, beginning with "/" | * */ function urlResolve(url) { @@ -68,7 +69,6 @@ function urlResolve(url) { urlParsingNode.setAttribute('href', href); - // urlParsingNode provides the UrlUtils interface - http://url.spec.whatwg.org/#urlutils return { href: urlParsingNode.href, protocol: urlParsingNode.protocol ? urlParsingNode.protocol.replace(/:$/, '') : '', @@ -91,7 +91,57 @@ function urlResolve(url) { * @returns {boolean} Whether the request is for the same origin as the application document. */ function urlIsSameOrigin(requestUrl) { - var parsed = (isString(requestUrl)) ? urlResolve(requestUrl) : requestUrl; - return (parsed.protocol === originUrl.protocol && - parsed.host === originUrl.host); + return urlsAreSameOrigin(requestUrl, originUrl); +} + +/** + * Parse a request URL and determine whether it is same-origin as the current document base URL. + * + * Note: The base URL is usually the same as the document location (`location.href`) but can + * be overriden by using the `` tag. + * + * @param {string|object} requestUrl The url of the request as a string that will be resolved + * or a parsed URL object. + * @returns {boolean} Whether the URL is same-origin as the document base URL. + */ +function urlIsSameOriginAsBaseUrl(requestUrl) { + return urlsAreSameOrigin(requestUrl, getBaseUrl()); +} + +/** + * Determines if two URLs share the same origin. + * + * @param {string|object} url1 First URL to compare as a string or a normalized URL in the form of + * a dictionary object returned by `urlResolve()`. + * @param {string|object} url2 Second URL to compare as a string or a normalized URL in the form of + * a dictionary object returned by `urlResolve()`. + * @return {boolean} True if both URLs have the same origin, and false otherwise. + */ +function urlsAreSameOrigin(url1, url2) { + url1 = (isString(url1)) ? urlResolve(url1) : url1; + url2 = (isString(url2)) ? urlResolve(url2) : url2; + + return (url1.protocol === url2.protocol && + url1.host === url2.host); +} + +/** + * Returns the current document base URL. + * @return {string} + */ +function getBaseUrl() { + if (window.document.baseURI) { + return window.document.baseURI; + } + + // document.baseURI is available everywhere except IE + if (!baseUrlParsingNode) { + baseUrlParsingNode = window.document.createElement('a'); + baseUrlParsingNode.href = '.'; + + // Work-around for IE bug described in Implementation Notes. The fix in urlResolve() is not + // suitable here because we need to track changes to the base URL. + baseUrlParsingNode = baseUrlParsingNode.cloneNode(false); + } + return baseUrlParsingNode.href; } diff --git a/test/.eslintrc.json b/test/.eslintrc.json index 4ac15d192fa3..62f21300db30 100644 --- a/test/.eslintrc.json +++ b/test/.eslintrc.json @@ -148,6 +148,7 @@ /* urlUtils.js */ "urlResolve": false, "urlIsSameOrigin": false, + "urlIsSameOriginAsBaseUrl": true, /* karma */ "dump": false, diff --git a/test/e2e/fixtures/base-tag/index.html b/test/e2e/fixtures/base-tag/index.html new file mode 100644 index 000000000000..929cc1c18b44 --- /dev/null +++ b/test/e2e/fixtures/base-tag/index.html @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/test/e2e/fixtures/base-tag/script.js b/test/e2e/fixtures/base-tag/script.js new file mode 100644 index 000000000000..79a3d81bdb8c --- /dev/null +++ b/test/e2e/fixtures/base-tag/script.js @@ -0,0 +1,14 @@ +'use strict'; + +angular. + module('test', []). + run(function($sce) { + window.isTrustedUrl = function(url) { + try { + $sce.getTrustedResourceUrl(url); + } catch (e) { + return false; + } + return true; + }; + }); diff --git a/test/e2e/tests/base-tag.spec.js b/test/e2e/tests/base-tag.spec.js new file mode 100644 index 000000000000..5780c4df4c79 --- /dev/null +++ b/test/e2e/tests/base-tag.spec.js @@ -0,0 +1,35 @@ +'use strict'; + +describe('SCE URL policy when base tags are present', function() { + function checkUrl(url, allowed) { + var urlIsTrusted = browser.executeScript('return isTrustedUrl(arguments[0])', url); + expect(urlIsTrusted).toBe(allowed); + } + + beforeAll(function() { + loadFixture('base-tag'); + }); + + it('allows the page URL (location.href)', function() { + checkUrl(browser.getLocationAbsUrl(), true); + }); + + it('blocks off-origin URLs', function() { + checkUrl('http://evil.com', false); + }); + + it('allows relative URLs ("/relative")', function() { + checkUrl('/relative', true); + }); + + it('allows absolute URLs from the base origin', function() { + checkUrl('http://www.example.com/path/to/file.html', true); + }); + + it('tracks changes to the base URL', function() { + browser.executeScript( + 'document.getElementsByTagName("base")[0].href = "http://xxx.example.com/";'); + checkUrl('http://xxx.example.com/path/to/file.html', true); + checkUrl('http://www.example.com/path/to/file.html', false); + }); +}); diff --git a/test/ng/sceSpecs.js b/test/ng/sceSpecs.js index 97f8f338f5cd..0b4d4249685c 100644 --- a/test/ng/sceSpecs.js +++ b/test/ng/sceSpecs.js @@ -464,6 +464,32 @@ describe('SCE', function() { '$sce', 'insecurl', 'Blocked loading resource from url not allowed by $sceDelegate policy. URL: foo'); } )); + + describe('when the document base URL has changed', function() { + var baseElem; + var cfg = {whitelist: ['self'], blacklist: []}; + beforeEach(function() { + baseElem = window.document.createElement('BASE'); + baseElem.setAttribute('href', window.location.protocol + '//foo.example.com/path/'); + window.document.head.appendChild(baseElem); + }); + afterEach(function() { + window.document.head.removeChild(baseElem); + }); + + it('should allow relative URLs', runTest(cfg, function($sce) { + expect($sce.getTrustedResourceUrl('foo')).toEqual('foo'); + })); + + it('should allow absolute URLs', runTest(cfg, function($sce) { + expect($sce.getTrustedResourceUrl('//foo.example.com/bar')).toEqual('//foo.example.com/bar'); + })); + + it('should still block some URLs', runTest(cfg, function($sce) { + expect(function() { $sce.getTrustedResourceUrl('//bad.example.com'); }).toThrowMinErr( + '$sce', 'insecurl', 'Blocked loading resource from url not allowed by $sceDelegate policy. URL: //bad.example.com'); + })); + }); }); it('should have blacklist override the whitelist', runTest( diff --git a/test/ng/urlUtilsSpec.js b/test/ng/urlUtilsSpec.js index c1d24fe645d4..233181c80eb7 100644 --- a/test/ng/urlUtilsSpec.js +++ b/test/ng/urlUtilsSpec.js @@ -23,11 +23,17 @@ describe('urlUtils', function() { }); }); - describe('isSameOrigin', function() { + describe('isSameOrigin and urlIsSameOriginAsBaseUrl', function() { it('should support various combinations of urls - both string and parsed', inject(function($document) { function expectIsSameOrigin(url, expectedValue) { expect(urlIsSameOrigin(url)).toBe(expectedValue); expect(urlIsSameOrigin(urlResolve(url))).toBe(expectedValue); + + // urlIsSameOriginAsBaseUrl() should behave the same as urlIsSameOrigin() by default. + // Behavior when there is a non-default base URL or when the base URL changes dynamically + // is tested in the end-to-end tests in e2e/tests/base-tag.spec.js. + expect(urlIsSameOriginAsBaseUrl(url)).toBe(expectedValue); + expect(urlIsSameOriginAsBaseUrl(urlResolve(url))).toBe(expectedValue); } expectIsSameOrigin('path', true); var origin = urlResolve($document[0].location.href);