Skip to content

Commit a98ef75

Browse files
authored
[fix] Pin updater download URLs to trusted GitHub hosts (#281) (#296)
* [fix] Pin updater asset URLs to trusted GitHub hosts (#281) Add allowedAssetHosts allowlist (github.com + objects.githubusercontent.com) and validateAssetURL helper. Call it in Check() after the empty-URL guards, before assigning DownloadURL/ChecksumsURL to the result — a MITM/DNS-hijack that tampers with the GitHub API response can no longer redirect the binary or checksums.txt to an attacker-controlled host. Replace the stale comment in findReleaseAssets that incorrectly argued checksum verification alone was sufficient mitigation. * [test] Validate asset URL helper and migrate fixtures (#281) Add TestValidateAssetURL (7 cases: happy paths for both allowed hosts, http scheme rejection, untrusted host, subdomain spoof, unparseable URL, empty string). Add TestCheckRejectsUntrustedDownloadHost and TestCheckRejectsUntrustedChecksumsHost integration tests at the Check() level. Migrate all example.com fixture URLs in updater_test.go and cmd/ tests to https://github.com/... so existing tests pass through the new URL validation. * [fix] Address review feedback on validateAssetURL (#281) Add inline comment on the empty-string guard clarifying it is defensive (Check() pre-screens, but future callers may not). Add test case for evil.objects.githubusercontent.com subdomain spoof to document that the exact-match allowlist correctly rejects it. * [chore] Simplify comments in validateAssetURL and related vars
1 parent 85748ec commit a98ef75

4 files changed

Lines changed: 146 additions & 16 deletions

File tree

cmd/root_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -284,8 +284,8 @@ func TestRootHelpFunctionRoot(t *testing.T) {
284284
_, _ = w.Write([]byte(`{
285285
"tag_name":"v99.0.0",
286286
"assets":[
287-
{"name":"rimba_99.0.0_linux_amd64.tar.gz","browser_download_url":"https://example.com/download"},
288-
{"name":"checksums.txt","browser_download_url":"https://example.com/checksums.txt"}
287+
{"name":"rimba_99.0.0_linux_amd64.tar.gz","browser_download_url":"https://github.com/lugassawan/rimba/releases/download/v99.0.0/rimba_99.0.0_linux_amd64.tar.gz"},
288+
{"name":"checksums.txt","browser_download_url":"https://github.com/lugassawan/rimba/releases/download/v99.0.0/checksums.txt"}
289289
]
290290
}`))
291291
}))

cmd/update_hint_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,8 @@ func TestCheckUpdateHintNewVersionAvailable(t *testing.T) {
4040
_, _ = w.Write([]byte(`{
4141
"tag_name":"` + testVersionNew + `",
4242
"assets":[
43-
{"name":"rimba_2.0.0_linux_amd64.tar.gz","browser_download_url":"https://example.com/download"},
44-
{"name":"checksums.txt","browser_download_url":"https://example.com/checksums.txt"}
43+
{"name":"rimba_2.0.0_linux_amd64.tar.gz","browser_download_url":"https://github.com/lugassawan/rimba/releases/download/v2.0.0/rimba_2.0.0_linux_amd64.tar.gz"},
44+
{"name":"checksums.txt","browser_download_url":"https://github.com/lugassawan/rimba/releases/download/v2.0.0/checksums.txt"}
4545
]
4646
}`))
4747
}))
@@ -95,7 +95,7 @@ func TestCheckUpdateHintTimeout(t *testing.T) {
9595
_, _ = w.Write([]byte(`{
9696
"tag_name":"` + testVersionNew + `",
9797
"assets":[
98-
{"name":"rimba_2.0.0_linux_amd64.tar.gz","browser_download_url":"https://example.com/download"}
98+
{"name":"rimba_2.0.0_linux_amd64.tar.gz","browser_download_url":"https://github.com/lugassawan/rimba/releases/download/v2.0.0/rimba_2.0.0_linux_amd64.tar.gz"}
9999
]
100100
}`))
101101
}))

internal/updater/updater.go

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"fmt"
1111
"io"
1212
"net/http"
13+
"net/url"
1314
"os"
1415
"path/filepath"
1516
"runtime"
@@ -26,6 +27,13 @@ const (
2627
goosWindows = "windows"
2728
)
2829

