CVE case study
CVE-2026-71493: Intermediate Symlinks Escaped Infracost's Template Boundary
Four Infracost template helpers relied on lexical path confinement and leaf-only symlink inspection. The readFile helper could follow a committed intermediate symlink and read outside the checkout. Version 0.10.45 resolves the complete path before allowing access.
- Severity
- Medium (5.9)
- Scoring
- CVSS 4.0
- Weakness
- CWE-59 · CWE-22
- Affected
- Infracost versions earlier than 0.10.45
- Remediation state
- Upgrade to Infracost 0.10.45 or later
- Advisory published
- 7 Aug 2026
Official vectorCVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:A/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N
Why it matters
Infracost config templates intentionally confine file helpers to the scanned repository. That boundary matters in CI because a pull-request author can control repository files while runner configuration and credentials live outside the checkout.
The affected check blocked a visible ../ escape and a symlink used as the final path component. It did not account for a symlink in the middle of an otherwise clean path, even though the operating system follows every component when the file is opened.
How I found it
On 24 June 2026, I traced infracost scan into the config-template parser and reviewed the boundary around readFile. The code used filepath.Rel for lexical containment and os.Lstat on the final pathname.
The combination blocked direct parent traversal and a symlink used as the final component, but it did not inspect symlinks earlier in the path. A committed directory symlink could therefore point outside the checkout while a request such as escape/marker.txt remained lexically clean.
I built three local proofs against the parser version used by Infracost: a synthetic out-of-root marker read, negative controls for ../ and a leaf symlink, and an end-to-end template-generation check showing the marker inside rendered configuration.
I reported the finding on 24 June. The shared parser repair merged on 25 June, the Infracost fix merged on 26 June, version 0.10.45 shipped on 3 July, and the advisory was published on 7 August with Finder credit.
Root cause
The guard joined the repository root and requested path, then used filepath.Rel to make a lexical containment decision. A path such as escape/marker.txt looks inside the repository as text even when escape is a symlink to an external directory.
A leaf-only os.Lstat check did not close the gap. It followed the intermediate symlink and inspected the ordinary marker file at the end, so the later os.ReadFile was allowed to follow the same route outside the checkout.
The public advisory covers readFile, pathExists, isDir, and matchPaths. The flaw was therefore a shared path-confinement assumption, not a single unsafe read call.
Source-to-sink trace
- 01Repository-controlled source
infracost.yml.tmplA pull-request author can provide a template call such as readFile with a repository-relative pathname.
- 02Lexical boundary
filepath.Rel inside the 0.10.44 template parserThe text escape/marker.txt appears to stay below the repository root because it contains no parent traversal segment.
- 03Incomplete link check
os.Lstat on the complete pathnameLstat identifies a symlink only at the leaf. It follows an intermediate directory symlink and sees the final regular file.
- 04Filesystem sink
readFile, pathExists, isDir, and matchPathsAll four helpers relied on the unsafe confinement check. readFile demonstrated the out-of-checkout read; pathExists and isDir could expose external filesystem state. matchPaths was moved to the shared boundary as part of the complete repair.
- 05Output path
Generated Infracost configurationFor readFile, external bytes enter rendered configuration that can be surfaced through the dashboard or pull-request comment.
Safe proof of concept
Prerequisites
- Python 3 on a local POSIX filesystem that supports symbolic links.
- Git only for the optional public source comparison.
- No Infracost account, API key, CI runner, or external target.
Step-by-step reproduction
- Optionally compare the public 0.10.44 parser with the 0.10.45 shared path boundary.
- Save the synthetic model below as
symlink_boundary_lab.pyand run it with Python 3. - The script creates an invented marker outside a temporary repository and an intermediate repository symlink named
escapethat points to that directory. - Confirm the affected decision rejects direct parent traversal and the leaf symlink but accepts
escape/marker.txt, after which the synthetic marker is read. - Confirm the fixed decision resolves the complete path and rejects all three routes to the external marker while continuing to allow an ordinary file inside the repository.
Compare the public path-confinement implementations
LAB_DIR=$(mktemp -d "/tmp/infracost-path-boundary.XXXXXX")
git clone --filter=blob:none https://github.com/infracost/infracost.git "$LAB_DIR/infracost"
git -C "$LAB_DIR/infracost" diff v0.10.44 v0.10.45 -- \
internal/config/template/parser.go \
internal/security/files.goSynthetic intermediate-symlink boundary model
import os
import stat
import tempfile
from pathlib import Path
with tempfile.TemporaryDirectory(prefix="infracost-path-lab-") as tmp:
root = Path(tmp)
repo = root / "repo"
outside = root / "outside"
repo.mkdir()
outside.mkdir()
(outside / "marker.txt").write_text("CYBERKAREEM_LOCAL_MARKER")
(repo / "inside.txt").write_text("SAFE_INSIDE_FILE")
(repo / "escape").symlink_to("../outside", target_is_directory=True)
(repo / "leaf.txt").symlink_to("../outside/marker.txt")
def affected_allowed(requested):
full = os.path.abspath(os.path.join(repo, requested))
relative = os.path.relpath(full, repo)
lexically_inside = relative != ".." and not relative.startswith(".." + os.sep)
leaf_is_link = stat.S_ISLNK(os.lstat(full).st_mode)
return lexically_inside and not leaf_is_link
def fixed_allowed(requested):
resolved_repo = repo.resolve()
resolved_target = (repo / requested).resolve()
try:
resolved_target.relative_to(resolved_repo)
return True
except ValueError:
return False
cases = ["../outside/marker.txt", "leaf.txt", "escape/marker.txt", "inside.txt"]
for requested in cases:
old = affected_allowed(requested)
new = fixed_allowed(requested)
value = (repo / requested).read_text() if old else "BLOCKED"
print(f"{requested}: affected={old} fixed={new} value={value}")Expected local result
../outside/marker.txt: affected=False fixed=False value=BLOCKED
leaf.txt: affected=False fixed=False value=BLOCKED
escape/marker.txt: affected=True fixed=False value=CYBERKAREEM_LOCAL_MARKER
inside.txt: affected=True fixed=True value=SAFE_INSIDE_FILEExpected evidence
- The direct parent-traversal control is rejected by both policies.
- The leaf-symlink control is rejected by both policies.
- Only the intermediate symlink passes the affected policy and exposes the invented external marker.
- The resolved-path policy rejects every external route while preserving ordinary in-repository access.
The direct ../outside/marker.txt path proves that the lexical guard was active, and leaf.txt proves that the leaf-only Lstat check was active. Their rejection isolates the intermediate-component gap.
On 0.10.45 or later, repeat the intermediate, leaf, parent-traversal, and legitimate in-repository cases through the real template Compile entrypoint. All external paths must fail before any helper reads, stats, or expands them.
Impact
A pull-request author could read files available to the Infracost process outside the checkout and place the returned content into generated configuration. Infracost output can then surface that content through its dashboard or pull-request comment.
The official assessment is Medium at 5.9 and claims confidentiality impact only. Normal fork-based pull_request runs generally have no secrets and a read-only token, while same-repository or privileged workflow contexts can expose more valuable runner files. The issue is a file read, not command execution.
Fix and retest
Upgrade to Infracost 0.10.45 or later. The fix routes all four helpers through security.IsPathAllowed, resolves symlinks throughout the path, resolves the allowed parent, and performs a segment-aware containment check on the resulting paths.
The patch also handles paths that do not yet exist by resolving their longest existing prefix. Regression tests cover parent traversal, leaf symlinks, intermediate symlinks, legitimate in-repository paths, and the real template compilation entrypoint.
Engineering lesson
Lexical path checks answer where a pathname appears to point. Filesystem operations act on where the fully resolved path actually points. A security boundary must make its decision on the resolved object or use a directory-scoped filesystem API that enforces the boundary structurally.
Negative controls are valuable here: showing that ../ and a leaf symlink were already blocked isolates the exact missing case and distinguishes a bypass from intended template capability.
References
- Vendor advisory GHSA-mmg6-4qmv-6pc8 opens in a new tab
- Affected template parser in 0.10.44 opens in a new tab
- Security fix pull request 3586 opens in a new tab
- Shared parser fix pull request 14 opens in a new tab
- Shared path boundary in 0.10.45 opens in a new tab
- Fixed release 0.10.45 opens in a new tab