diff --git a/src/app/js/router.js b/src/app/js/router.js index 27d807acf3f..abb6bf11cb5 100644 --- a/src/app/js/router.js +++ b/src/app/js/router.js @@ -6,12 +6,12 @@ Provides URL-based routing using HTML5 `pushState()` or the location hash. **/ var HistoryHash = Y.HistoryHash, - Lang = Y.Lang, QS = Y.QueryString, YArray = Y.Array, win = Y.config.win, location = win.location, + origin = location.origin || (location.protocol + '//' + location.host), // We have to queue up pushState calls to avoid race conditions, since the // popstate event doesn't actually provide any info on what URL it's @@ -109,7 +109,7 @@ Y.Router = Y.extend(Router, Y.Base, { @type RegExp @protected **/ - _regexPathParam: /([:*])([\w-]+)/g, + _regexPathParam: /([:*])([\w\-]+)/g, /** Regex that matches and captures the query portion of a URL, minus the @@ -122,15 +122,15 @@ Y.Router = Y.extend(Router, Y.Base, { _regexUrlQuery: /\?([^#]*).*$/, /** - Regex that matches everything before the path portion of an HTTP or HTTPS - URL. This will be used to strip this part of the URL from a string when we + Regex that matches everything before the path portion of a URL (the origin). + This will be used to strip this part of the URL from a string when we only want the path. - @property _regexUrlStrip + @property _regexUrlOrigin @type RegExp @protected **/ - _regexUrlStrip: /^https?:\/\/[^\/]*/i, + _regexUrlOrigin: /^(?:[^\/#?:]+:\/\/|\/\/)[^\/]*/, // -- Lifecycle Methods ---------------------------------------------------- initializer: function (config) { @@ -218,12 +218,20 @@ Y.Router = Y.extend(Router, Y.Base, { Returns `true` if this router has at least one route that matches the specified URL, `false` otherwise. + This method enforces the same-origin security constraint on the specified + `url`; any URL which is not from the same origin as the current URL will + always return `false`. + @method hasRoute @param {String} url URL to match. @return {Boolean} `true` if there's at least one matching route, `false` otherwise. **/ hasRoute: function (url) { + if (!this._hasSameOrigin(url)) { + return false; + } + return !!this.match(this.removeRoot(url)).length; }, @@ -273,7 +281,7 @@ Y.Router = Y.extend(Router, Y.Base, { // Strip out the non-path part of the URL, if any (e.g. // "http://foo.com"), so that we're left with just the path. - url = url.replace(this._regexUrlStrip, ''); + url = url.replace(this._regexUrlOrigin, ''); if (root && url.indexOf(root) === 0) { url = url.substring(root.length); @@ -306,9 +314,9 @@ Y.Router = Y.extend(Router, Y.Base, { // New URL: http://example.com/ @method replace - @param {String} [url] URL to set. Should be a relative URL. If this - router's `root` property is set, this URL must be relative to the - root URL. If no URL is specified, the page's current URL will be used. + @param {String} [url] URL to set. This URL needs to be of the same origin as + the current URL. This can be a URL relative to the router's `root` + attribute. If no URL is specified, the page's current URL will be used. @chainable @see save() **/ @@ -422,9 +430,9 @@ Y.Router = Y.extend(Router, Y.Base, { // New URL: http://example.com/ @method save - @param {String} [url] URL to set. Should be a relative URL. If this - router's `root` property is set, this URL must be relative to the - root URL. If no URL is specified, the page's current URL will be used. + @param {String} [url] URL to set. This URL needs to be of the same origin as + the current URL. This can be a URL relative to the router's `root` + attribute. If no URL is specified, the page's current URL will be used. @chainable @see replace() **/ @@ -574,15 +582,29 @@ Y.Router = Y.extend(Router, Y.Base, { }, /** - Gets the current route path. + Gets the location origin (i.e., protocol, host, and port) as a URL. + + @example + http://example.com + + @method _getOrigin + @return {String} Location origin (i.e., protocol, host, and port). + @protected + **/ + _getOrigin: function () { + return origin; + }, + + /** + Gets the current route path, relative to the `root` (if any). @method _getPath @return {String} Current route path. @protected **/ _getPath: function () { - return (!this._html5 && this._getHashPath()) || - this.removeRoot(location.pathname); + var path = (!this._html5 && this._getHashPath()) || location.pathname; + return this.removeRoot(path); }, /** @@ -660,8 +682,8 @@ Y.Router = Y.extend(Router, Y.Base, { @protected **/ _getResponse: function (req) { - // For backcompat, the response object is a function that calls `next()` - // on the request object and returns the result. + // For backwards compatibility, the response object is a function that + // calls `next()` on the request object and returns the result. var res = function () { return req.next.apply(this, arguments); }; @@ -692,16 +714,39 @@ Y.Router = Y.extend(Router, Y.Base, { return location.toString(); }, + /** + Returns `true` when the specified `url` is from the same origin as the + current URL; i.e., the protocol, host, and port of the URLs are the same. + + All host or path relative URLs are of the same origin. A scheme-relative URL + is first prefixed with the current scheme before being evaluated. + + @method _hasSameOrigin + @param {String} url URL to compare origin with the current URL. + @return {Boolean} Whether the URL has the same origin of the current URL. + @protected + **/ + _hasSameOrigin: function (url) { + var origin = ((url && url.match(this._regexUrlOrigin)) || [])[0]; + + // Prepend current scheme to scheme-relative URLs. + if (origin && origin.indexOf('//') === 0) { + origin = location.protocol + origin; + } + + return !origin || origin === this._getOrigin(); + }, + /** Joins the `root` URL to the specified _url_, normalizing leading/trailing `/` characters. @example - router.root = '/foo' + router.set('root', '/foo'); router._joinURL('bar'); // => '/foo/bar' router._joinURL('/bar'); // => '/foo/bar' - router.root = '/foo/' + router.set('root', '/foo/'); router._joinURL('bar'); // => '/foo/bar' router._joinURL('/bar'); // => '/foo/bar' @@ -800,6 +845,10 @@ Y.Router = Y.extend(Router, Y.Base, { /** Saves a history entry using either `pushState()` or the location hash. + This method enforces the same-origin security constraint; attempting to save + a `url` that is not from the same origin as the current URL will result in + an error. + @method _save @param {String} [url] URL for the history entry. @param {Boolean} [replace=false] If `true`, the current history entry will @@ -810,6 +859,12 @@ Y.Router = Y.extend(Router, Y.Base, { _save: function (url, replace) { var urlIsString = typeof url === 'string'; + // Perform same-origin check on the specified URL. + if (urlIsString && !this._hasSameOrigin(url)) { + Y.error('Security error: The new URL must be of the same origin as the current URL.'); + return this; + } + // Force _ready to true to ensure that the history change is handled // even if _save is called before the `ready` event fires. this._ready = true; diff --git a/src/app/tests/app-test.js b/src/app/tests/app-test.js index 5ccc06a883c..b995b0d2573 100644 --- a/src/app/tests/app-test.js +++ b/src/app/tests/app-test.js @@ -6,6 +6,8 @@ var ArrayAssert = Y.ArrayAssert, html5 = Y.Router.html5, + win = Y.config.win, + routerSuite, modelSuite, modelListSuite, @@ -1808,9 +1810,20 @@ routerSuite.add(new Y.Test.Case({ routerSuite.add(new Y.Test.Case({ name: 'Methods', + startUp: function () { + this.errorFn = Y.config.errorFn; + this.throwFail = Y.config.throwFail; + }, + tearDown: function () { this.router && this.router.destroy(); delete this.router; + + Y.config.errorFn = this.errorFn; + delete this.errorFn; + + Y.config.throwFail = this.throwFail; + delete this.throwFail; }, 'route() should add a route': function () { @@ -1835,7 +1848,7 @@ routerSuite.add(new Y.Test.Case({ var router = this.router = new Y.Router(), routes; - function one () {} + function one() {} function two() {} function three() {} @@ -1851,8 +1864,7 @@ routerSuite.add(new Y.Test.Case({ }, 'hasRoute() should return `true` if one or more routes match the given path': function () { - var router = this.router = new Y.Router(), - routes; + var router = this.router = new Y.Router(); function noop () {} @@ -1867,7 +1879,8 @@ routerSuite.add(new Y.Test.Case({ 'hasRoute() should support full URLs': function () { var router = this.router = new Y.Router(), - routes; + loc = win && win.location, + origin = loc ? (loc.origin || (loc.protocol + '//' + loc.host)) : ''; function noop () {} @@ -1875,9 +1888,27 @@ routerSuite.add(new Y.Test.Case({ router.route(/foo/, noop); router.route('/bar', noop); - Assert.isTrue(router.hasRoute('http://example.com/foo')); - Assert.isTrue(router.hasRoute('https://example.com/bar')); - Assert.isFalse(router.hasRoute('http://example.com/baz/quux')); + Assert.isTrue(router.hasRoute(origin + '/foo')); + Assert.isTrue(router.hasRoute(origin + '/bar')); + Assert.isFalse(router.hasRoute(origin + '/baz/quux')); + + // Scheme-relative URL. + Assert.isTrue(router.hasRoute('//' + loc.host + '/foo')); + }, + + 'hasRoute() should always return `false` for URLs with different origins': function () { + var router = this.router = new Y.Router(), + origin = 'http://something.really.random.com'; + + function noop () {} + + router.route('/:foo', noop); + router.route(/foo/, noop); + router.route('/bar', noop); + + Assert.isFalse(router.hasRoute(origin + '/foo')); + Assert.isFalse(router.hasRoute(origin + '/bar')); + Assert.isFalse(router.hasRoute(origin + '/baz/quux')); }, 'dispatch() should dispatch to the first route that matches the current URL': function () { @@ -1934,7 +1965,7 @@ routerSuite.add(new Y.Test.Case({ Assert.areSame('/foo/bar', router.removeRoot('/foo/bar')); }, - 'removeRoot() should strip the "http://foo.com" portion of the URL, if any': function () { + 'removeRoot() should strip the origin ("http://foo.com") portion of the URL, if any': function () { var router = this.router = new Y.Router(); Assert.areSame('/foo/bar', router.removeRoot('http://example.com/foo/bar')); @@ -1942,6 +1973,8 @@ routerSuite.add(new Y.Test.Case({ Assert.areSame('/foo/bar', router.removeRoot('http://user:pass@example.com/foo/bar')); Assert.areSame('/foo/bar', router.removeRoot('http://example.com:8080/foo/bar')); Assert.areSame('/foo/bar', router.removeRoot('http://user:pass@example.com:8080/foo/bar')); + Assert.areSame('/foo/bar', router.removeRoot('file:///foo/bar')); + Assert.areSame('/foo/bar', router.removeRoot('/foo/bar')); router.set('root', '/foo'); Assert.areSame('/bar', router.removeRoot('http://example.com/foo/bar')); @@ -1949,10 +1982,12 @@ routerSuite.add(new Y.Test.Case({ Assert.areSame('/bar', router.removeRoot('http://user:pass@example.com/foo/bar')); Assert.areSame('/bar', router.removeRoot('http://example.com:8080/foo/bar')); Assert.areSame('/bar', router.removeRoot('http://user:pass@example.com:8080/foo/bar')); + Assert.areSame('/bar', router.removeRoot('file:///foo/bar')); + Assert.areSame('/bar', router.removeRoot('/foo/bar')); }, 'replace() should replace the current history entry': function () { - var test = this, + var test = this, router = this.router = new Y.Router(); router.route('/replace', function (req) { @@ -1972,7 +2007,7 @@ routerSuite.add(new Y.Test.Case({ }, 'save() should create a new history entry': function () { - var test = this, + var test = this, router = this.router = new Y.Router(); router.route('/save', function (req) { @@ -2023,16 +2058,78 @@ routerSuite.add(new Y.Test.Case({ this.wait(2000); }, - '_joinURL() should normalize / separators': function () { + 'replace() should error when the URL is not from the same origin': function () { + var router = this.router = new Y.Router(), + origin = 'http://something.really.random.com', + test = this; + + // We don't want the uncaught error line noise because we expect an + // error to be thrown, and it won't be caught because `save()` is async. + Y.config.throwFail = false; + Y.config.errorFn = function (e) { + test.resume(function () { + Assert.areSame(e, 'Security error: The new URL must be of the same origin as the current URL.'); + }); + }; + + router.route('/foo', function () { + test.resume(function () { + Assert.fail('Should not route when URL has different origin.'); + }); + }); + + // Wrapped in a setTimeout to make the async test work on iOS<5, which + // performs this action synchronously. + setTimeout(function () { + router.replace(origin + '/foo'); + }, 1); + + this.wait(500); + }, + + 'save() should error when the URL is not from the same origin': function () { + var router = this.router = new Y.Router(), + origin = 'http://something.really.random.com', + test = this; + + // We don't want the uncaught error line noise because we expect an + // error to be thrown, and it won't be caught because `save()` is async. + Y.config.throwFail = false; + Y.config.errorFn = function (e) { + test.resume(function () { + Assert.areSame(e, 'Security error: The new URL must be of the same origin as the current URL.'); + }); + }; + + router.route('/foo', function () { + test.resume(function () { + Assert.fail('Should not route when URL has different origin.'); + }); + }); + + // Wrapped in a setTimeout to make the async test work on iOS<5, which + // performs this action synchronously. + setTimeout(function () { + router.save(origin + '/foo'); + }, 1); + + this.wait(500); + }, + + '_joinURL() should normalize "/" separators': function () { var router = this.router = new Y.Router(); router.set('root', '/foo'); Assert.areSame('/foo/bar', router._joinURL('bar')); Assert.areSame('/foo/bar', router._joinURL('/bar')); + Assert.areSame('/foo/bar', router._joinURL('/foo/bar')); + Assert.areSame('/foo/foo/bar', router._joinURL('foo/bar')); router.set('root', '/foo/'); Assert.areSame('/foo/bar', router._joinURL('bar')); Assert.areSame('/foo/bar', router._joinURL('/bar')); + Assert.areSame('/foo/bar', router._joinURL('/foo/bar')); + Assert.areSame('/foo/foo/bar', router._joinURL('foo/bar')); }, '_dispatch() should pass `src` through to request object passed to route handlers': function () {