VYPR
High severity8.8GHSA Advisory· Published Jun 15, 2026· Updated Jun 15, 2026

@angular/platform-server: URL Parser Differential leading to SSRF Allowlist Bypass

CVE-2026-50168

Description

Parser differential in @angular/platform-server allows bypass of host allowlist via malformed URLs, enabling SSRF and response injection.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Parser differential in @angular/platform-server allows bypass of host allowlist via malformed URLs, enabling SSRF and response injection.

Vulnerability

The vulnerability resides in the @angular/platform-server package used for server-side rendering (SSR). A parser differential exists between Node's strict WHATWG URL parser (used for allowedHosts allowlist validation) and the lenient Domino URL parser (used to initialize the server-side emulated DOM). When a malformed URL with a double port structure (e.g., http://evil.com:80:80/path) is received, URL.canParse(url) returns false and skips host validation entirely. However, Domino's internal parser accepts the same malformed URL and resolves the origin to http://evil.com:80. The SSR HTTP interceptor (relativeUrlsTransformerInterceptorFn) then resolves all relative backend requests against this hijacked origin. All versions of @angular/platform-server prior to a fix are affected, provided the application enables SSR and configures host routing allowlists (allowedHosts) [1][2].

Exploitation

An attacker sends an HTTP request with a malformed Host header (e.g., Host: evil.com:80:80) or an absolute-form request URI to the vulnerable SSR server. Node's URL.canParse() rejects the malformed host, so the allowlist check (allowedHosts) is bypassed. The request is then parsed by Domino's lenient parser, which adopts http://evil.com:80 as the document origin. Consequently, all subsequent relative HttpClient API requests from the SSR process—which may carry sensitive credentials, session cookies, and internal authorization tokens—are routed to the attacker‑controlled server instead of the legitimate backend. Additionally, the attacker can supply custom payloads that are injected into the emulated DOM, leading to response injection and content poisoning in the rendered HTML served to end users [1][2].

Impact

Successful exploitation results in server‑side request forgery (SSRF) and response injection. The attacker can exfiltrate sensitive data from internal API calls, including authentication tokens and session cookies. Furthermore, the ability to poison the rendered HTML allows the attacker to inject malicious content into responses served to users, potentially leading to cross‑site scripting (XSS) or other client‑side attacks. The attacker requires no authentication and can trigger the exploit remotely over the network. The severity is high, as it compromises confidentiality and integrity of SSR‑generated content and may expose internal services [1][2].

Mitigation

As of the publication date (2026‑06‑15), no official fixed version has been released. A fix is proposed in pull request #68928 on the Angular repository, which normalizes URL parsing and rejects malformed absolute URLs that bypass the allowlist. The PR is targeted for the next patch release but has not yet been merged [3]. Until a patched version is available, administrators may consider temporarily disabling SSR or removing the allowedHosts configuration (if practical), although this may not fully mitigate the risk. The vulnerability is not listed in the CISA Known Exploited Vulnerabilities (KEV) catalog at this time. Monitor the Angular advisory for updates [1][2][3].

AI Insight generated on Jun 15, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

1

Patches

1
1307ff355c99

fix(platform-server): secure location and document initialization against SSRF and path hijack

