Command
build
Is this a regression?
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> = {
+ '&': '&',
+ '"': '"',
+ "'": ''',
+ '<': '<',
+ '>': '>',
+};
+
+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"></script><script>globalThis.__deployUrlXss='1'</script><script src="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
Anything else relevant?
No response
Command
build
Is this a regression?
The previous version in which this bug was not present was
No response
Description
Report description
Angular CLI persistent XSS via
deployUrlin generatedindex.htmlThe problem
Please describe the technical details of the vulnerability
1. technical details
The application builder accepts
deployUrlas an unrestricted string:During index generation, that value is used to build raw HTML attribute values for generated
<script>and<link>tags:The URL helper does not escape HTML-special characters. It simply prepends
deployUrlto relative asset names:Because
deployUrlis inserted directly into HTML attributes, a payload containing a quote can break out of the intendedsrc="..."orhref="..."context and inject a new script tag into the generatedindex.html.For example, a malicious
deployUrllike:produces a generated
index.htmllike: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-clirepository code insteadof an external Angular release. It:
--deploy-urlbrowser/output and opens it in a real browserThe PoC is executed with:
cd ./work/angular-cli/deployurl_index_xss_test ./run-poc.shThe generated project uses the application builder:
The PoC payload is:
The trigger is a standard build command:
npx ng build --configuration development --deploy-url "${ATTACK_DEPLOY_URL}"Observed result:
The PoC then serves the generated
dist/.../browserdirectory and opens it in a real browser. Observed result: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 thatexecutes that script.
This demonstrates that attacker-controlled HTML/JavaScript from
deployUrlis written into the generatedindex.htmland executes in the browser when the built page is visited.3. patch
Impact analysis – Please briefly explain who can exploit the vulnerability, and what they gain when doing so
An attacker who can influence the
deployUrlvalue used during an Angular build can exploit this issue by supplying a crafteddeployUrlthrough 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
Anything else relevant?
No response