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) + } +}