Verify external assets without forwarding Gitea credentials

This commit is contained in:
2026-10-06 01:01:36 +00:00
parent 8b417760c6
commit 6b777dd861
4 changed files with 60 additions and 28 deletions

View File

@@ -161,8 +161,8 @@ func (c Client) downloadHash(ctx context.Context, client *http.Client, base *url
return "", 0, failure(FailureUnverifiable, "download release asset", 0, errors.New("attachment has no download URL"))
}
assetURL, err := base.Parse(rawURL)
if err != nil || !sameOrigin(base, assetURL) || assetURL.User != nil {
return "", 0, failure(FailureUnverifiable, "download release asset", 0, errors.New("asset URL is outside the configured service origin"))
if err != nil || assetURL.User != nil {
return "", 0, failure(FailureUnverifiable, "download release asset", 0, errors.New("invalid attachment URL"))
}
assetURL.Fragment = ""
visited := make(map[string]struct{})
@@ -176,7 +176,9 @@ func (c Client) downloadHash(ctx context.Context, client *http.Client, base *url
if err != nil {
return "", 0, failure(FailureUnverifiable, "download release asset", 0, err)
}
request.Header.Set("Authorization", "token "+token)
if sameOrigin(base, assetURL) {
request.Header.Set("Authorization", "token "+token)
}
request.Header.Set("Accept", "application/octet-stream")
response, err := client.Do(request)
if err != nil {
@@ -185,8 +187,8 @@ func (c Client) downloadHash(ctx context.Context, client *http.Client, base *url
if isRedirect(response.StatusCode) {
location, locationErr := response.Location()
_ = response.Body.Close()
if locationErr != nil || !sameOrigin(base, location) || location.User != nil {
return "", 0, failure(FailureUnverifiable, "redirect release asset", response.StatusCode, errors.New("asset redirect is outside the configured service origin"))
if locationErr != nil || location.User != nil {
return "", 0, failure(FailureUnverifiable, "redirect release asset", response.StatusCode, errors.New("invalid attachment redirect"))
}
location.Fragment = ""
assetURL = location

View File

@@ -249,26 +249,48 @@ func TestInspectAssetsFollowsSameOriginRedirectWithAuthorization(t *testing.T) {
}
}
func TestInspectAssetsNeverSendsTokenToCrossOriginRedirect(t *testing.T) {
var externalCalled atomic.Bool
external := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { externalCalled.Store(true) }))
defer external.Close()
expected := assetFor("app.bin", []byte("alpha"))
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assertAssetGET(t, r, "")
if r.URL.Path == assetListPath {
writeAssetList(t, w, []assetResponse{{ID: 1, Name: expected.Filename, Size: expected.Size, BrowserDownloadURL: "/files/redirect"}})
return
}
http.Redirect(w, r, external.URL+"/private", http.StatusFound)
}))
defer server.Close()
inspection, err := testClient(server).InspectAssets(context.Background(), testDestination(server.URL), 10, []ExpectedAsset{expected})
if err != nil {
t.Fatal(err)
}
if inspection.Assets[0].State != AssetUnverifiable || externalCalled.Load() {
t.Fatalf("inspection = %#v, cross-origin called = %t", inspection, externalCalled.Load())
func TestInspectAssetsVerifiesExternalContentWithoutGiteaToken(t *testing.T) {
for _, mode := range []string{"direct", "redirect", "conflicting"} {
t.Run(mode, func(t *testing.T) {
expected := assetFor("app.bin", []byte("matching bytes"))
externalGets := 0
external := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
externalGets++
if r.Header.Get("Authorization") != "" {
t.Error("Gitea token reached external host")
}
content := []byte("matching bytes")
if mode == "conflicting" {
content = []byte("different bytes")
}
w.Write(content)
}))
defer external.Close()
origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assertAssetGET(t, r, "")
if r.URL.Path == assetListPath {
download := external.URL + "/content"
if mode == "redirect" {
download = "/redirect"
}
writeAssetList(t, w, []assetResponse{{ID: 1, Name: expected.Filename, Size: expected.Size, BrowserDownloadURL: download}})
return
}
http.Redirect(w, r, external.URL+"/content", http.StatusFound)
}))
defer origin.Close()
inspection, err := testClient(origin).InspectAssets(context.Background(), testDestination(origin.URL), 10, []ExpectedAsset{expected})
if err != nil {
t.Fatal(err)
}
want := AssetMatching
if mode == "conflicting" {
want = AssetConflicting
}
if inspection.Assets[0].State != want || externalGets != 1 {
t.Fatalf("inspection=%#v downloads=%d", inspection, externalGets)
}
})
}
}