Skip to content

fix: Reject a metrics download URL whose host differs from the client's - #4562

Open
prasanna8585 wants to merge 1 commit into
google:masterfrom
prasanna8585:fix/copilot-metrics-download-host-check
Open

prasanna8585 wants to merge 1 commit into
google:masterfrom
prasanna8585:fix/copilot-metrics-download-host-check

Conversation

@prasanna8585

Copy link
Copy Markdown
Contributor

Problem

DownloadCopilotMetrics and the six current Download*Metrics methods (via the shared fetchMetricsReport) take a download URL that callers are documented to read out of a prior Get*MetricsReport response's DownloadLinks field, not one they construct themselves. Both built the outgoing request directly from that URL and sent it through the client's own credentialed http.Client, with no check that the URL's host matched the client's configured host.

That client's auth transport (WithAuthToken's RoundTripper) attaches the caller's Authorization header to every request it sends, regardless of destination host. A report response naming a foreign host — from a compromised/malicious GitHub Enterprise Server instance, or any position able to influence that response — would have had the caller's bearer token, and the downloaded report body, sent to that host.

This is the same vulnerability class this repo already fixed twice this week: bareDoUntilFound's cross-host check on 301 redirects, and UploadReleaseAssetFromRelease's host check on the release object's UploadURL field. Neither mitigation covers this path, since both functions here build and send their request independently of those.

Fix

Parse the download URL and compare its host against client.baseURL.Host before building the request, in both fetchMetricsReport and DownloadCopilotMetrics, following the same pattern already established by the UploadURL fix (url.Parse + strings.EqualFold + a descriptive error).

Testing

  • gofmt -l clean on both changed files.
  • This sandbox couldn't fetch the toolchain the module's go.mod requires (go >= 1.26.0, network-restricted), so I verified the exact fix logic (URL parsing, host comparison) in a standalone, dependency-free Go program: confirmed the leak is real pre-fix (a simulated auth header reaches an attacker-controlled server), confirmed the fix blocks it with zero header leakage, and confirmed a legitimate same-host request still succeeds.
  • Added TestCopilotService_fetchMetricsReport_ForeignHostIsRejected, TestCopilotService_DownloadCopilotMetrics_ForeignHostIsRejected, and TestCopilotService_fetchMetricsReport_MalformedDownloadURL to copilot_test.go, matching this file's existing httptest.NewServer + Transport-wrapping pattern. These are syntactically valid (gofmt-checked) but I couldn't run them against the toolchain for the reason above — please run in CI as part of review.
  • Confirmed every existing test in this file already constructs download URLs as client.baseURL.String() + path, so this shouldn't change any existing test's behavior.

…he client's

DownloadCopilotMetrics, DownloadDailyMetrics, DownloadPeriodicMetrics,
DownloadUserDailyMetrics, DownloadUserPeriodicMetrics,
DownloadRepositoryDailyMetrics, and DownloadUserTeamsDailyMetrics all
take a download URL that the caller is documented to read out of a
prior Get*MetricsReport response's DownloadLinks field, not one it
constructs itself. Both fetchMetricsReport (the shared path for the
six current methods) and the deprecated DownloadCopilotMetrics built
the outgoing request directly from that URL and sent it through the
client's own credentialed HTTP client (fetchMetricsReport via
s.client.client.Do, DownloadCopilotMetrics via s.client.BareDo) with
no check that the URL's host matched the client's configured one.

That client's auth transport attaches the caller's Authorization
header to every request it sends, regardless of destination host --
confirmed by reading github.go's WithAuthToken, which installs a
RoundTripper that unconditionally sets the header before delegating
to the underlying transport. A report response naming a foreign host
(from a compromised or malicious GitHub Enterprise Server instance,
or any position able to influence that response) would have had its
bearer token, and the downloaded report body, sent to that host.

This is the same vulnerability class this repository already fixed
twice elsewhere: bareDoUntilFound's cross-host check on 301 redirects
("a cross-host target would leak credentials"), and
UploadReleaseAssetFromRelease's host check on the release object's
UploadURL field (also server-provided, also merged this same week).
Neither mitigation covers this code path, since both
fetchMetricsReport and DownloadCopilotMetrics build and send their
request independently of bareDoUntilFound and of
UploadReleaseAssetFromRelease's check.

Fix: parse the download URL and compare its host against the client's
configured baseURL.Host before building the request, in both
fetchMetricsReport and DownloadCopilotMetrics, following the same
pattern (url.Parse + strings.EqualFold + a descriptive error) already
established by the UploadURL fix.

