CVE case study

CVE-2026-71494: Untrusted Terraform Hosts Could Receive Terraform Cloud or Registry Tokens

Infracost attached Terraform Cloud, Enterprise, or registry tokens to requests whose destination host could come from scanned Terraform. Version 0.10.45 scopes credentials to trusted hosts.

Severity
Medium (5.9)
Scoring
CVSS 4.0
Weakness
CWE-522
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

In the affected Infracost paths, scanned Terraform could influence the destination host while separate request code attached a configured Terraform Cloud, Enterprise, or registry token. The token was not first tied to the host the user had trusted.

The practical consequence depends on the workflow. A normal fork-based pull_request run normally receives no repository secrets, so there may be no token to disclose. An outside contributor's workflow also typically requires maintainer approval. A same-repository pull request, pull_request_target workflow, or another CI design that exposes a token while processing untrusted Terraform can disclose it.

How I found it

On 30 June 2026, I looked for requests where scanned Terraform selected the host and Infracost added a token elsewhere. Terraform's cloud and remote backend blocks stood out because their hostname values come from the repository being scanned.

I traced a scanned hostname into the Terraform Cloud remote-variables loader and found that the configured Bearer token was not bound to the trusted Terraform endpoint. A repository could therefore redirect the authenticated request while the client continued to attach the token.

I verified the source-to-sink path in the affected code. The local listener below is an illustrative policy model, not an execution of the vulnerable Infracost binary: it shows the difference between attaching a synthetic token before checking the host and refusing the same destination under the fixed remote-variable policy.

I reported the issue to Infracost on 30 June. The public fix later applied the same host-binding rule to remote variable loading, remote-plan token selection, and Terragrunt registry requests. Version 0.10.45 shipped on 3 July, and the advisory was published on 7 August with Finder credit.

Root cause

The remote-variables loader accepted a hostname parsed from the Terraform cloud or remote backend configuration. The request path then used the configured secret without first proving that the destination still matched the trusted Terraform Cloud or Enterprise endpoint.

The public patch shows the same policy gap in three related areas: remote variable loading, remote-plan token selection, and Terragrunt registry requests. Host selection and credential selection were implemented separately, with no shared rule binding the credential to an approved host.

Source-to-sink trace

  1. 01
    Repository-controlled sourceTerraform cloud or remote backend hostname

    The scanned Terraform can name a destination that is different from the operator's trusted Terraform Cloud or Enterprise host.

  2. 02
    Host selectioninternal/hcl/remote_variables_loader.go in 0.10.44

    The remote-variables path accepted the parsed hostname as the API destination.

  3. 03
    Credential sourceConfigured Terraform Cloud, Enterprise, or registry token

    The CI environment or Infracost configuration supplies a secret token independently of the scanned hostname.

  4. 04
    Disclosure sinkOutbound Authorization header

    The affected request attached the Bearer token without first requiring the destination to match the trusted host.

  5. 05
    Fixed policyInfracost pull request 3590

    Version 0.10.45 releases a token only to the configured trusted host and applies a separate allowlist to registry requests.

Safe proof of concept

Prerequisites

  • Python 3 in a temporary local directory.
  • Git only for the optional public source comparison.
  • No real API token, cloud workspace, CI runner, or public endpoint.

Step-by-step reproduction

  1. Optionally compare Infracost 0.10.44 with pull request 3590 to see where the trusted-host checks were introduced across the three credential paths.
  2. Save the illustrative loopback model below as host_scope_lab.py and run it with Python 3. It models the remote-variable decision and does not import or execute Infracost.
  3. The affected-policy branch sends SYNTHETIC_TOKEN to the repository-selected loopback host. This demonstrates why adding a token before validating the host is unsafe.
  4. The no-token control reaches the same listener without an Authorization header, demonstrating why workflow secret exposure is a required precondition.
  5. The fixed remote-variable branch compares the repository-selected host with the trusted host and refuses the mismatch before making a request. Confirm that the listener receives only the first two control requests. The registry fix is different: it may contact another registry, but does not attach the protected token.

