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
- 01Repository-controlled source
Terraform cloud or remote backend hostnameThe scanned Terraform can name a destination that is different from the operator's trusted Terraform Cloud or Enterprise host.
- 02Host selection
internal/hcl/remote_variables_loader.go in 0.10.44The remote-variables path accepted the parsed hostname as the API destination.
- 03Credential source
Configured Terraform Cloud, Enterprise, or registry tokenThe CI environment or Infracost configuration supplies a secret token independently of the scanned hostname.
- 04Disclosure sink
Outbound Authorization headerThe affected request attached the Bearer token without first requiring the destination to match the trusted host.
- 05Fixed policy
Infracost pull request 3590Version 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
- Optionally compare Infracost 0.10.44 with pull request 3590 to see where the trusted-host checks were introduced across the three credential paths.
- Save the illustrative loopback model below as
host_scope_lab.pyand run it with Python 3. It models the remote-variable decision and does not import or execute Infracost. - The affected-policy branch sends
SYNTHETIC_TOKENto the repository-selected loopback host. This demonstrates why adding a token before validating the host is unsafe. - The no-token control reaches the same listener without an Authorization header, demonstrating why workflow secret exposure is a required precondition.
- 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.goSynthetic 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: 2Expected 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.
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.
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.