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

  1. 01
    Repository-controlled sourceinfracost.yml.tmpl

    A pull-request author can provide a template call such as readFile with a repository-relative pathname.

  2. 02
    Lexical boundaryfilepath.Rel inside the 0.10.44 template parser

    The text escape/marker.txt appears to stay below the repository root because it contains no parent traversal segment.

  3. 03
    Incomplete link checkos.Lstat on the complete pathname

    Lstat identifies a symlink only at the leaf. It follows an intermediate directory symlink and sees the final regular file.

  4. 04
    Filesystem sinkreadFile, pathExists, isDir, and matchPaths

    All 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.

  5. 05
    Output pathGenerated Infracost configuration

    For 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

  1. Optionally compare the public 0.10.44 parser with the 0.10.45 shared path boundary.
  2. Save the synthetic model below as symlink_boundary_lab.py and run it with Python 3.
  3. The script creates an invented marker outside a temporary repository and an intermediate repository symlink named escape that points to that directory.
  4. Confirm the affected decision rejects direct parent traversal and the leaf symlink but accepts escape/marker.txt, after which the synthetic marker is read.
  5. 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.go

Synthetic 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_FILE

Expected 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.
Negative control

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.

Fixed-version re-test

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

Follow this thread

Related research, source, and writing.

These paths share a published research boundary, project source, article series, or authorized lab context with this record.

Back to article start