mirror of
https://github.com/git-pkgs/proxy.git
synced 2026-11-09 07:39:19 -05:00
275 lines
11 KiB
Go
275 lines
11 KiB
Go
package handler
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"testing/iotest"
|
|
|
|
"github.com/git-pkgs/cooldown"
|
|
"github.com/go-chi/chi/v5/middleware"
|
|
)
|
|
|
|
// Exercise the actual routes as well as the two shared entry points. Error
|
|
// responses also reach relay branches in handlers that normally parse metadata.
|
|
func relayTestRoutes(proxy *Proxy, upstream string) http.Handler {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/upstream", func(w http.ResponseWriter, r *http.Request) {
|
|
proxy.ProxyUpstream(w, r, upstream, nil)
|
|
})
|
|
mux.HandleFunc("/file", func(w http.ResponseWriter, r *http.Request) {
|
|
proxy.ProxyFile(w, r, upstream)
|
|
})
|
|
mux.HandleFunc("/metadata", func(w http.ResponseWriter, r *http.Request) {
|
|
proxy.ProxyCached(w, r, upstream, "test", "index")
|
|
})
|
|
const proxyURL = "http://proxy.local"
|
|
mount := func(prefix string, h http.Handler) {
|
|
mux.Handle(prefix+"/", http.StripPrefix(prefix, h))
|
|
}
|
|
mount("/nuget", NewNuGetHandlerWithUpstreams(proxy, proxyURL, upstream, upstream).Routes())
|
|
mount("/conan", NewConanHandlerWithUpstream(proxy, proxyURL, upstream).Routes())
|
|
mount("/composer", NewComposerHandlerWithUpstreams(proxy, proxyURL, upstream, upstream).Routes())
|
|
mount("/pypi", NewPyPIHandlerWithUpstreams(proxy, proxyURL, upstream, upstream).Routes())
|
|
mount("/gem", NewGemHandlerWithUpstream(proxy, proxyURL, upstream).Routes())
|
|
mount("/conda", NewCondaHandlerWithUpstream(proxy, proxyURL, upstream).Routes())
|
|
mount("/hex", NewHexHandlerWithUpstreams(proxy, proxyURL, upstream, upstream).Routes())
|
|
mount("/swift", NewSwiftHandler(proxy, proxyURL, upstream).Routes())
|
|
mount("/v2", NewContainerHandlerWithRegistry(proxy, proxyURL, upstream).Routes())
|
|
mount("/npm", NewNPMHandler(proxy, proxyURL, upstream).Routes())
|
|
// Match the production recovery middleware: it must not swallow the abort.
|
|
return middleware.Recoverer(mux)
|
|
}
|
|
|
|
func TestRelayRoutes(t *testing.T) {
|
|
for _, route := range []string{
|
|
"/upstream", "/file", "/metadata", "/nuget/query",
|
|
"/conan/v2/conans/demo/1.0/user/stable/revisions/rev/files/other.txt",
|
|
"/composer/search.json", "/pypi/simple/", "/gem/api/v1/dependencies",
|
|
"/conda/conda-forge/noarch/repodata.json",
|
|
"/v2/library/demo/manifests/latest", "/v2/library/demo/tags/list",
|
|
"/npm/-/npm/v1/keys",
|
|
} {
|
|
t.Run(route, func(t *testing.T) {
|
|
for _, truncated := range []bool{false, true} {
|
|
t.Run(fmt.Sprintf("truncated=%v", truncated), func(t *testing.T) {
|
|
testRelayRoute(t, route, truncated)
|
|
})
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func testRelayRoute(t *testing.T, route string, truncated bool) {
|
|
t.Helper()
|
|
// Large enough to commit downstream headers before a body-copy failure.
|
|
body := strings.Repeat("x", 64*1024)
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
conn, rw, err := w.(http.Hijacker).Hijack()
|
|
if err != nil {
|
|
t.Error(err)
|
|
return
|
|
}
|
|
defer func() { _ = conn.Close() }()
|
|
_, _ = fmt.Fprintf(rw, "HTTP/1.1 502 Bad Gateway\r\nContent-Type: text/plain\r\nConnection: X-Private\r\nX-Private: secret\r\nTransfer-Encoding: chunked\r\nTrailer: X-Checksum, X-Private\r\n\r\n%x\r\n%s\r\n", len(body), body)
|
|
if !truncated {
|
|
_, _ = fmt.Fprint(rw, "0\r\nX-Checksum: verified\r\nX-Late: discovered-at-eof\r\nX-Private: still-secret\r\n\r\n")
|
|
}
|
|
// The proxy may drop the connection before reading the whole
|
|
// response. What it made of the response under test is asserted
|
|
// downstream.
|
|
_ = rw.Flush()
|
|
}))
|
|
defer upstream.Close()
|
|
proxy := testProxy()
|
|
proxy.Logger = slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
proxy.HTTPClient = upstream.Client()
|
|
proxy.Cooldown = &cooldown.Config{Default: "3d"}
|
|
downstream := httptest.NewServer(relayTestRoutes(proxy, upstream.URL))
|
|
defer downstream.Close()
|
|
|
|
resp, err := downstream.Client().Get(downstream.URL + route)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
got, readErr := io.ReadAll(resp.Body)
|
|
if resp.StatusCode != http.StatusBadGateway {
|
|
t.Fatalf("status = %d, want 502", resp.StatusCode)
|
|
}
|
|
if resp.Header.Get("Connection") != "" || resp.Header.Get("X-Private") != "" || resp.Trailer.Get("X-Private") != "" {
|
|
t.Fatalf("connection-scoped fields leaked: headers=%v trailers=%v", resp.Header, resp.Trailer)
|
|
}
|
|
if truncated {
|
|
if readErr == nil {
|
|
t.Fatal("truncated upstream was delivered as a complete response")
|
|
}
|
|
return
|
|
}
|
|
if readErr != nil || string(got) != body {
|
|
t.Fatalf("body length = %d, want %d; error = %v", len(got), len(body), readErr)
|
|
}
|
|
if resp.Trailer.Get("X-Checksum") != "verified" || resp.Trailer.Get("X-Late") != "discovered-at-eof" {
|
|
t.Fatalf("trailers not relayed: %v", resp.Trailer)
|
|
}
|
|
if route == "/pypi/simple/" && !strings.Contains(resp.Header.Get("Vary"), "Accept") {
|
|
t.Error("PyPI lost Vary: Accept")
|
|
}
|
|
}
|
|
|
|
func TestRelayResponseHeaders(t *testing.T) {
|
|
headers := http.Header{
|
|
"Connection": {"keep-alive, x-private", " X-Second, Content-Length "},
|
|
"X-Private": {"secret"}, "X-Second": {"secret"},
|
|
"Keep-Alive": {"timeout=5"}, "Proxy-Connection": {"keep-alive"},
|
|
"Proxy-Authenticate": {"challenge"}, "Proxy-Authorization": {"credentials"},
|
|
"Te": {"trailers"}, "Trailer": {"X-Untrusted"},
|
|
"Transfer-Encoding": {"chunked"}, "Upgrade": {"websocket"},
|
|
"Content-Length": {"999"}, "Content-Type": {"application/octet-stream"},
|
|
"Content-Encoding": {"gzip"}, "Etag": {`"v1"`},
|
|
"Set-Cookie": {"a=1", "b=2"}, "Www-Authenticate": {"Bearer realm=test"},
|
|
}
|
|
resp := &http.Response{StatusCode: http.StatusOK, Header: headers, Body: io.NopCloser(strings.NewReader("body"))}
|
|
w := httptest.NewRecorder()
|
|
w.Header().Set("X-Request-ID", "local")
|
|
testProxy().relayResponse(w, httptest.NewRequest(http.MethodGet, "/", nil), resp, nil)
|
|
for _, name := range []string{
|
|
"Connection", "Keep-Alive", "Proxy-Connection", "Proxy-Authenticate",
|
|
"Proxy-Authorization", "Te", "Trailer", "Transfer-Encoding", "Upgrade",
|
|
"X-Private", "X-Second", "Content-Length",
|
|
} {
|
|
if got := w.Header().Get(name); got != "" {
|
|
t.Errorf("hop-by-hop header %s survived: %q", name, got)
|
|
}
|
|
}
|
|
for _, name := range []string{"Content-Type", "Content-Encoding", "Etag", "Set-Cookie", "Www-Authenticate"} {
|
|
if got := strings.Join(w.Header().Values(name), ","); got != strings.Join(headers.Values(name), ",") {
|
|
t.Errorf("end-to-end header %s changed: %q", name, got)
|
|
}
|
|
}
|
|
if w.Header().Get("X-Request-ID") != "local" || w.Body.String() != "body" {
|
|
t.Fatal("local header or response body changed")
|
|
}
|
|
if headers.Get("X-Private") != "secret" {
|
|
t.Fatal("relay mutated the upstream response headers")
|
|
}
|
|
}
|
|
|
|
func TestRelayResponseBodiless(t *testing.T) {
|
|
for _, tt := range []struct {
|
|
method string
|
|
status int
|
|
length string
|
|
}{
|
|
{http.MethodHead, http.StatusOK, "99"},
|
|
{http.MethodGet, http.StatusNoContent, ""},
|
|
{http.MethodGet, http.StatusResetContent, "0"},
|
|
{http.MethodGet, http.StatusNotModified, "99"},
|
|
{http.MethodGet, http.StatusEarlyHints, ""},
|
|
} {
|
|
t.Run(fmt.Sprintf("%s/%d", tt.method, tt.status), func(t *testing.T) {
|
|
resp := &http.Response{
|
|
StatusCode: tt.status, Header: http.Header{"Content-Length": {"99"}},
|
|
Body: io.NopCloser(iotest.ErrReader(errors.New("body must not be read"))),
|
|
Trailer: http.Header{"X-Checksum": {"ignored"}},
|
|
}
|
|
w := httptest.NewRecorder()
|
|
testProxy().relayResponse(w, httptest.NewRequest(tt.method, "/", nil), resp, nil)
|
|
if w.Code != tt.status || w.Body.Len() != 0 || w.Header().Get("Trailer") != "" {
|
|
t.Fatalf("unexpected bodiless response: %d %v %q", w.Code, w.Header(), w.Body.String())
|
|
}
|
|
if got := w.Header().Get("Content-Length"); got != tt.length {
|
|
t.Errorf("Content-Length = %q, want %q", got, tt.length)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
type relayFailWriter struct{ http.ResponseWriter }
|
|
|
|
func (w relayFailWriter) Write([]byte) (int, error) {
|
|
return 0, errors.New("downstream disconnected")
|
|
}
|
|
|
|
func TestRelayResponseCopyFailure(t *testing.T) {
|
|
for _, downstreamError := range []bool{false, true} {
|
|
t.Run(fmt.Sprintf("downstreamError=%v", downstreamError), func(t *testing.T) {
|
|
var logs bytes.Buffer
|
|
proxy := testProxy()
|
|
proxy.Logger = slog.New(slog.NewTextHandler(&logs, nil))
|
|
resp := &http.Response{
|
|
StatusCode: http.StatusOK, Header: make(http.Header),
|
|
Request: httptest.NewRequest(http.MethodGet, "http://upstream.test/file", nil),
|
|
Body: io.NopCloser(io.MultiReader(strings.NewReader("part"), iotest.ErrReader(io.ErrUnexpectedEOF))),
|
|
}
|
|
var w http.ResponseWriter = httptest.NewRecorder()
|
|
wantBytes := "bytes=4"
|
|
if downstreamError {
|
|
w = relayFailWriter{httptest.NewRecorder()}
|
|
wantBytes = "bytes=0"
|
|
}
|
|
defer func() {
|
|
if got := recover(); got != http.ErrAbortHandler {
|
|
t.Errorf("panic = %v, want http.ErrAbortHandler", got)
|
|
}
|
|
for _, field := range []string{"url=http://upstream.test/file", "status=200", wantBytes, "error="} {
|
|
if !strings.Contains(logs.String(), field) {
|
|
t.Errorf("log missing %q: %s", field, logs.String())
|
|
}
|
|
}
|
|
}()
|
|
proxy.relayResponse(w, httptest.NewRequest(http.MethodGet, "/", nil), resp, nil)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRelayResponseTrailerValidation(t *testing.T) {
|
|
resp := &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Header: http.Header{"Connection": {"X-Private"}, "Content-Length": {"4"}},
|
|
Body: io.NopCloser(strings.NewReader("body")),
|
|
Trailer: http.Header{
|
|
"X-Checksum": {"verified"}, "X-Private": {"secret"},
|
|
"Content-Length": {"999"}, "Content-Type": {"bad/type"},
|
|
"Authorization": {"secret"}, "Transfer-Encoding": {"chunked"},
|
|
"If-Match": {"secret"}, "Invalid Name": {"invalid"},
|
|
},
|
|
}
|
|
w := httptest.NewRecorder()
|
|
testProxy().relayResponse(w, httptest.NewRequest(http.MethodGet, "/", nil), resp, nil)
|
|
result := w.Result()
|
|
defer func() { _ = result.Body.Close() }()
|
|
if result.Header.Get("Content-Length") != "" {
|
|
t.Error("declared trailers must remove Content-Length")
|
|
}
|
|
if len(result.Trailer) != 1 || result.Trailer.Get("X-Checksum") != "verified" {
|
|
t.Fatalf("invalid trailers leaked: %v", result.Trailer)
|
|
}
|
|
}
|
|
|
|
func TestRelayClosesBodyOnAbort(t *testing.T) {
|
|
for _, mode := range []string{"upstream", "file", "metadata"} {
|
|
t.Run(mode, func(t *testing.T) {
|
|
body := &closeTrackingReader{Reader: iotest.ErrReader(io.ErrUnexpectedEOF)}
|
|
proxy := testProxy()
|
|
proxy.HTTPClient = &http.Client{Transport: pypiRoundTripFunc(func(r *http.Request) (*http.Response, error) {
|
|
return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: body, Request: r}, nil
|
|
})}
|
|
defer func() {
|
|
if got := recover(); got != http.ErrAbortHandler {
|
|
t.Errorf("panic = %v, want http.ErrAbortHandler", got)
|
|
}
|
|
if !body.closed {
|
|
t.Error("upstream body was not closed on abort")
|
|
}
|
|
}()
|
|
relayTestRoutes(proxy, "http://upstream.test").ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/"+mode, nil))
|
|
})
|
|
}
|
|
}
|