Skip to content

Credential pinning

A provider holds an API token. It also downloads assets from URLs it did not choose. Those two facts, combined carelessly, leak the token.

The threat

Release metadata is author-controlled. When a provider asks the API for a release, the asset download URLs come back as data, and on most platforms a release author can set them to any URL at all.

So the naive download is a credential-exfiltration primitive:

// hands the API token to whatever host the metadata names
req, _ := http.NewRequest(http.MethodGet, asset.GetBrowserDownloadURL(), nil)
req.Header.Set("Authorization", "token "+p.token)

The victim does not have to do anything unusual. They install a tool, or update one, whose release was published — or edited — by someone hostile. The token goes to the attacker's server with a valid Authorization header attached.

The defence

Attach the credential only when the target is the host the provider authenticated against:

if p.token != "" && forge.HostTrusted(downloadURL, p.baseURL) {
    req.Header.Set("Authorization", "token "+p.token)
}

Note what this does not do: it does not block the download. An asset hosted elsewhere is still fetched, just unauthenticated — which is correct for a public asset on a CDN, a very common arrangement.

Why it is a shared primitive

Three providers each implemented this check separately before it moved into the core, and all three had the same gap: they compared only the host, not the scheme. A base of https://git.example.com therefore trusted http://git.example.com, and the credential went out in plaintext to a host an attacker on the network path can impersonate.

That is not a hypothetical ordering of events. An attacker who can rewrite release metadata — the exact capability this defence assumes — would choose a scheme downgrade, because it is the cheapest way to turn a pinned credential into an intercepted one.

Three copies of one security check are three chances to get it subtly wrong, and they had all made the same mistake. HostTrusted is the one implementation, and a fourth-party provider author now has a primitive to reach for rather than a check to reinvent.

What counts as trusted

Property Rule Why
Host Must match, case-insensitively DNS is case-insensitive; a case difference is not an attack
Port Part of the host comparison A different port is a different service
Scheme Must match Prevents the plaintext downgrade above
Parse failure Not trusted Fails closed
Missing host Not trusted A relative URL has no host to pin against

It fails closed throughout, and the asymmetry justifies it: a false negative costs one unauthenticated request, which may simply succeed. A false positive costs the credential.

Beware the lookalike: git.example.com.evil.org is not a match. Host comparison is exact, never a suffix test — a suffix test is how this defence is usually defeated.

Escape hatches, and why they exist

Strictness with no way out is not actually safer. An operator whose deployment genuinely needs something wider will remove the pinning entirely rather than fight it, and a removed check protects nobody. So there are two deliberate relaxations, each stated at the call site where it is visible and greppable:

// assets served from a storage domain the operator controls
forge.HostTrusted(url, base, forge.WithAdditionalHosts("assets.example.com"))

// a lab or air-gapped install with no TLS at all
forge.HostTrusted(url, base, forge.WithInsecureSchemeDowngrade())

WithAdditionalHosts still matches exactly, case-insensitively, including port — widening the set never turns it into a suffix test. And it does not relax the scheme: the two options are independent, so trusting an extra host does not quietly also permit plaintext.

WithInsecureSchemeDowngrade is named to be uncomfortable, because it should be. It puts the credential on the wire in cleartext. There is one defensible use — a deployment with genuinely no TLS, where the operator accepts that. Against anything internet-facing, the answer is TLS, not this option.

Neither is a default, and neither can be enabled by configuration this module reads. They are code, written deliberately, by whoever authored the provider.