Skip to content

Arbitrary file read via //# sourceMappingURL= path traversal in SourceMapResolver #4322

Description

@prasanna8585

Summary

closure-compiler reads arbitrary files from the local filesystem when compiling JavaScript source that contains a crafted //# sourceMappingURL= comment with a relative path-traversal sequence. No bounds check is applied to keep the resolved path within the expected directory.

This was reported to the Google OSS VRP (bughunters.google.com). The team closed it as Infeasible (does not meet OSS VRP's bar, since it requires closure-compiler to already be compiling untrusted/third-party source) and explicitly authorized public disclosure here. Filing per that guidance so the pattern is documented and users who compile untrusted input can mitigate it.

This is the same bug class as CVE-2026-49356 / GHSA-4x5r-pxfx-6jf8 in @babel/core (fixed June 13, 2026) — an inline sourceMappingURL comment used to read arbitrary files via unbounded relative path resolution. closure-compiler has an independently vulnerable implementation of the same pattern.

Root cause

src/com/google/javascript/jscomp/SourceMapResolver.java:

private static boolean isAbsolute(String url) {
  try {
    return new URI(url).isAbsolute() || url.startsWith("/");
  } ...
}
 
static @Nullable SourceFile getRelativePath(String baseFilePath, String relativePath) {
  return SourceFile.builder()
      .withPath(
          FileSystems.getDefault().getPath(baseFilePath).resolveSibling(relativePath).normalize())
      ...
}

isAbsolute() only rejects URIs that are themselves absolute (file:///etc/passwd) or start with /. It does not reject relative traversal sequences like ../../../../etc/passwd, so those pass through to getRelativePath(). There, resolveSibling(relativePath).normalize() resolves the traversal but the result is never checked against the base directory (no resolved.startsWith(base) guard anywhere in this function).

Full attack chain

  1. Attacker supplies a JS file containing:
   //# sourceMappingURL=../../../../etc/passwd
  1. The parser extracts sourceMapURL = "../../../../etc/passwd".
  2. SourceMapResolver.extractSourceMap(jsFile, "../../../../etc/passwd", parseInline) is invoked.
  3. isAbsolute("../../../../etc/passwd")false (it's a relative path — not blocked).
  4. getRelativePath(jsFile.getName(), "../../../../etc/passwd") → e.g. Path.of("input.js").resolveSibling("../../../../etc/passwd").normalize() → resolves outside the working directory (e.g. /etc/passwd when the base path allows enough .. segments, or further up the tree relative to CWD otherwise).
  5. Returns a SourceFile built from that resolved path.
  6. compiler.addInputSourceMap(jsFileName, new SourceMapInput(sourceMapSourceFile)) registers it.
  7. Later, inputSourceMap.getSourceMap(errorManager) calls sourceFile.getCode(), which reads the file's contents from disk.
  8. The file contents surface to the attacker via compiler error messages (e.g. SOURCEMAP_PARSE_FAILED when the target file isn't valid source-map JSON, which will include a snippet/parse context) and/or via the sourcesContent array of the compiler's own output source map, if source map output is enabled.
    No special flag is required — resolveSourceMapAnnotations (which governs whether sourceMappingURL comments are honored) defaults to true.

Affected scenarios

Impactful wherever closure-compiler processes JavaScript it doesn't fully control:

  • SaaS/hosted build or minification services accepting user-submitted JS
  • CI/CD pipelines compiling third-party or unreviewed dependencies
  • Monorepos where one tenant's/team's code is compiled in a shared build environment
  • IDE or dev-tool integrations that run closure-compiler over arbitrary opened files
    Users who only ever compile their own fully-trusted source are not affected.

Suggested fix

Add a bounds check in getRelativePath() so the resolved path can't escape the expected base directory:

static @Nullable SourceFile getRelativePath(String baseFilePath, String relativePath) {
  Path base = FileSystems.getDefault().getPath(baseFilePath).toAbsolutePath().normalize();
  Path resolved = base.resolveSibling(relativePath).toAbsolutePath().normalize();
  if (!resolved.startsWith(base.getParent())) {
    return null; // reject traversal outside the base directory
  }
  return SourceFile.builder()
      .withPath(resolved)
      .withKind(SourceKind.NON_CODE)
      .build();
}

An alternative, lower-effort mitigation (matching Babel's approach) would be to default resolveSourceMapAnnotations to false, requiring an explicit opt-in for any environment that needs inline source-map resolution over trusted input.

Comparison with CVE-2026-49356 (@babel/core)

@babel/core (CVE-2026-49356, patched) closure-compiler (this issue)
Trigger //# sourceMappingURL= comment //# sourceMappingURL= comment
Enabled by default Yes Yes (resolveSourceMapAnnotations=true)
Bounds check before fix None None
Disclosure vector Output source map Compiler error messages + output sourcesContent
Status Fixed June 13, 2026 Unpatched as of this report

Verification

Dynamically verified against closure-compiler v20260629 (latest at time of testing) on Java 21, by compiling a JS input containing a sourceMappingURL comment pointing outside the working directory and observing the target file's contents surface via compiler error output.

Environment

  • Reported to bughunters.google.com; closed as Infeasible for OSS VRP purposes, with explicit permission granted to disclose publicly here.
  • SourceMapResolver.java last touched by a refactor-only commit (no security-relevant change) prior to this report; pattern confirmed still present as of the date of this issue.

Metadata

Metadata

Assignees

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