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.