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 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:

OFF-HOST server got PRIVATE-TOKEN: "SECRET-TOKEN-123"

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.