Limit release asset redirects

This commit is contained in:
2026-11-02 03:28:24 +00:00
parent 23b3a3d340
commit 76676ee5a6
4 changed files with 98 additions and 2 deletions

View File

@@ -87,7 +87,8 @@ Unknown-write outcomes are reserved for unresolved writes. Unrelated attachments
Attachment downloads and redirects carry the Gitea authorization token only Attachment downloads and redirects carry the Gitea authorization token only
when their scheme, host, and port match the configured service. Cross-origin when their scheme, host, and port match the configured service. Cross-origin
content is downloaded without that token and verified by its digest. content is downloaded without that token and verified by its digest. Downloads
follow at most ten redirects; an additional redirect is unverifiable.
Shared delivery uploads only after inspection finds no conflicts or Shared delivery uploads only after inspection finds no conflicts or
unverifiable assets. It uploads missing binaries in filename order and then the unverifiable assets. It uploads missing binaries in filename order and then the

View File

@@ -47,7 +47,7 @@ the agent policy; an intermediate compilation dependency is recorded accurately.
| 9 | A16 | Complete — shared declaration, receipt, delivery, prebuilt, and source-only regressions passed. | | 9 | A16 | Complete — shared declaration, receipt, delivery, prebuilt, and source-only regressions passed. |
| 10 | A07: allocation and temporary metadata | Complete — allocation resolution, joined cleanup causes, tag evidence, and receipt installation checks passed. | | 10 | A07: allocation and temporary metadata | Complete — allocation resolution, joined cleanup causes, tag evidence, and receipt installation checks passed. |
| 11 | A07: build and delivery outcomes | Complete — managed and native cleanup tests cover success, failure, cancellation, hosted completion, and CLI reporting. | | 11 | A07: build and delivery outcomes | Complete — managed and native cleanup tests cover success, failure, cancellation, hosted completion, and CLI reporting. |
| 12 | A08 | Pending | | 12 | A08 | Complete — redirect boundary, origin authorization, cycle, and delivery outcome tests passed. |
| 13 | A09 | Pending | | 13 | A09 | Pending |
| 14 | A10 | Pending | | 14 | A10 | Pending |
| 15 | A13 | Pending | | 15 | A13 | Pending |

View File

@@ -56,6 +56,7 @@ func (c Client) openAsset(ctx context.Context, client *http.Client, base *url.UR
} }
assetURL.Fragment = "" assetURL.Fragment = ""
visited := make(map[string]struct{}) visited := make(map[string]struct{})
followed := 0
for { for {
key := assetURL.String() key := assetURL.String()
if _, exists := visited[key]; exists { if _, exists := visited[key]; exists {
@@ -75,6 +76,10 @@ func (c Client) openAsset(ctx context.Context, client *http.Client, base *url.UR
return nil, failure(FailureUnverifiable, "download release asset", 0, err) return nil, failure(FailureUnverifiable, "download release asset", 0, err)
} }
if isRedirect(response.StatusCode) { if isRedirect(response.StatusCode) {
if followed == 10 {
_ = response.Body.Close()
return nil, failure(FailureUnverifiable, "redirect release asset: redirect limit exceeded", response.StatusCode, errors.New("at most 10 redirects are allowed"))
}
location, locationErr := response.Location() location, locationErr := response.Location()
_ = response.Body.Close() _ = response.Body.Close()
if locationErr != nil || location.User != nil { if locationErr != nil || location.User != nil {
@@ -82,6 +87,7 @@ func (c Client) openAsset(ctx context.Context, client *http.Client, base *url.UR
} }
location.Fragment = "" location.Fragment = ""
assetURL = location assetURL = location
followed++
continue continue
} }
if response.StatusCode < 200 || response.StatusCode >= 300 { if response.StatusCode < 200 || response.StatusCode >= 300 {

View File

@@ -4,15 +4,104 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"io" "io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strconv"
"strings"
"sync/atomic" "sync/atomic"
"testing" "testing"
"go.maximumdirect.net/releasekit/internal/forge" "go.maximumdirect.net/releasekit/internal/forge"
) )
func TestBackendOpenAssetBoundsRedirects(t *testing.T) {
for _, hops := range []int{0, 10, 11} {
t.Run(strconv.Itoa(hops), func(t *testing.T) {
var requests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
if got := r.Header.Get("Authorization"); got != "token fixture-secret-token" {
t.Errorf("authorization = %q", got)
}
step, err := strconv.Atoi(strings.TrimPrefix(r.URL.Path, "/"))
if err != nil {
t.Errorf("invalid path %q", r.URL.Path)
return
}
if step < hops {
http.Redirect(w, r, fmt.Sprintf("/%d", step+1), http.StatusFound)
return
}
_, _ = io.WriteString(w, "asset")
}))
defer server.Close()
backend := NewBackend(testDestination(server.URL), server.Client())
stream, err := backend.OpenAsset(t.Context(), assetHandle{downloadURL: "/0"})
if hops <= 10 {
if err != nil {
t.Fatal(err)
}
body, readErr := io.ReadAll(stream)
closeErr := stream.Close()
if readErr != nil || closeErr != nil || string(body) != "asset" || requests.Load() != int32(hops+1) {
t.Fatalf("body = %q, read = %v, close = %v, requests = %d", body, readErr, closeErr, requests.Load())
}
return
}
var failure *forge.Failure
if !errors.As(err, &failure) || failure.Kind != forge.FailureUnverifiable || !strings.Contains(err.Error(), "redirect limit") || requests.Load() != 11 || stream != nil {
t.Fatalf("stream = %v, error = %v, requests = %d", stream, err, requests.Load())
}
})
}
}
func TestBackendOpenAssetRedirectReturnsToOrigin(t *testing.T) {
var origin *httptest.Server
external := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "" {
t.Errorf("cross-origin authorization = %q", got)
}
http.Redirect(w, r, origin.URL+"/final", http.StatusFound)
}))
defer external.Close()
origin = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "token fixture-secret-token" {
t.Errorf("origin authorization = %q", got)
}
if r.URL.Path == "/start" {
http.Redirect(w, r, external.URL+"/away", http.StatusFound)
return
}
_, _ = io.WriteString(w, "asset")
}))
defer origin.Close()
stream, err := NewBackend(testDestination(origin.URL), origin.Client()).OpenAsset(t.Context(), assetHandle{downloadURL: "/start"})
if err != nil {
t.Fatal(err)
}
defer stream.Close()
if body, err := io.ReadAll(stream); err != nil || string(body) != "asset" {
t.Fatalf("body = %q, error = %v", body, err)
}
}
func TestBackendOpenAssetRejectsRedirectCycle(t *testing.T) {
var requests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
http.Redirect(w, r, "/loop#different-fragment", http.StatusFound)
}))
defer server.Close()
stream, err := NewBackend(testDestination(server.URL), server.Client()).OpenAsset(t.Context(), assetHandle{downloadURL: "/loop"})
var failure *forge.Failure
if !errors.As(err, &failure) || failure.Kind != forge.FailureUnverifiable || !strings.Contains(err.Error(), "loop") || requests.Load() != 1 || stream != nil {
t.Fatalf("stream = %v, error = %v, requests = %d", stream, err, requests.Load())
}
}
func TestBackendListAssetsRejectsMalformedAttachmentList(t *testing.T) { func TestBackendListAssetsRejectsMalformedAttachmentList(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != "/api/v1/repos/owner/project/releases/10/assets" { if r.Method != http.MethodGet || r.URL.Path != "/api/v1/repos/owner/project/releases/10/assets" {