Compare the public affected and fixed credential paths

LAB_DIR=$(mktemp -d "/tmp/infracost-host-scope.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/hcl/remote_variables_loader.go \
  internal/hcl/parser.go \
  internal/hcl/terragrunt_registry_service.go

Synthetic loopback host-binding model

from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from threading import Thread
from urllib.parse import urlsplit
from urllib.request import Request, urlopen

seen = []

class Listener(BaseHTTPRequestHandler):
    def do_GET(self):
        seen.append(self.headers.get("Authorization"))
        self.send_response(204)
        self.end_headers()

    def log_message(self, *_):
        pass

server = ThreadingHTTPServer(("127.0.0.1", 0), Listener)
Thread(target=server.serve_forever, daemon=True).start()
repository_host = f"http://127.0.0.1:{server.server_port}"
trusted_host = "https://app.terraform.io"
token = "SYNTHETIC_TOKEN"

def send(destination, supplied_token):
    headers = {}
    if supplied_token:
        headers["Authorization"] = f"Bearer {supplied_token}"
    urlopen(Request(destination + "/api/v2/workspaces", headers=headers))

def fixed_policy(destination, trusted, supplied_token):
    if urlsplit(destination).netloc != urlsplit(trusted).netloc:
        return "REFUSED_UNTRUSTED_HOST"
    send(destination, supplied_token)
    return "SENT_TO_TRUSTED_HOST"

send(repository_host, token)
send(repository_host, None)
fixed_result = fixed_policy(repository_host, trusted_host, token)

server.shutdown()
print("listener headers:", seen)
print("fixed decision:", fixed_result)
print("requests seen:", len(seen))

Expected local result

listener headers: ['Bearer SYNTHETIC_TOKEN', None]
fixed decision: REFUSED_UNTRUSTED_HOST
requests seen: 2

Expected evidence

  • The illustrative affected-policy branch places the synthetic token in the first request's Authorization header.
  • The same destination receives no credential when no token is configured, confirming the advisory's required precondition.
  • The fixed policy refuses the untrusted host and produces no third listener request.
Negative control

Run the same repository-selected request with no token. The listener records the request but its Authorization value is None. Then apply the fixed host comparison and confirm that the mismatched destination is not contacted at all.

Fixed-version re-test

On 0.10.45 or later, test the remote-variables loader, remote-plan path, and registry client with matching and mismatching hosts. A configured token may appear only on requests to the trusted endpoint or an explicitly allowed registry host.

Impact

If a Terraform Cloud, Enterprise, or registry token is present, attacker-controlled Terraform can direct a token-bearing request to a host the attacker controls. The exposed credential may allow access to the resources and operations granted to that token.

The official assessment is Medium at 5.9, with confidentiality impact only. This article does not claim that every pull request exposes a token: the run must actually provide one, and untrusted fork workflows commonly do not.

Fix and retest

Upgrade to Infracost 0.10.45 or later. The fix sends a configured token only when the destination matches the trusted host, which defaults to app.terraform.io unless TERRAFORM_CLOUD_HOST or the terraform_cloud_host Infracost option is set.

The fixed remote-variable path skips or refuses loading when the untrusted host does not match. The remote-plan path applies the same host-scoped selection, and the registry path limits token attachment to an explicit allowlist of trusted registry and Terraform hosts.

As defense in depth, keep untrusted contributions on pull_request, avoid providing secrets to jobs that parse pull-request Terraform, and pin organization, workspace, and host in trusted Infracost configuration.

Engineering lesson

Before attaching a token, verify that the request is going to the host the user trusted. A client should never assume that a destination selected elsewhere is safe for its credential.

After finding one host-confusion path, search every sibling client that carries the same credential and apply the same host check to each one.

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