30+
// allowedAssetHosts pins asset downloads to GitHub-controlled hosts.
31+
// objects.githubusercontent.com is included for CDN redirects followed at HTTP fetch time.
32+
var allowedAssetHosts = map[string]bool{
33+
"github.com": true,
34+
"objects.githubusercontent.com": true,
35+
}
36+
2937
// maxBinarySize is the decompressed size limit for the extracted binary (100 MiB).
3038
// Tests may override this to exercise the size-limit path.
3139
var maxBinarySize int64 = 100 << 20
@@ -138,6 +146,12 @@ func (u *Updater) Check(ctx context.Context) (*CheckResult, error) {
138146
if checksumsURL == "" {
139147
return nil, fmt.Errorf("%s not found in release %s", checksumsFileName, release.TagName)
140148
}
149+
if err := validateAssetURL(downloadURL); err != nil {
150+
return nil, fmt.Errorf("untrusted download URL: %w", err)
151+
}
152+
if err := validateAssetURL(checksumsURL); err != nil {
153+
return nil, fmt.Errorf("untrusted checksums URL: %w", err)
154+
}
141155
result.DownloadURL = downloadURL
142156
result.AssetName = assetName
143157
result.ChecksumsURL = checksumsURL
@@ -353,10 +367,7 @@ func assetNameFor(goos, goarch, version string) string {
353367
}
354368

355369
// findReleaseAssets scans release assets for the platform archive and checksums file.
356-
// Returns empty strings for any asset not found.
357-
// BrowserDownloadURL values are used verbatim; checksum verification (issue #222) is
358-
// the intended mitigation against redirected-URL attacks rather than host-prefix
359-
// validation, which would need to enumerate all valid GitHub CDN hostnames.
370+
// Returns empty strings for any asset not found. URLs are validated by Check before use.
360371
func findReleaseAssets(assetName string, assets []Asset) (downloadURL, checksumsURL string) {
361372
for _, a := range assets {
362373
if a.Name == assetName {
@@ -368,3 +379,23 @@ func findReleaseAssets(assetName string, assets []Asset) (downloadURL, checksums
368379
}
369380
return
370381
}
382+
383+
// validateAssetURL rejects asset URLs with a non-https scheme or untrusted host,
384+
// guarding against a tampered API response redirecting downloads to an attacker (#281).
385+
func validateAssetURL(rawURL string) error {
386+
// Check() pre-screens for empty, but guard here for direct callers.
387+
if rawURL == "" {
388+
return errors.New("asset URL must not be empty")
389+
}
390+
u, err := url.Parse(rawURL)
391+
if err != nil {
392+
return fmt.Errorf("parsing asset URL %q: %w", rawURL, err)
393+
}
394+
if u.Scheme != "https" {
395+
return fmt.Errorf("asset URL %q must use https, got scheme %q", rawURL, u.Scheme)
396+
}
397+
if !allowedAssetHosts[u.Hostname()] {
398+
return fmt.Errorf("asset URL %q host %q is not a trusted GitHub host", rawURL, u.Hostname())
399+
}
400+
return nil
401+
}

internal/updater/updater_test.go

Lines changed: 106 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ func serveOctetStream(t *testing.T, data []byte) *httptest.Server {
8888
func releaseJSON(tagName string, extras ...string) string {
8989
assets := make([]string, 0, 1+len(extras))
9090
assets = append(assets,
91-
`{"name":"checksums.txt","browser_download_url":"https://example.com/checksums.txt"}`,
91+
`{"name":"checksums.txt","browser_download_url":"https://github.com/lugassawan/rimba/releases/download/v2.0.0/checksums.txt"}`,
9292
)
9393
assets = append(assets, extras...)
9494
return fmt.Sprintf(`{"tag_name":%q,"assets":[%s]}`, tagName, strings.Join(assets, ","))
@@ -110,7 +110,7 @@ func TestCheckUpToDate(t *testing.T) {
110110

111111
func TestCheckNewVersionAvailable(t *testing.T) {
112112
srv := serveJSON(t, releaseJSON(testVersionNew,
113-
`{"name":"rimba_2.0.0_linux_amd64.tar.gz","browser_download_url":"https://example.com/rimba_2.0.0_linux_amd64.tar.gz"}`,
113+
`{"name":"rimba_2.0.0_linux_amd64.tar.gz","browser_download_url":"https://github.com/lugassawan/rimba/releases/download/v2.0.0/rimba_2.0.0_linux_amd64.tar.gz"}`,
114114
))
115115
u := newTestUpdater(srv)
116116

@@ -120,7 +120,7 @@ func TestCheckNewVersionAvailable(t *testing.T) {
120120
t.Errorf("expected not up to date")
121121
}
122122

123-
wantURL := "https://example.com/rimba_2.0.0_linux_amd64.tar.gz"
123+
wantURL := "https://github.com/lugassawan/rimba/releases/download/v2.0.0/rimba_2.0.0_linux_amd64.tar.gz"
124124
if result.DownloadURL != wantURL {
125125
t.Errorf("download URL = %q, want %q", result.DownloadURL, wantURL)
126126
}
@@ -130,7 +130,7 @@ func TestCheckNewVersionAvailable(t *testing.T) {
130130
t.Errorf("AssetName = %q, want %q", result.AssetName, wantAsset)
131131
}
132132

133-
wantChecksums := "https://example.com/checksums.txt"
133+
wantChecksums := "https://github.com/lugassawan/rimba/releases/download/v2.0.0/checksums.txt"
134134
if result.ChecksumsURL != wantChecksums {
135135
t.Errorf("ChecksumsURL = %q, want %q", result.ChecksumsURL, wantChecksums)
136136
}
@@ -140,7 +140,7 @@ func TestCheckNoMatchingAsset(t *testing.T) {
140140
srv := serveJSON(t, `{
141141
"tag_name":"`+testVersionNew+`",
142142
"assets":[
143-
{"name":"rimba_2.0.0_windows_amd64.zip","browser_download_url":"https://example.com/rimba_2.0.0_windows_amd64.zip"}
143+
{"name":"rimba_2.0.0_windows_amd64.zip","browser_download_url":"https://github.com/lugassawan/rimba/releases/download/v2.0.0/rimba_2.0.0_windows_amd64.zip"}
144144
]
145145
}`)
146146
u := newTestUpdater(srv)
@@ -160,7 +160,7 @@ func TestCheckMissingChecksums(t *testing.T) {
160160
srv := serveJSON(t, fmt.Sprintf(`{
161161
"tag_name":%q,
162162
"assets":[
163-
{"name":"rimba_2.0.0_linux_amd64.tar.gz","browser_download_url":"https://example.com/rimba_2.0.0_linux_amd64.tar.gz"}
163+
{"name":"rimba_2.0.0_linux_amd64.tar.gz","browser_download_url":"https://github.com/lugassawan/rimba/releases/download/v2.0.0/rimba_2.0.0_linux_amd64.tar.gz"}
164164
]
165165
}`, testVersionNew))
166166
u := newTestUpdater(srv)
@@ -176,7 +176,7 @@ func TestCheckMissingChecksums(t *testing.T) {
176176

177177
func TestCheckWindowsAsset(t *testing.T) {
178178
srv := serveJSON(t, releaseJSON(testVersionNew,
179-
`{"name":"rimba_2.0.0_windows_amd64.zip","browser_download_url":"https://example.com/rimba_2.0.0_windows_amd64.zip"}`,
179+
`{"name":"rimba_2.0.0_windows_amd64.zip","browser_download_url":"https://github.com/lugassawan/rimba/releases/download/v2.0.0/rimba_2.0.0_windows_amd64.zip"}`,
180180
))
181181
u := &Updater{
182182
CurrentVersion: testVersion,
@@ -1534,3 +1534,102 @@ func TestAssetNameFor(t *testing.T) {
15341534
})
15351535
}
15361536
}
1537+
1538+
// ---- validateAssetURL tests ----
1539+
1540+
func TestValidateAssetURL(t *testing.T) {
1541+
tests := []struct {
1542+
name string
1543+
url string
1544+
wantErr string
1545+
}{
1546+
{
1547+
name: "github.com happy path",
1548+
url: "https://github.com/lugassawan/rimba/releases/download/v2.0.0/rimba_2.0.0_linux_amd64.tar.gz",
1549+
},
1550+
{
1551+
name: "objects.githubusercontent.com happy path",
1552+
url: "https://objects.githubusercontent.com/github-production-release-asset/12345/rimba_2.0.0_linux_amd64.tar.gz",
1553+
},
1554+
{
1555+
name: "http scheme rejected",
1556+
url: "http://github.com/lugassawan/rimba/releases/download/v2.0.0/rimba_2.0.0_linux_amd64.tar.gz",
1557+
wantErr: "must use https",
1558+
},
1559+
{
1560+
name: "untrusted host rejected",
1561+
url: "https://evil.com/lugassawan/rimba/releases/download/v2.0.0/rimba_2.0.0_linux_amd64.tar.gz",
1562+
wantErr: "not a trusted GitHub host",
1563+
},
1564+
{
1565+
name: "subdomain spoof rejected",
1566+
url: "https://github.com.evil.com/lugassawan/rimba/releases/download/v2.0.0/rimba_2.0.0_linux_amd64.tar.gz",
1567+
wantErr: "not a trusted GitHub host",
1568+
},
1569+
{
1570+
name: "objects.githubusercontent.com subdomain spoof rejected",
1571+
url: "https://evil.objects.githubusercontent.com/rimba_2.0.0_linux_amd64.tar.gz",
1572+
wantErr: "not a trusted GitHub host",
1573+
},
1574+
{
1575+
name: "unparseable URL rejected",
1576+
url: "https://github.com/%zz",
1577+
wantErr: "parsing asset URL",
1578+
},
1579+
{
1580+
name: "empty URL rejected",
1581+
url: "",
1582+
wantErr: "must not be empty",
1583+
},
1584+
}
1585+
1586+
for _, tt := range tests {
1587+
t.Run(tt.name, func(t *testing.T) {
1588+
err := validateAssetURL(tt.url)
1589+
if tt.wantErr == "" {
1590+
if err != nil {
1591+
t.Errorf("unexpected error: %v", err)
1592+
}
1593+
return
1594+
}
1595+
if err == nil {
1596+
t.Fatalf("expected error containing %q, got nil", tt.wantErr)
1597+
}
1598+
if !strings.Contains(err.Error(), tt.wantErr) {
1599+
t.Errorf("error = %q, want to contain %q", err.Error(), tt.wantErr)
1600+
}
1601+
})
1602+
}
1603+
}
1604+
1605+
func TestCheckRejectsUntrustedDownloadHost(t *testing.T) {
1606+
srv := serveJSON(t, releaseJSON(testVersionNew,
1607+
`{"name":"rimba_2.0.0_linux_amd64.tar.gz","browser_download_url":"https://evil.com/rimba_2.0.0_linux_amd64.tar.gz"}`,
1608+
))
1609+
u := newTestUpdater(srv)
1610+
1611+
_, err := u.Check(context.Background())
1612+
if err == nil {
1613+
t.Fatal("expected error for untrusted download URL host")
1614+
}
1615+
if !strings.Contains(err.Error(), "untrusted download URL") {
1616+
t.Errorf("error = %q, want to contain 'untrusted download URL'", err.Error())
1617+
}
1618+
}
1619+
1620+
func TestCheckRejectsUntrustedChecksumsHost(t *testing.T) {
1621+
body := fmt.Sprintf(`{"tag_name":%q,"assets":[
1622+
{"name":"rimba_2.0.0_linux_amd64.tar.gz","browser_download_url":"https://github.com/lugassawan/rimba/releases/download/v2.0.0/rimba_2.0.0_linux_amd64.tar.gz"},
1623+
{"name":"checksums.txt","browser_download_url":"https://evil.com/checksums.txt"}
1624+
]}`, testVersionNew)
1625+
srv := serveJSON(t, body)
1626+
u := newTestUpdater(srv)
1627+
1628+
_, err := u.Check(context.Background())
1629+
if err == nil {
1630+
t.Fatal("expected error for untrusted checksums URL host")
1631+
}
1632+
if !strings.Contains(err.Error(), "untrusted checksums URL") {
1633+
t.Errorf("error = %q, want to contain 'untrusted checksums URL'", err.Error())
1634+
}
1635+
}

0 commit comments

Comments
 (0)