fix!: Send credentials only to configured origins - #4564
Conversation
Signed-off-by: Glenn Lewis <6598971+gmlewis@users.noreply.github.com>
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #4564 +/- ##
==========================================
+ Coverage 98.57% 98.59% +0.01%
==========================================
Files 197 197
Lines 18299 18326 +27
==========================================
+ Hits 18038 18068 +30
+ Misses 261 258 -3 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Signed-off-by: Glenn Lewis <6598971+gmlewis@users.noreply.github.com>
|
cc: @stevehipwell |
|
Reviewed the design and ran it locally (go1.27.1, PR head 582c158). On the trade-off you flagged — I think the body does matter, and I would keep a check there. Two things pushed me that way. First, the premise. "The URL comes from the caller's own release object" is true of the variable, but the value is normally the API's: Second, the failure is silent, which concerns me more than the bytes. Token configured, The token is protected — that part works exactly as described. But the call returns a nil error and a The reconciliation, if it helps: the two rules are not actually in tension, because they fail differently. For credentials, "send it unauthenticated" is right, since the request may still work. For a body whose destination came from a response, an error is right, since the operation cannot be completed on an origin the caller did not configure — and silently not doing it would be worse than failing. That makes it a body-disclosure check rather than a credential check, so it does not reintroduce the second credential rule you are removing. Renaming and re-commenting what #4556 did, in those terms, would keep one credential policy and still refuse to POST the artifact to an unconfigured origin. Keeping the policy absolute and accepting the residual is defensible too — but I would put a line in the release notes either way, since a caller reading "credentials go only to configured origins" would not expect the body to still travel. Happy for #4564 to supersede #4556, and thanks for the co-author credit. One small thing: |
|
One follow-up, now that I have read the issues this closes. I think the residual is systemic rather than specific to the upload helper, and that changes the shape of what I suggested above.
So after this PR, for the response-supplied URLs in this class:
Your policy answers the credential question uniformly and correctly — I have no disagreement with that half. It leaves the destination question open in all three, and the two harms are not the same size: the upload leaks the artifact, the metrics path accepts attacker-supplied content as a report. So I would restate my earlier suggestion. Rather than "keep a check in the upload helper", the version that fits your own principle — one rule, one place — is: when a URL came from a response and names an origin the client was never configured for, the operation cannot be completed as intended, so fail it. That is a single rule applied in the same place, and it is more uniform than handling each of these paths differently. Whether that belongs in #4564 or a follow-up is entirely your call. I raise it only because #4562 shows this is not one helper's quirk — it is the general shape of a URL that arrives from a server rather than from the caller. |
Signed-off-by: Glenn Lewis <6598971+gmlewis@users.noreply.github.com>
|
You are right about the upload, @sushant-me; fixed. It's one gate at But not extended to the downloads: their links are cross-origin by design ( I've reworked the description to match. PTAL. |
|
Verified locally and I think this is right — and better than what I proposed. Thanks for taking the upload half. The residual I reported is closed, measured the same way I found it. I re-ran my round-1 probe against your branch — a token-configured client whose
The host is never contacted, which is stronger than "the body was withheld", and the error carries the destination redacted. Your "one place" claim holds, and I checked it rather than assuming it — the same way the credential predicate turned out to be single-sourced. You are also right about the downloads, and more precisely than I was. I framed the rule as "fail an operation whose URL came from a response", and you identified the flaw in that: "came from a response" is a call-site property. The transport and the shared predicate cannot see it — Local check on the branch: One small thing, not a request: |
Signed-off-by: Glenn Lewis <6598971+gmlewis@users.noreply.github.com>
|
Thanks, @sushant-me. The pointer you want is already in the function, 18 lines below the doc comment: "NewUploadRequest refuses that case with ErrUntrustedUploadDestination … so there is no host check here: every upload this helper builds goes through that one gate." The doc comment names the error but not the mechanism, so I'll add One thing I missed before: PTAL. |
|
Verified on The doc pointer. You are correct that the mechanism is described in the function, 18 lines below the doc comment, and I read past it. Naming
I re-ran the check against both paths, and separately re-ran the round-1 experiment: Neither constructor contacts the host, and the body never leaves. The rename is complete — 0 remaining references to Two small things, neither a request:
LGTM from me. |
|
Verified this against our original report (#4562) directly in the diff, not just the description. The fetchMetricsReport/DownloadCopilotMetrics host-check code we wrote is removed entirely here, and correctly so the new doc comment on fetchMetricsReport states "a link that points elsewhere is fetched without them, as is any redirect target," which is exactly the gap our version had (we only validated the caller-supplied URL string, not what a redirect from an on-host URL could point to). Ran the logic through the same standalone repro we used to confirm the original bug: a simulated foreign-host redirect now receives no Authorization header at all, where our patch would have let it through. TestCopilotService_DownloadMetrics_ForeignHostGetsNoCredentials exercises both affected methods against a real httptest server and asserts the header never arrives that's the right level of coverage for this class of bug (destination-side, not just input-validation-side). On the download-content-trust residual sushant-me raised: agree with where you landed. Provenance isn't visible at the point a host check would need to run, and GitHub's own report links are legitimately cross-origin by design, so failing them would break the common case to guard the rare one. The comment documenting it as caller-facing untrusted input is the right call. Happy to see this supersede #4562 it closes what we found, plus the redirect-hop case we missed. LGTM from our side, with the above as the specific things we checked rather than assumed. |
Not-Dhananjay-Mishra
left a comment
There was a problem hiding this comment.
Few nits, otherwise LGTM 🚀
Co-authored-by: Dhananjay Mishra <technicaldmcontact@gmail.com>
Co-authored-by: Dhananjay Mishra <technicaldmcontact@gmail.com>
Signed-off-by: Glenn Lewis <6598971+gmlewis@users.noreply.github.com>
|
Thank you, @Not-Dhananjay-Mishra! |
BREAKING CHANGE: Credentials are sent only to a client's configured API and upload origins — every other destination, including a redirect hop, goes out unauthenticated. A request body aimed outside those origins is refused.
BasicAuthTransportandUnauthenticatedRateLimitedTransportgainedAllowedOrigins, which GitHub Enterprise users must set.One predicate, sameOrigin, now decides every destination question, replacing the per-method host checks that had been accumulating (#4556, #4562). Credentials are withheld outside the configured origins and the request still goes out; a body cannot be treated that way, since reporting an upload that succeeded while the bytes went elsewhere is worse, so
NewUploadRequestandNewFormRequestrefuse it — one gate in each body-carrying constructor, generalizing what #4556 did in one (thanks @sushant-me). Download links are cross-origin by design (raw.githubusercontent.com, pre-signed CDN hosts, Copilot report links), so those keep the credential half only, with the residual documented.WithAuthToken— tokens attach only to the configured origins; configure another host with WithURLs.NewUploadRequest—ErrUntrustedDestinationfor an absolute URL outside the configured origins; configure the host withWithURLsorWithEnterpriseURLs.NewFormRequest— same gate: an absolute URL outside the configured origins now returnsErrUntrustedDestination. No call site passes one today.BasicAuthTransport,UnauthenticatedRateLimitedTransport— newAllowedOrigins; empty means the GitHub.com API and upload origins, not "any origin".UploadReleaseAssetFromRelease— still errors on a foreignUploadURL, now via the central gate.Client()— no longer warns it is API-only; still safe for other hosts.Client.Clone— carries a token over, re-scoped to the clone's origins.Closes: #4363, Closes: #4364, Closes: #4562, Closes: #4365, Closes: #4366
Replaces #4556 — its per-helper check is now the central upload gate.
cc: @huynhtrungcsc @prasanna8585 @sushant-me