From 5050726b1966b5af3203ea8337ca79be4483076f Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Tue, 22 Sep 2026 15:11:10 +0000 Subject: [PATCH] bridges: strip client Authorization at the Route Every client aperture-cli launches sends a placeholder bearer because its SDK refuses to build a request without one. A gateway that treats the header as authoritative rejects it with a 401, which is the Solar Winds failure. Ingress auth at the Aperture is the Machine's tailnet identity and every upstream auth mode either replaces or strips the client header, so dropping it at the Route is safe and fixes all clients at once. --- internal/bridges/route.go | 5 +++ internal/bridges/route_test.go | 59 ++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 internal/bridges/route_test.go diff --git a/internal/bridges/route.go b/internal/bridges/route.go index aa65f72..089ba0c 100644 --- a/internal/bridges/route.go +++ b/internal/bridges/route.go @@ -95,6 +95,11 @@ func (mc *Machine) openRoute(target *url.URL) (*Route, error) { proxy.Director = func(req *http.Request) { director(req) req.Host = target.Host + // Clients send a placeholder bearer because their SDKs refuse to + // build a request without one. Ingress auth at the Aperture is the + // Machine's tailnet identity, so the header is never load-bearing, + // and a gateway that treats it as authoritative rejects it. + req.Header.Del("Authorization") } proxy.Transport = transport proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { diff --git a/internal/bridges/route_test.go b/internal/bridges/route_test.go new file mode 100644 index 0000000..611be0c --- /dev/null +++ b/internal/bridges/route_test.go @@ -0,0 +1,59 @@ +package bridges + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/tailscale/aperture-cli/internal/config" +) + +// TestRouteDropsClientAuthorization reproduces the Solar Winds 401: every +// client aperture-cli launches sends a placeholder bearer because its SDK +// refuses to build a request without one, and a gateway that treats that +// header as authoritative rejects it. The Route reaches an Aperture whose +// ingress auth is the Machine's tailnet identity, so the header is never +// load-bearing and must not cross. The backend stands in for their gateway. +func TestRouteDropsClientAuthorization(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "" { + http.Error(w, `{"code":"invalid_api_key","message":"Invalid bearer token"}`, http.StatusUnauthorized) + return + } + w.WriteHeader(http.StatusNoContent) + })) + defer backend.Close() + + node := &fakeNode{backendAddr: strings.TrimPrefix(backend.URL, "http://")} + m := NewMachines(false) + m.newNode = func(_ config.Bridge, _ string, _ func(string, ...any), _ func(string, ...any)) tailnetNode { + return node + } + defer m.Close() + + localURL, err := activateMachine(m, + context.Background(), + config.Bridge{ID: "bridge-abcdef", Name: "Work"}, + "http://127.0.0.1", + collect(&[]string{}), + ) + if err != nil { + t.Fatal(err) + } + + req, err := http.NewRequest(http.MethodPost, localURL+"/v1/responses", nil) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Authorization", "Bearer not-needed") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusNoContent { + t.Fatalf("status = %d, want %d: Authorization crossed the Route", resp.StatusCode, http.StatusNoContent) + } +}