https://github.com/angular/angularAlan AgiusMay 27, 2026via body-scan-shorthand
7 files changed · +240 43
  • packages/platform-server/src/location.ts+1 18 modified
    @@ -17,24 +17,7 @@ import {inject, Injectable, ɵWritable as Writable} from '@angular/core';
     import {Subject} from 'rxjs';
     
     import {INITIAL_CONFIG} from './tokens';
    -
    -/**
    - * 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.
    - */
    -export function parseUrl(urlStr: string, origin: string): URL {
    -  if (URL.canParse(urlStr)) {
    -    return new URL(urlStr);
    -  }
    -
    -  if (urlStr && urlStr[0] !== '/') {
    -    urlStr = `/${urlStr}`;
    -  }
    -
    -  return new URL(origin + urlStr);
    -}
    +import {parseUrl} from './url';
     
     /**
      * Server-side implementation of URL state. Implements `pathname`, `search`, and `hash`
    
  • packages/platform-server/src/server.ts+5 1 modified
    @@ -37,6 +37,7 @@ import {
     
     import {DominoAdapter, parseDocument} from './domino_adapter';
     import {SERVER_HTTP_PROVIDERS} from './http';
    +import {parseUrl} from './url';
     import {ServerPlatformLocation} from './location';
     import {enableDomEmulation, PlatformState} from './platform_state';
     import {ServerEventManagerPlugin} from './server_events';
    @@ -100,7 +101,10 @@ function _document() {
         document =
           typeof config.document === 'string'
             ? _enableDomEmulation
    -          ? parseDocument(config.document, config.url)
    +          ? parseDocument(
    +              config.document,
    +              config.url !== undefined ? parseUrl(config.url, 'http://localhost').href : undefined,
    +            )
               : window.document
             : config.document;
       } else {
    
  • packages/platform-server/src/url.ts+42 0 added
    @@ -0,0 +1,42 @@
    +/**
    + * @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
    + */
    +
    +const LEADING_SLASHES_REGEX = /^[/\\]+/;
    +
    +/**
    + * Parses a URL string and returns a resolved WHATWG URL object.
    + * If no origin is provided, it parses and returns the URL only if it is a valid absolute URL;
    + * otherwise it returns `null` (or throws if the URL is a malformed absolute URL).
    + * If an origin is provided, relative URLs and protocol-relative URLs are normalized and resolved against it.
    + */
    +export function parseUrl(urlStr: string | undefined): URL | null;
    +export function parseUrl(urlStr: string | undefined, origin: string): URL;
    +export function parseUrl(urlStr: string | undefined, origin?: string): URL | null {
    +  if (!urlStr) {
    +    return origin !== undefined ? new URL('/', origin) : null;
    +  }
    +
    +  if (URL.canParse(urlStr)) {
    +    return new URL(urlStr);
    +  }
    +
    +  if (/^[a-zA-Z][a-zA-Z0-9+.-]*:(\/\/|\\\\)/.test(urlStr)) {
    +    throw new Error(`Invalid URL: ${urlStr}`);
    +  }
    +
    +  if (origin === undefined) {
    +    return null;
    +  }
    +
    +  let normalizedPath = urlStr.replace(LEADING_SLASHES_REGEX, '/');
    +  if (normalizedPath[0] !== '/') {
    +    normalizedPath = `/${normalizedPath}`;
    +  }
    +
    +  return new URL(normalizedPath, origin);
    +}
    
  • packages/platform-server/src/utils.ts+9 5 modified
    @@ -29,6 +29,7 @@ import {platformServer} from './server';
     import {PlatformState} from './platform_state';
     import {BEFORE_APP_SERIALIZED, INITIAL_CONFIG, PlatformConfig} from './tokens';
     import {createScript} from './transfer_state';
    +import {parseUrl} from './url';
     
     /**
      * Event dispatch (JSAction) script is inlined into the HTML by the build
    @@ -377,11 +378,14 @@ export async function renderApplication(
     }
     
     function validateAllowedHosts(url: string | undefined, allowedHosts: string[] | undefined) {
    -  if (typeof url === 'string' && URL.canParse(url)) {
    -    const hostname = new URL(url).hostname;
    -    const allowedHostsSet: ReadonlySet<string> = new Set(allowedHosts);
    -    if (!isHostAllowed(hostname, allowedHostsSet)) {
    -      throw new Error(`Host ${url} is not allowed. You can configure \`allowedHosts\` option.`);
    +  if (typeof url === 'string') {
    +    const parsedUrl = parseUrl(url);
    +    if (parsedUrl !== null) {
    +      const hostname = parsedUrl.hostname;
    +      const allowedHostsSet: ReadonlySet<string> = new Set(allowedHosts);
    +      if (!isHostAllowed(hostname, allowedHostsSet)) {
    +        throw new Error(`Host ${url} is not allowed. You can configure \`allowedHosts\` option.`);
    +      }
         }
       }
     }
    
  • packages/platform-server/test/platform_location_spec.ts+67 19 modified
    @@ -7,30 +7,13 @@
      */
     import '@angular/compiler';
     
    -import {PlatformLocation, ɵgetDOM as getDOM} from '@angular/common';
    +import {DOCUMENT, PlatformLocation, ɵgetDOM as getDOM} from '@angular/common';
     import {destroyPlatform} from '@angular/core';
     import {INITIAL_CONFIG, platformServer} from '@angular/platform-server';
     
    -import {parseUrl} from '../src/location';
    -
     (function () {
       if (getDOM().supportsDOMEvents) return; // NODE only
     
    -  describe('parseUrl', () => {
    -    it('should resolve relative paths against origin', () => {
    -      const url = parseUrl('/deep/path?query#hash', 'http://test.com');
    -      expect(url.href).toBe('http://test.com/deep/path?query#hash');
    -      expect(url.search).toBe('?query');
    -      expect(url.hash).toBe('#hash');
    -    });
    -
    -    it('should resolve absolute URLs ignoring origin', () => {
    -      const url = parseUrl('http://other.com/deep/path', 'http://test.com');
    -      expect(url.href).toBe('http://other.com/deep/path');
    -      expect(url.origin).toBe('http://other.com');
    -    });
    -  });
    -
       describe('PlatformLocation', () => {
         beforeEach(() => {
           destroyPlatform();
    @@ -173,7 +156,72 @@ import {parseUrl} from '../src/location';
             platform.destroy();
     
             expect(location.hostname).withContext(`hostname for URL: "${url}"`).toBe('');
    -        expect(location.pathname).withContext(`pathname for URL: "${url}"`).toBe(url);
    +        expect(location.pathname)
    +          .withContext(`pathname for URL: "${url}"`)
    +          .toBe('/attacker.com/deep/path');
    +      }
    +    });
    +
    +    it('should set the proper document location when the URL has leading slashes to prevent origin hijack', async () => {
    +      const urls = ['/\\attacker.com/deep/path', '//attacker.com/deep/path'];
    +
    +      for (const url of urls) {
    +        const platform = platformServer([
    +          {
    +            provide: INITIAL_CONFIG,
    +            useValue: {
    +              document: '<html><head></head><body></body></html>',
    +              url,
    +            },
    +          },
    +        ]);
    +
    +        const doc = platform.injector.get(DOCUMENT);
    +        platform.destroy();
    +
    +        expect(doc.location.origin).not.toBe('http://attacker.com');
    +        expect(doc.location.pathname).toBe('/attacker.com/deep/path');
    +      }
    +    });
    +
    +    it('should not expose protocol-relative URLs on the location to prevent open redirect and SSRF bypasses', async () => {
    +      const urls = ['/\\attacker.com/deep/path', '//attacker.com/deep/path'];
    +      const origins = [undefined, 'http://localhost:4200'];
    +
    +      for (const url of urls) {
    +        for (const origin of origins) {
    +          const providers: any[] = [
    +            {
    +              provide: INITIAL_CONFIG,
    +              useValue: {
    +                document: '',
    +                url,
    +              },
    +            },
    +          ];
    +
    +          if (origin) {
    +            providers.push({
    +              provide: DOCUMENT,
    +              useValue: {
    +                location: {
    +                  origin,
    +                },
    +              },
    +            });
    +          }
    +
    +          const platform = platformServer(providers);
    +          const location = platform.injector.get(PlatformLocation) as any;
    +          platform.destroy();
    +
    +          // A relative redirect URL starting with // or /\ is normalized by browsers to a protocol-relative URL.
    +          // The PlatformLocation.url property MUST NOT expose these unsafe patterns.
    +          const isVulnerable = location.url.startsWith('//') || location.url.startsWith('/\\');
    +          expect(isVulnerable)
    +            .withContext(`URL: "${url}", origin: "${origin}", location.url: "${location.url}"`)
    +            .toBeFalse();
    +        }
           }
         });
       });
    
  • packages/platform-server/test/url_spec.ts+72 0 added
    @@ -0,0 +1,72 @@
    +/**
    + * @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 {parseUrl} from '../src/url';
    +
    +describe('parseUrl', () => {
    +  describe('with origin', () => {
    +    it('should resolve relative paths against origin', () => {
    +      const url = parseUrl('/deep/path?query#hash', 'http://test.com');
    +      expect(url.href).toBe('http://test.com/deep/path?query#hash');
    +      expect(url.search).toBe('?query');
    +      expect(url.hash).toBe('#hash');
    +    });
    +
    +    it('should resolve absolute URLs ignoring origin', () => {
    +      const url = parseUrl('http://other.com/deep/path', 'http://test.com');
    +      expect(url.href).toBe('http://other.com/deep/path');
    +      expect(url.origin).toBe('http://other.com');
    +    });
    +
    +    it('should throw an error for malformed absolute URLs', () => {
    +      const malformedUrls = [
    +        'http://evil.com:80:80/path',
    +        'https://evil.com:80:80/path',
    +        'http://[google.com]/path',
    +        'http://google.com:port/path',
    +        'http://google.com:80a/path',
    +      ];
    +
    +      for (const url of malformedUrls) {
    +        expect(() => parseUrl(url, 'http://test.com')).toThrowError(
    +          new RegExp(`Invalid URL: ${url.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`),
    +        );
    +      }
    +    });
    +  });
    +
    +  describe('without origin', () => {
    +    it('should return null for relative paths', () => {
    +      expect(parseUrl('/deep/path?query#hash')).toBeNull();
    +      expect(parseUrl('deep/path')).toBeNull();
    +    });
    +
    +    it('should parse valid absolute URLs', () => {
    +      const url = parseUrl('http://other.com/deep/path');
    +      expect(url).not.toBeNull();
    +      expect(url!.href).toBe('http://other.com/deep/path');
    +      expect(url!.origin).toBe('http://other.com');
    +    });
    +
    +    it('should throw an error for malformed absolute URLs', () => {
    +      const malformedUrls = [
    +        'http://evil.com:80:80/path',
    +        'https://evil.com:80:80/path',
    +        'http://[google.com]/path',
    +        'http://google.com:port/path',
    +        'http://google.com:80a/path',
    +      ];
    +
    +      for (const url of malformedUrls) {
    +        expect(() => parseUrl(url)).toThrowError(
    +          new RegExp(`Invalid URL: ${url.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`),
    +        );
    +      }
    +    });
    +  });
    +});
    
  • packages/platform-server/test/utils_spec.ts+44 0 modified
    @@ -60,6 +60,28 @@ describe('allowedHosts validation in renderApplication', () => {
           expect(error.message).not.toContain('is not allowed');
         }
       });
    +
    +  it('should throw an error for malformed absolute URLs (SSRF bypass attempt)', async () => {
    +    const malformedUrls = [
    +      'http://evil.com:80:80/path',
    +      'https://evil.com:80:80/path',
    +      'http://[google.com]/path',
    +      'http://google.com:port/path',
    +      'http://google.com:80a/path',
    +    ];
    +
    +    for (const url of malformedUrls) {
    +      await expectAsync(
    +        renderApplication(bootstrap, {
    +          document: '<app></app>',
    +          url,
    +          allowedHosts: ['test.com'],
    +        }),
    +      )
    +        .withContext(`URL: ${url}`)
    +        .toBeRejectedWithError(new RegExp(/Invalid URL:.+/));
    +    }
    +  });
     });
     
     describe('allowedHosts validation in renderModule', () => {
    @@ -94,4 +116,26 @@ describe('allowedHosts validation in renderModule', () => {
           expect(error.message).not.toContain('is not allowed');
         }
       });
    +
    +  it('should throw an error for malformed absolute URLs (SSRF bypass attempt)', async () => {
    +    const malformedUrls = [
    +      'http://evil.com:80:80/path',
    +      'https://evil.com:80:80/path',
    +      'http://[google.com]/path',
    +      'http://google.com:port/path',
    +      'http://google.com:80a/path',
    +    ];
    +
    +    for (const url of malformedUrls) {
    +      await expectAsync(
    +        renderModule(MockModule, {
    +          document: '<app></app>',
    +          url,
    +          allowedHosts: ['test.com'],
    +        }),
    +      )
    +        .withContext(`URL: ${url}`)
    +        .toBeRejectedWithError(new RegExp(/Invalid URL:.+/));
    +    }
    +  });
     });
    

Vulnerability mechanics

Synthesis attempt was rejected by the grounding validator. Re-run pending.

References

3

News mentions

0

No linked articles in our index yet.