Skip to content

Security: Angular CLI persistent XSS via deployUrl in generated index.html #33075

@foxllb

Description

@foxllb

Command

build

Is this a regression?

  • Yes, this behavior used to work in the previous version

The previous version in which this bug was not present was

No response

Description

Report description

Angular CLI persistent XSS via deployUrl in generated index.html

The problem

Please describe the technical details of the vulnerability

1. technical details

The application builder accepts deployUrl as an unrestricted string:

"deployUrl": {
  "type": "string",
  "description": "Customize the base path for the URLs of resources in 'index.html' and component stylesheets. This option is only necessary for specific deployment scenarios, such as with Angular Elements or when utilizing different CDN locations."
}

During index generation, that value is used to build raw HTML attribute values for generated <script> and <link> tags:

let scriptTags: string[] = [];
for (const [src, isModule] of scripts) {
  const attrs = [`src="${generateUrl(src, deployUrl)}"`];

  if (isModule) {
    attrs.push('type="module"');
  } else {
    attrs.push('defer');
  }

  if (crossOrigin !== 'none') {
    attrs.push(`crossorigin="${crossOrigin}"`);
  }

  scriptTags.push(`<script ${attrs.join(' ')}></script>`);
}

let headerLinkTags: string[] = [];
for (const src of stylesheets) {
  const attrs = [`rel="stylesheet"`, `href="${generateUrl(src, deployUrl)}"`];

  if (crossOrigin !== 'none') {
    attrs.push(`crossorigin="${crossOrigin}"`);
  }

  headerLinkTags.push(`<link ${attrs.join(' ')}>`);
}

The URL helper does not escape HTML-special characters. It simply prepends deployUrl to relative asset names:

function generateUrl(value: string, deployUrl: string | undefined): string {
  if (!deployUrl) {
    return value;
  }

  // Skip if root-relative, absolute or protocol relative url
  if (/^((?:\w+:)?\/\/|data:|chrome:|\/)/.test(value)) {
    return value;
  }

  return `${deployUrl}${value}`;
}

Because deployUrl is inserted directly into HTML attributes, a payload containing a quote can break out of the intended src="..." or href="..." context and inject a new script tag into the generated index.html.

For example, a malicious deployUrl like:

x"></script><script>globalThis.__deployUrlXss='1'</script><script src="

produces a generated index.html like:

<html><head><base href="/"><link rel="stylesheet" href="x"></script><script>globalThis.__deployUrlXss='1'</script><script src="styles.css"></head><body><app-root></app-root><script src="x"></script><script>globalThis.__deployUrlXss='1'</script><script src="polyfills.js" type="module"></script><script src="x"></script><script>globalThis.__deployUrlXss='1'</script><script src="main.js" type="module"></script></body></html>

That injected inline script becomes part of the final published page and runs when a browser loads the generated index.html.

2. vulnerability reproduction

The attached PoC uses the current local angular-cli repository code instead
of an external Angular release. It:

  • builds local package tarballs from the current repository
  • creates a disposable Angular browser application that consumes those tarballs
  • runs a normal application build with an attacker-controlled --deploy-url
  • serves the generated browser/ output and opens it in a real browser

The PoC is executed with:

cd ./work/angular-cli/deployurl_index_xss_test
./run-poc.sh

The generated project uses the application builder:

"build": {
  "builder": "@angular-devkit/build-angular:application",
  "options": {
    "outputPath": "dist/deployurl-index-xss",
    "index": "src/index.html",
    "browser": "src/main.ts",
    "polyfills": ["zone.js"],
    "tsConfig": "tsconfig.app.json",
    "assets": [],
    "styles": ["src/styles.css"],
    "scripts": []
  }
}

The PoC payload is:

ATTACK_DEPLOY_URL='x"></script><script>globalThis.__deployUrlXss='\''1'\''</script><script src="'

The trigger is a standard build command:

npx ng build --configuration development --deploy-url "${ATTACK_DEPLOY_URL}"

Observed result:

[poc] Running build with attacker-controlled deployUrl.
Application bundle generation complete.

[poc] Injected HTML evidence:
8:  <link rel="stylesheet" href="x"></script><script>globalThis.__deployUrlXss='1'</script><script src="styles.css"></head>
11:  <script src="x"></script><script>globalThis.__deployUrlXss='1'</script><script src="polyfills.js" type="module"></script><script src="x"></script><script>globalThis.__deployUrlXss='1'</script><script src="main.js" type="module"></script></body>