Verified:
- gofmt -l reports no issues on either changed file.
- The full module requires go >= 1.26.0, which this sandbox's network
  policy could not download (proxy.golang.org is not reachable), so a
  full `go build`/`go test` of the module itself was not possible
  here. In its place: (1) extracted the exact fix logic (URL parsing,
  host comparison, error construction) into a minimal, dependency-free
  Go program and ran it standalone, confirming (a) the vulnerability
  is real -- a simulated transport-level auth header genuinely reaches
  an attacker-controlled server when no check is present; (b) the fix
  rejects the foreign-host request before it is sent, with zero header
  leakage; (c) a legitimate same-host request still succeeds normally.
  (2) Added real Go tests to copilot_test.go
  (TestCopilotService_fetchMetricsReport_ForeignHostIsRejected,
  TestCopilotService_DownloadCopilotMetrics_ForeignHostIsRejected,
  TestCopilotService_fetchMetricsReport_MalformedDownloadURL) using
  the same httptest.NewServer + Transport-wrapping pattern this file
  already uses in
  TestCopilotService_fetchMetricsReport_closesOriginalBodyOnErrorResponse,
  confirmed syntactically valid via gofmt, but not run against the
  toolchain for the reason above -- these should be run in CI as part
  of review.
- Confirmed the fix's host-comparison logic matches how every existing
  test in this file already constructs download URLs
  (client.baseURL.String() + path), so no existing test's behavior
  should change.
@gmlewis gmlewis changed the title fix(copilot): reject a metrics download URL whose host differs from the client's fix: Reject a metrics download URL whose host differs from the client's Sep 17, 2026
@codecov

codecov Bot commented Sep 17, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.55%. Comparing base (72a2f27) to head (de37305).
⚠️ Report is 1 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #4562      +/-   ##
==========================================
- Coverage   98.57%   98.55%   -0.02%     
==========================================
  Files         197      197              
  Lines       18299    18315      +16     
==========================================
+ Hits        18038    18051      +13     
- Misses        261      264       +3     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@gmlewis

gmlewis commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator

Thanks for the PR. The underlying observation is right — fetchMetricsReport and DownloadCopilotMetrics do send the caller's token to whatever host is named, since they bypass bareDoUntilFound's guard entirely, and it's fair to note that.

I don't want to land it as written, for three reasons:

  1. It doesn't close the hole it describes. Both functions dispatch through s.client.client / BareDo, and credentials are injected in the RoundTripper (github/github.go:612-615) — so they're re-added on every redirected hop. A download URL on the configured host that 302s to another host still leaks the token with this patch applied. Guarding only the caller's URL string doesn't address that, and the new tests don't cover it.
  2. The repo already has the right mechanism for download paths. bareDoUntilFound refuses cross-host 301s, and DownloadReleaseAsset fetches redirect targets with a caller-supplied uncredentialed client. If we want a guard here, it should route through bareDoUntilFound and surface the redirect target the way DownloadReleaseAsset does, rather than adding a fourth pre-flight string check.
  3. The boundary is under active discussion in WithAuthToken authorizes requests outside configured hosts #4366, including the allowed-origin set (baseURL + uploadURL) and comparison granularity. This patch settles both unilaterally as host-only against baseURL — which doesn't even match fix: Reject an upload URL whose host differs from the configured upload host #4556's comparison against uploadURL.Host, and is the same predicate divergence flagged in review on fix: Scope token auth to configured origins #4363.

Also worth noting on the tests: url.Parse rejecting \x7f is already covered at copilot_test.go:4114 with "\n" (and failed before your change too), and the leakedAuth assertions can't fire once the function returns before sending. And the "fixed twice this week" framing is off — the redirect check is #4171 from April.

My suggestion is to hold this until #4366 settles the policy, then implement it once at the injection point.

@gmlewis

gmlewis commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator

See: #4366 (comment)

@prasanna8585

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed review, and for catching the redirect-hop gap specifically that's a real miss on my part. I'd traced the RoundTripper injection at github.go:612-615 for the report and still didn't carry it through to what happens after a redirect, which is exactly the case that matters here. Fair catch.

Agreed on all three points:

  • A same-host URL that 302s elsewhere still leaks the token with this patch, since the check only validates the caller-supplied string, not the redirect target. The tests don't cover that because the patch doesn't handle it.
  • bareDoUntilFound and DownloadReleaseAsset's uncredentialed-redirect-following pattern is the right mechanism for this, and I should have routed through it instead of adding a separate check.
  • I didn't realize WithAuthToken authorizes requests outside configured hosts #4366 was actively deciding the allowed-origin set, and picking baseURL-only here inconsistent with fix: Reject an upload URL whose host differs from the configured upload host #4556's uploadURL.Host comparison would've unilaterally pre-empted that. That's on me for not checking open issues first.

Also noted: the \x7f test does duplicate copilot_test.go:4114, and the "fixed twice this week" framing was wrong #4171 is from April, not this week. Thanks for the correction.

Happy to hold this per your suggestion. I'll watch #4366 and come back with a single implementation at the injection point once the origin-set policy is settled, matching whatever bareDoUntilFound/DownloadReleaseAsset end up doing. Closing/holding this PR in the meantime let me know if you'd rather I close it now or leave it open as a reference until #4366 lands.

@gmlewis

gmlewis commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator

I'll watch #4366 and come back with a single implementation at the injection point once the origin-set policy is settled, matching whatever bareDoUntilFound/DownloadReleaseAsset end up doing. Closing/holding this PR in the meantime let me know if you'd rather I close it now or leave it open as a reference until #4366 lands.

Please take a look at #4564 and make sure you are happy with that impementation, as I believe it should solve the problem across the entire client library.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants