diff --git a/packages/platform-server/src/location.ts b/packages/platform-server/src/location.ts index 8c49a0958a85..964a4c3b4c9d 100644 --- a/packages/platform-server/src/location.ts +++ b/packages/platform-server/src/location.ts @@ -18,29 +18,18 @@ import {Subject} from 'rxjs'; import {INITIAL_CONFIG, PlatformConfig} from './tokens'; -const RESOLVE_PROTOCOL = 'resolve:'; - -function parseUrl(urlStr: string): { - hostname: string; - protocol: string; - port: string; - pathname: string; - search: string; - hash: string; -} { - const {hostname, protocol, port, pathname, search, hash} = new URL( - urlStr, - RESOLVE_PROTOCOL + '//', - ); - - return { - hostname, - protocol: protocol === RESOLVE_PROTOCOL ? '' : protocol, - port, - pathname, - search, - hash, - }; +/** + * Parses a URL string and returns a URL object. + * @param urlStr The string to parse. + * @param origin The origin to use for resolving the URL. + * @returns The parsed URL. + */ +function parseUrl(urlStr: string, origin: string): URL { + // If the URL is empty or start with a `/` it is a pathname relative to the origin + // otherwise it's an absolute URL. + const urlToParse = urlStr.length === 0 || urlStr[0] === '/' ? origin + urlStr : urlStr; + + return new URL(urlToParse); } /** @@ -67,14 +56,17 @@ export class ServerPlatformLocation implements PlatformLocation { return; } if (config.url) { - const url = parseUrl(config.url); - this.protocol = url.protocol; - this.hostname = url.hostname; - this.port = url.port; - this.pathname = url.pathname; - this.search = url.search; - this.hash = url.hash; - this.href = _doc.location.href; + const {protocol, hostname, port, pathname, search, hash, href} = parseUrl( + config.url, + this._doc.location.origin, + ); + this.protocol = protocol; + this.hostname = hostname; + this.port = port; + this.pathname = pathname; + this.search = search; + this.hash = hash; + this.href = href; } } @@ -116,10 +108,13 @@ export class ServerPlatformLocation implements PlatformLocation { replaceState(state: any, title: string, newUrl: string): void { const oldUrl = this.url; - const parsedUrl = parseUrl(newUrl); - (this as Writable).pathname = parsedUrl.pathname; - (this as Writable).search = parsedUrl.search; - this.setHash(parsedUrl.hash, oldUrl); + const {pathname, search, hash, href, protocol} = parseUrl(newUrl, this._doc.location.origin); + const writableThis = this as Writable; + writableThis.pathname = pathname; + writableThis.search = search; + writableThis.href = href; + writableThis.protocol = protocol; + this.setHash(hash, oldUrl); } pushState(state: any, title: string, newUrl: string): void { diff --git a/packages/platform-server/test/platform_location_spec.ts b/packages/platform-server/test/platform_location_spec.ts new file mode 100644 index 000000000000..2dae5e9fb064 --- /dev/null +++ b/packages/platform-server/test/platform_location_spec.ts @@ -0,0 +1,163 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ +import '@angular/compiler'; + +import {PlatformLocation, ɵgetDOM as getDOM} from '@angular/common'; +import {destroyPlatform} from '@angular/core'; +import {INITIAL_CONFIG, platformServer} from '@angular/platform-server'; + +(function () { + if (getDOM().supportsDOMEvents) return; // NODE only + + describe('PlatformLocation', () => { + beforeEach(() => { + destroyPlatform(); + }); + + afterEach(() => { + destroyPlatform(); + }); + + it('is injectable', async () => { + const platform = platformServer([ + {provide: INITIAL_CONFIG, useValue: {document: ''}}, + ]); + + const location = platform.injector.get(PlatformLocation); + expect(location.pathname).toBe('/'); + platform.destroy(); + }); + it('is configurable via INITIAL_CONFIG', async () => { + const platform = platformServer([ + { + provide: INITIAL_CONFIG, + useValue: { + document: '', + url: 'http://test.com/deep/path?query#hash', + }, + }, + ]); + + const location = platform.injector.get(PlatformLocation); + expect(location.pathname).toBe('/deep/path'); + expect(location.search).toBe('?query'); + expect(location.hash).toBe('#hash'); + }); + + it('parses component pieces of a URL', async () => { + const platform = platformServer([ + { + provide: INITIAL_CONFIG, + useValue: { + document: '', + url: 'http://test.com:80/deep/path?query#hash', + }, + }, + ]); + + const location = platform.injector.get(PlatformLocation); + expect(location.hostname).toBe('test.com'); + expect(location.protocol).toBe('http:'); + expect(location.port).toBe(''); + expect(location.pathname).toBe('/deep/path'); + expect(location.search).toBe('?query'); + expect(location.hash).toBe('#hash'); + }); + + it('handles empty search and hash portions of the url', async () => { + const platform = platformServer([ + { + provide: INITIAL_CONFIG, + useValue: { + document: '', + url: 'http://test.com/deep/path', + }, + }, + ]); + + const location = platform.injector.get(PlatformLocation); + expect(location.pathname).toBe('/deep/path'); + expect(location.search).toBe(''); + expect(location.hash).toBe(''); + }); + + it('pushState causes the URL to update', async () => { + const platform = platformServer([ + {provide: INITIAL_CONFIG, useValue: {document: ''}}, + ]); + + const location = platform.injector.get(PlatformLocation); + location.pushState(null, 'Test', '/foo#bar'); + expect(location.pathname).toBe('/foo'); + expect(location.hash).toBe('#bar'); + platform.destroy(); + }); + + it('replaceState causes the URL to update', async () => { + const platform = platformServer([ + { + provide: INITIAL_CONFIG, + useValue: { + document: '', + url: 'http://test.com/deep/path?query#hash', + }, + }, + ]); + + const location = platform.injector.get(PlatformLocation); + location.replaceState(null, 'Test', '/foo#bar'); + expect(location.pathname).toBe('/foo'); + expect(location.hash).toBe('#bar'); + expect(location.href).toBe('http://test.com/foo#bar'); + expect(location.protocol).toBe('http:'); + platform.destroy(); + }); + + it('allows subscription to the hash state', (done) => { + const platform = platformServer([ + {provide: INITIAL_CONFIG, useValue: {document: ''}}, + ]); + const location = platform.injector.get(PlatformLocation); + + expect(location.pathname).toBe('/'); + location.onHashChange((e: any) => { + expect(e.type).toBe('hashchange'); + expect(e.oldUrl).toBe('/'); + expect(e.newUrl).toBe('/foo#bar'); + platform.destroy(); + done(); + }); + location.pushState(null, 'Test', '/foo#bar'); + }); + + it('neutralizes hostname hijack attempts', async () => { + const urls = ['/\\attacker.com/deep/path', '//attacker.com/deep/path']; + + for (const url of urls) { + const platform = platformServer([ + { + provide: INITIAL_CONFIG, + useValue: { + document: '', + // This should be treated as relative URL. + // Example: `req.url: '//attacker.com/deep/path'` where request + // to express server is 'http://localhost:4200//attacker.com/deep/path'. + url, + }, + }, + ]); + + const location = platform.injector.get(PlatformLocation); + platform.destroy(); + + expect(location.hostname).withContext(`hostname for URL: "${url}"`).toBe(''); + expect(location.pathname).withContext(`pathname for URL: "${url}"`).toBe(url); + } + }); + }); +})();