The PoC then serves the generated dist/.../browser directory and opens it in a real browser. Observed result:

[poc] Browser visited http://127.0.0.1:37345/
[poc] Injected script executed. globalThis.__deployUrlXss = 1

This PoC intentionally does not include a control case. It demonstrates the
realistic impact path only: a successful browser build that writes attacker-
controlled script into the published index.html, and a browser visit that
executes that script.

This demonstrates that attacker-controlled HTML/JavaScript from deployUrl is written into the generated index.html and executes in the browser when the built page is visited.

3. patch

diff --git a/packages/angular/build/src/utils/index-file/augment-index-html.ts b/packages/angular/build/src/utils/index-file/augment-index-html.ts
index e0f4a8d6d8f62807f6ef8775f0a8d1f4a7d6c2b1..5a2b2d9abf1c6c87d95bb7b742c6c18a4809a0de 100644
--- a/packages/angular/build/src/utils/index-file/augment-index-html.ts
+++ b/packages/angular/build/src/utils/index-file/augment-index-html.ts
@@ -293,17 +293,29 @@ function generateSriAttributes(content: string): string {
   return `integrity="${algo}-${hash}"`;
 }
 
+const HTML_ATTRIBUTE_ENTITIES: Record<string, string> = {
+  '&': '&amp;',
+  '"': '&quot;',
+  "'": '&#39;',
+  '<': '&lt;',
+  '>': '&gt;',
+};
+
+function escapeHtmlAttribute(value: string): string {
+  return value.replace(/[&"'<>]/g, (char) => HTML_ATTRIBUTE_ENTITIES[char]);
+}
+
 function generateUrl(value: string, deployUrl: string | undefined): string {
   if (!deployUrl) {
-    return value;
+    return escapeHtmlAttribute(value);
   }
 
   // Skip if root-relative, absolute or protocol relative url
   if (/^((?:\w+:)?\/\/|data:|chrome:|\/)/.test(value)) {
-    return value;
+    return escapeHtmlAttribute(value);
   }
 
-  return `${deployUrl}${value}`;
+  return escapeHtmlAttribute(`${deployUrl}${value}`);
 }
 
 function updateAttribute(
   tag: { attrs: { name: string; value: string }[] },
diff --git a/packages/angular/build/src/builders/application/tests/options/deploy-url_spec.ts b/packages/angular/build/src/builders/application/tests/options/deploy-url_spec.ts
index 35935707c8f5d8ab0da5798df791a9ed1f2cc0bf..a25d7340d6dd7f6181e3c9ba0d20f0e5261b46f2 100644
--- a/packages/angular/build/src/builders/application/tests/options/deploy-url_spec.ts
+++ b/packages/angular/build/src/builders/application/tests/options/deploy-url_spec.ts
@@ -58,6 +58,26 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
         );
     });
 
+    it('escapes special characters in deployUrl before writing HTML tags', async () => {
+      harness.useTarget('build', {
+        ...BASE_OPTIONS,
+        styles: ['src/styles.css'],
+        deployUrl: `x"></script><script>globalThis.__deployUrlXss='1'</script><script src="`,
+      });
+
+      const { result } = await harness.executeOnce();
+      expect(result?.success).toBe(true);
+
+      harness
+        .expectFile('dist/browser/index.html')
+        .content.not.toContain(`<script>globalThis.__deployUrlXss='1'</script>`);
+      harness
+        .expectFile('dist/browser/index.html')
+        .content.toContain(
+          `src="x&quot;&gt;&lt;/script&gt;&lt;script&gt;globalThis.__deployUrlXss=&#39;1&#39;&lt;/script&gt;&lt;script src=&quot;main.js" type="module"></script>`,
+        );
+    });
+
     it('should update resources component stylesheets to reference deployURL', async () => {
       await harness.writeFile('src/app/test.svg', '<svg></svg>');
       await harness.writeFile(

Impact analysis – Please briefly explain who can exploit the vulnerability, and what they gain when doing so

An attacker who can influence the deployUrl value used during an Angular build can exploit this issue by supplying a crafted deployUrl through workspace configuration or build arguments.

Successful exploitation injects attacker-controlled script into the generated index.html. When users visit the deployed page, that script executes in their browser in the origin of the affected application.

Minimal Reproduction

deployurl_index_xss_test.tar.gz

Exception or Error


Your Environment

latest code

Anything else relevant?

No response

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions