Validate module name and version derived from PR paths in bcr_presubmit.py (#2759)
`module_name` and `module_version` in `bcr_presubmit.py` are derived
from a pull request's changed file paths (`modules/<name>/<version>/`)
via `get_target_modules()`, using a regex whose only constraint is "no
slash" (`modules\/([^\/]+)\/([^\/]+)\/`). Both values can therefore
contain arbitrary shell and Starlark metacharacters.
Two sinks interpolate these values unescaped:
- The shell command string built in `add_presubmit_jobs()` (partially
addressed by #2739's `shlex.quote` fix at that one call site).
- The generated `MODULE.bazel` content in `create_anonymous_repo()`:
```python
scratch_file(root, "MODULE.bazel", ["bazel_dep(name = '%s', version =
'%s')" % (module_name, module_version)])
```
A `module_name`/`module_version` containing a single quote closes the
Starlark string literal early, letting the rest of the payload be parsed
as new top-level `MODULE.bazel` statements (e.g. `load(...)` + a
repository rule invocation), evaluated when `bazel vendor` runs during
`anonymous_module_runner`/`test_module_runner`. This sink is unaffected
by #2739, since `shlex.quote` only protects shell-argument parsing, not
the raw string value that later reaches this `%`-format call.
This patch rejects any `module_name`/`module_version` that doesn't match
the character set Bzlmod itself accepts
(https://bazel.build/external/module#module_name, `#version`) at the
point they're derived from the PR path in
`get_target_modules()`/`get_modules_with_metadata_change()`. Validating
at the source closes both the already-partially-fixed shell sink and the
still-open `MODULE.bazel` sink (and any future sink in this file) at a
single choke point, rather than escaping per call site.
Normal module names/versions (e.g. `rules_foo` / `1.2.3`, `protobuf` /
`27.0-rc1`) are unaffected. Anything containing quotes, backticks,
semicolons, dollar signs, spaces, or uppercase letters in the name is
now rejected before it reaches any downstream sink.
diff --git a/buildkite/bazel-central-registry/bcr_presubmit.py b/buildkite/bazel-central-registry/bcr_presubmit.py
index be7ff62..cc09783 100755
--- a/buildkite/bazel-central-registry/bcr_presubmit.py
+++ b/buildkite/bazel-central-registry/bcr_presubmit.py
@@ -34,6 +34,22 @@
BCR_REPO_DIR = pathlib.Path(os.getcwd())
+# Bazel module name / version character sets, per
+# https://bazel.build/external/module#module_name and #version. Anything
+# outside these sets is not a name/version Bzlmod itself would ever accept,
+# so rejecting it here is safe for legitimate modules and closes every sink
+# in this file (shell command construction, generated MODULE.bazel content,
+# and the modules/<name>/<version>/ path getters below) to a single choke
+# point instead of escaping/quoting at each call site individually.
+MODULE_NAME_RE = re.compile(r"^[a-z]([a-z0-9._-]*[a-z0-9])?$")
+MODULE_VERSION_RE = re.compile(r"^[A-Za-z0-9]+(?:[._+-][A-Za-z0-9]+)*$")
+
+
+def is_valid_module_identifier(module_name, module_version):
+ return bool(MODULE_NAME_RE.match(module_name)) and bool(
+ MODULE_VERSION_RE.match(module_version)
+ )
+
BUILDKITE_ORG = os.environ.get("BUILDKITE_ORGANIZATION_SLUG", "bazel")
SCRIPT_URL = "https://raw.githubusercontent.com/bazelbuild/continuous-integration/{}/buildkite/bazel-central-registry/bcr_presubmit.py?{}".format(
@@ -87,7 +103,20 @@
for line in output.decode("utf-8").split():
s = re.match(r"modules\/([^\/]+)\/([^\/]+)\/", line)
if s:
- modules.add(s.groups())
+ module_name, module_version = s.groups()
+ # [^\/]+ only excludes "/", so a crafted PR path can smuggle
+ # shell/Starlark metacharacters into module_name/module_version
+ # (e.g. quotes, backticks, semicolons) that later get interpolated
+ # into generated commands and MODULE.bazel content elsewhere in
+ # this file. Reject anything that isn't a name/version Bzlmod
+ # itself would accept, rather than trusting the path shape alone.
+ if not is_valid_module_identifier(module_name, module_version):
+ raise BcrPipelineException(
+ "Invalid module name or version derived from changed path %r: "
+ "module_name=%r module_version=%r"
+ % (line, module_name, module_version)
+ )
+ modules.add((module_name, module_version))
return sorted(modules)
@@ -105,7 +134,13 @@
for line in output.decode("utf-8").split():
s = re.match(r"modules\/([^\/]+)\/metadata\.json", line)
if s:
- modules.add(s.groups()[0])
+ module_name = s.groups()[0]
+ if not MODULE_NAME_RE.match(module_name):
+ raise BcrPipelineException(
+ "Invalid module name derived from changed path %r: module_name=%r"
+ % (line, module_name)
+ )
+ modules.add(module_name)
return sorted(modules)