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 hands the token to whatever host the metadata names:
// 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 check¶
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, which is 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 one implementation 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.
The SDKs will hand you a leak if you let them¶
The pinning above is forge's own. The platform SDKs underneath do not all uphold it, and two of the three offer a method that leaks a credential when used exactly as its name suggests. Both were verified against the versions this module pins, by request rather than by reading the source.
go-gitlab: the guard runs before the redirect¶
(*gitlab.Client).NewRequestToURL attaches PRIVATE-TOKEN to a request for any
URL, and refuses a URL that is not on the client's base host:
client only allows requests to URLs matching the clients configured base URL.
Got "https://attacker.example/evil.tar.gz", base URL is "https://gitlab.example/api/v4/"
That reads as a guardrail. It is not one. The check runs when the request is built; a redirect happens afterwards, and the client follows it carrying the credential:
An asset link on the instance redirecting to object storage is GitLab's ordinary path for a large file, not an exotic case. So the credential reaches whatever host a release author's link eventually resolves to, which is the threat this page opens with.
Do not route an asset download through NewRequestToURL
It looks like the tidy option, because the SDK holds the credential, so let the SDK make the request. It moves the leak back in.
forge-gitlab builds its download client with
httpclient.WithSensitiveHeaders("PRIVATE-TOKEN"), whose CheckRedirect
deletes the header on any cross-host hop.
TestDownloadReleaseAsset_TokenNotForwardedAcrossRedirect pins it. forge is
safe here precisely where the SDK is not, and that is deliberate rather
than incidental.
go-github: an authenticated client, handed out on request¶
(*github.Client).Client() returns the underlying *http.Client, and its every
request carries Authorization: Bearer <token>, to any host you send it to. The
only protection is its doc comment, which says so plainly:
This should only be used for requests to the GitHub API because request headers will contain an authorization token.
Treat it as unusable for anything that touches an author-controlled URL.
Its download path, by contrast, is correct and worth copying:
DownloadReleaseAsset uses bareDoUntilFound to stop at the redirect, then
refetches with a fresh, credential-free request through a client the caller
supplies. Authenticated hop to the API, unauthenticated hop to storage: the
separation this page argues for, implemented upstream.
Gitea¶
gitea.dev/sdk exposes no client accessor, no token accessor and no
arbitrary-URL request method. GetReleaseAttachment returns metadata; the bytes
come from browser_download_url, fetched by the provider itself. There is
nothing here to misuse, and nothing to help either.
What this means if you are authoring a provider¶
Never assume the SDK's credential handling is host-scoped. Check what happens across a redirect, with a real request to a real test server. A base-URL check in the SDK's request builder tells you nothing about the hop after it.
Build the download path yourself, on a client that strips sensitive headers
across hosts, and gate the credential with forge.HostTrusted. That is two
independent defences: the gate decides whether the credential goes on at all, and
the redirect policy catches the case where the first hop was legitimate and the
second was not.