From 3ad985f36ef6dc31fa2e8a33965b02a016cf14db Mon Sep 17 00:00:00 2001 From: xvkong233 <88175491+xvkong233@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:02:15 +0800 Subject: [PATCH] fix(nat): scrub dashboard-issued credentials from all accepted token channels The initial selective strip covered only the Authorization header, but the dashboard also accepts its JWT via the ?token= query parameter and the nz-jwt cookie (jwt.go TokenLookup channels). A dashboard credential arriving through those channels would still reach a NAT backend. Apply the same classify-then-strip rule to all three channels at NAT ingress: values verified as dashboard-issued are removed, foreign values are preserved byte-for-byte. Adds a ServeNAT flow test exercising the live classifier and a wiring test pinning the gate injection in main(). --- .../controller/dashboard_credential.go | 30 ++- .../dashboard_credential_nat_flow_test.go | 250 ++++++++++++++++++ .../controller/dashboard_credential_test.go | 46 ++++ .../dashboard_credential_wiring_test.go | 27 ++ cmd/dashboard/main.go | 18 +- cmd/dashboard/rpc/nat.go | 9 +- .../rpc/nat_capability_agentcompat.go | 16 +- .../rpc/nat_capability_agentcompat_test.go | 90 ++++++- cmd/dashboard/rpc/nat_capability_default.go | 9 +- .../rpc/nat_capability_default_test.go | 58 +++- cmd/dashboard/rpc/nat_credential.go | 189 +++++++++++-- cmd/dashboard/rpc/nat_credential_test.go | 146 ++++++++-- 12 files changed, 806 insertions(+), 82 deletions(-) create mode 100644 cmd/dashboard/controller/dashboard_credential_nat_flow_test.go create mode 100644 cmd/dashboard/dashboard_credential_wiring_test.go diff --git a/cmd/dashboard/controller/dashboard_credential.go b/cmd/dashboard/controller/dashboard_credential.go index e24ecdd7..00af53ea 100644 --- a/cmd/dashboard/controller/dashboard_credential.go +++ b/cmd/dashboard/controller/dashboard_credential.go @@ -43,19 +43,33 @@ func newDashboardCredentialJWTParser(authMiddleware *ginjwt.GinJWTMiddleware) *g // NAT backend may authenticate with credentials of its own. // // Decision policy mirrors the dashboard's own auth middlewares: only the -// case-sensitive "Bearer " scheme is considered; PAT-shaped values are -// resolved against the api_tokens table; everything else is treated as a JWT. -// Whenever the classification cannot be completed (parser or config not ready, -// database failure) the value is conservatively reported as a dashboard -// credential so the caller strips it; only a deterministic proof that the -// value was not issued by this dashboard (scheme mismatch, unknown PAT hash, -// failed signature verification) returns false. +// case-sensitive "Bearer " scheme is considered; the value after the scheme is +// classified by IsDashboardCredentialValue, which is also the entry point for +// the panel's scheme-less TokenLookup channels. Only a deterministic proof +// that the value was not issued by this dashboard (scheme mismatch, unknown +// PAT hash, failed signature verification) returns false. func IsDashboardCredential(authz string) bool { raw := strings.TrimSpace(authz) if !strings.HasPrefix(raw, "Bearer ") { return false } - plaintext := strings.TrimSpace(strings.TrimPrefix(raw, "Bearer ")) + return IsDashboardCredentialValue(strings.TrimPrefix(raw, "Bearer ")) +} + +// IsDashboardCredentialValue reports whether a raw credential value — the part +// after the "Bearer " scheme, exactly as the panel's other TokenLookup +// channels carry it (the ?token= query parameter and the nz-jwt cookie, see +// initParams) — was issued by this dashboard: a panel-signed JWT or a panel +// API token (PAT). +// +// Failure policy is identical to IsDashboardCredential: whenever the +// classification cannot be completed (parser or config not ready, database +// failure) the value is conservatively reported as a dashboard credential so +// the caller strips it; only a deterministic proof that the value was not +// issued by this dashboard (unknown PAT hash, failed signature verification) +// returns false. +func IsDashboardCredentialValue(value string) bool { + plaintext := strings.TrimSpace(value) if strings.HasPrefix(plaintext, model.APITokenPrefix) { return isDashboardAPIToken(plaintext) } diff --git a/cmd/dashboard/controller/dashboard_credential_nat_flow_test.go b/cmd/dashboard/controller/dashboard_credential_nat_flow_test.go new file mode 100644 index 00000000..26a8f6ff --- /dev/null +++ b/cmd/dashboard/controller/dashboard_credential_nat_flow_test.go @@ -0,0 +1,250 @@ +package controller + +import ( + "bufio" + "bytes" + "context" + "io" + "net" + "net/http" + "net/url" + "sync" + "testing" + "time" + + "github.com/goccy/go-json" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/metadata" + + "github.com/nezhahq/nezha/cmd/dashboard/rpc" + "github.com/nezhahq/nezha/model" + "github.com/nezhahq/nezha/proto" + rpcService "github.com/nezhahq/nezha/service/rpc" + "github.com/nezhahq/nezha/service/singleton" +) + +// setupNATFlowTest extends setupDashboardCredentialTest with the minimal +// ServeNAT environment: a registered server with a task stream and a fresh +// handler singleton, mirroring the rpc package's serveNAT fixture. +func setupNATFlowTest(t *testing.T) (*rpcService.NezhaHandler, *model.Server, *natFlowTaskStream) { + t.Helper() + setupDashboardCredentialTest(t) + + require.NoError(t, singleton.DB.AutoMigrate(&model.Server{})) + originalServerShared, originalHandler := singleton.ServerShared, rpcService.NezhaHandlerSingleton + t.Cleanup(func() { + singleton.ServerShared, rpcService.NezhaHandlerSingleton = originalServerShared, originalHandler + }) + + serverRow := &model.Server{Common: model.Common{ID: 7}, UUID: "nat-credential-flow-test", Name: "nat-credential-flow-test"} + require.NoError(t, singleton.DB.Create(serverRow).Error) + singleton.ServerShared = singleton.NewServerClass() + handler := rpcService.NewNezhaHandler() + rpcService.NezhaHandlerSingleton = handler + + server, ok := singleton.ServerShared.Get(serverRow.ID) + require.True(t, ok) + taskStream := &natFlowTaskStream{} + server.SetTaskStream(taskStream) + + // Wire the gate exactly as cmd/dashboard/main.go does, with the real + // classifiers built by setupDashboardCredentialTest. + rpc.SetNATDashboardCredentialGate(IsDashboardCredential, IsDashboardCredentialValue) + t.Cleanup(func() { rpc.SetNATDashboardCredentialGate(nil, nil) }) + + return handler, server, taskStream +} + +// TestServeNATWithRealClassifierStripsDashboardCredentialsFromAllChannels pins +// the main() wiring contract end to end: with the real classifiers wired as +// main() does, ServeNAT forwards a request whose dashboard-issued credentials +// — Authorization header, ?token= query parameter, nz-jwt cookie — are all +// stripped, while foreign values and untouched query/cookie data survive +// byte-for-byte. +func TestServeNATWithRealClassifierStripsDashboardCredentialsFromAllChannels(t *testing.T) { + handler, server, taskStream := setupNATFlowTest(t) + + dashboardJWT := mintDashboardCredentialJWT(t, time.Hour) + foreignJWT := mintForeignJWT(t) + // No api_tokens row hashes to this PAT, so the classifier proves it was + // not issued by this dashboard and it must survive the tunnel. + foreignPAT := model.APITokenPrefix + "foreign-issued-pat" + dashboardPAT := model.APITokenPrefix + "dashboard-issued-pat" + require.NoError(t, singleton.DB.Create(&model.APIToken{ + UserID: 1, + Name: "nat-flow", + TokenHash: model.HashAPIToken(dashboardPAT), + }).Error) + + connection := &natFlowConn{closed: make(chan struct{})} + agent := &natFlowAgent{writeDone: make(chan struct{})} + writer := &natFlowWriter{conn: connection} + request := &http.Request{ + Method: http.MethodPost, + URL: &url.URL{Path: "/nat", RawQuery: "keep=1&token=" + dashboardJWT + "&token=" + url.QueryEscape(foreignJWT) + + "&token=" + foreignPAT + "&token=" + dashboardPAT}, + Header: make(http.Header), + Body: http.NoBody, + } + request.Header.Set("Authorization", "Bearer "+dashboardJWT) + request.Header.Set("Cookie", "sid=abc; nz-jwt="+dashboardJWT+"; nz-jwt="+foreignJWT+"; nz-jwt="+foreignPAT) + request.Header.Set("X-Ordinary-Nat", "ordinary") + taskStream.onSend = func(task *proto.Task) error { + var nat model.TaskNAT + if err := json.Unmarshal([]byte(task.Data), &nat); err != nil { + return err + } + return handler.AgentConnected(nat.StreamID, agent) + } + + done := make(chan struct{}) + go func() { + rpc.ServeNAT(writer, request, &model.NAT{ServerID: server.ID, Host: "target.example"}) + close(done) + }() + select { + case <-agent.writeDone: + case <-time.After(time.Second): + t.Fatal("agent did not receive the transferred NAT request") + } + require.NoError(t, connection.Close()) + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("ServeNAT did not finish") + } + + forwarded := string(agent.writtenBytes()) + // Dashboard credentials are gone from every channel. + require.NotContains(t, forwarded, "Authorization:") + require.NotContains(t, forwarded, dashboardJWT) + require.NotContains(t, forwarded, dashboardPAT) + // Foreign credentials and untouched data survive byte-for-byte. + require.Contains(t, forwarded, "POST /nat?keep=1&token="+url.QueryEscape(foreignJWT)+"&token="+foreignPAT+" HTTP/1.1") + require.Contains(t, forwarded, "Cookie: sid=abc; nz-jwt="+foreignJWT+"; nz-jwt="+foreignPAT) + require.Contains(t, forwarded, "X-Ordinary-Nat: ordinary") +} + +// TestServeNATWithRealClassifierKeepsForeignCredentials pins that the real +// classifier deterministically proves foreign values were not issued by this +// dashboard and leaves every channel untouched. +func TestServeNATWithRealClassifierKeepsForeignCredentials(t *testing.T) { + handler, server, taskStream := setupNATFlowTest(t) + + foreignJWT := mintForeignJWT(t) + connection := &natFlowConn{closed: make(chan struct{})} + agent := &natFlowAgent{writeDone: make(chan struct{})} + writer := &natFlowWriter{conn: connection} + request := &http.Request{ + Method: http.MethodPost, + URL: &url.URL{Path: "/nat", RawQuery: "token=" + foreignJWT}, + Header: make(http.Header), + Body: http.NoBody, + } + request.Header.Set("Authorization", "Bearer "+foreignJWT) + request.Header.Set("Cookie", "nz-jwt="+foreignJWT) + taskStream.onSend = func(task *proto.Task) error { + var nat model.TaskNAT + if err := json.Unmarshal([]byte(task.Data), &nat); err != nil { + return err + } + return handler.AgentConnected(nat.StreamID, agent) + } + + done := make(chan struct{}) + go func() { + rpc.ServeNAT(writer, request, &model.NAT{ServerID: server.ID, Host: "target.example"}) + close(done) + }() + select { + case <-agent.writeDone: + case <-time.After(time.Second): + t.Fatal("agent did not receive the transferred NAT request") + } + require.NoError(t, connection.Close()) + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("ServeNAT did not finish") + } + + forwarded := string(agent.writtenBytes()) + require.Contains(t, forwarded, "POST /nat?token="+foreignJWT+" HTTP/1.1") + require.Contains(t, forwarded, "Authorization: Bearer "+foreignJWT) + require.Contains(t, forwarded, "Cookie: nz-jwt="+foreignJWT) +} + +type natFlowTaskStream struct { + onSend func(*proto.Task) error +} + +func (stream *natFlowTaskStream) Send(task *proto.Task) error { + if stream.onSend != nil { + return stream.onSend(task) + } + return nil +} +func (*natFlowTaskStream) Recv() (*proto.TaskResult, error) { return nil, io.EOF } +func (*natFlowTaskStream) SetHeader(metadata.MD) error { return nil } +func (*natFlowTaskStream) SendHeader(metadata.MD) error { return nil } +func (*natFlowTaskStream) SetTrailer(metadata.MD) {} +func (*natFlowTaskStream) Context() context.Context { return context.Background() } +func (*natFlowTaskStream) SendMsg(any) error { return nil } +func (*natFlowTaskStream) RecvMsg(any) error { return io.EOF } + +type natFlowWriter struct{ conn *natFlowConn } + +func (writer *natFlowWriter) Header() http.Header { return http.Header{} } +func (writer *natFlowWriter) Write(data []byte) (int, error) { return len(data), nil } +func (writer *natFlowWriter) WriteHeader(int) {} +func (writer *natFlowWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { + go func() { _, _ = writer.conn.Read(make([]byte, 1)) }() + return writer.conn, bufio.NewReadWriter(bufio.NewReader(writer.conn), bufio.NewWriter(writer.conn)), nil +} + +type natFlowConn struct { + closed chan struct{} + closeOnce sync.Once +} + +func (conn *natFlowConn) Read([]byte) (int, error) { + <-conn.closed + return 0, io.EOF +} +func (conn *natFlowConn) Write(data []byte) (int, error) { return len(data), nil } +func (conn *natFlowConn) Close() error { + conn.closeOnce.Do(func() { close(conn.closed) }) + return nil +} +func (conn *natFlowConn) LocalAddr() net.Addr { return natFlowAddr{} } +func (conn *natFlowConn) RemoteAddr() net.Addr { return natFlowAddr{} } +func (conn *natFlowConn) SetDeadline(time.Time) error { return nil } +func (conn *natFlowConn) SetReadDeadline(time.Time) error { return nil } +func (conn *natFlowConn) SetWriteDeadline(time.Time) error { return nil } + +type natFlowAddr struct{} + +func (natFlowAddr) Network() string { return "test" } +func (natFlowAddr) String() string { return "test" } + +type natFlowAgent struct { + mu sync.Mutex + written bytes.Buffer + writeDone chan struct{} + once sync.Once +} + +func (agent *natFlowAgent) Read([]byte) (int, error) { return 0, io.EOF } +func (agent *natFlowAgent) Write(data []byte) (int, error) { + agent.mu.Lock() + agent.written.Write(data) + agent.mu.Unlock() + agent.once.Do(func() { close(agent.writeDone) }) + return len(data), nil +} +func (*natFlowAgent) Close() error { return nil } +func (agent *natFlowAgent) writtenBytes() []byte { + agent.mu.Lock() + defer agent.mu.Unlock() + return append([]byte(nil), agent.written.Bytes()...) +} diff --git a/cmd/dashboard/controller/dashboard_credential_test.go b/cmd/dashboard/controller/dashboard_credential_test.go index 9ad1070e..fbfe47ec 100644 --- a/cmd/dashboard/controller/dashboard_credential_test.go +++ b/cmd/dashboard/controller/dashboard_credential_test.go @@ -117,6 +117,52 @@ func TestIsDashboardCredentialClassification(t *testing.T) { }) } +// TestIsDashboardCredentialValueClassification pins the scheme-less classifier +// that backs IsDashboardCredential and drives the ?token= query parameter and +// nz-jwt cookie channels at NAT ingress (see IsDashboardCredentialValue). +func TestIsDashboardCredentialValueClassification(t *testing.T) { + setupDashboardCredentialTest(t) + + dashboardJWT := mintDashboardCredentialJWT(t, time.Hour) + expiredDashboardJWT := mintDashboardCredentialJWT(t, -time.Hour) + foreignJWT := mintForeignJWT(t) + + dashboardPAT := model.APITokenPrefix + "dashboard-credential-value-pat" + require.NoError(t, singleton.DB.Create(&model.APIToken{ + UserID: 1, + Name: "nat-value-classification", + TokenHash: model.HashAPIToken(dashboardPAT), + }).Error) + + t.Run("dashboard signed jwt is a dashboard credential", func(t *testing.T) { + require.True(t, IsDashboardCredentialValue(dashboardJWT)) + }) + t.Run("expired dashboard signed jwt is still a dashboard credential", func(t *testing.T) { + require.True(t, IsDashboardCredentialValue(expiredDashboardJWT)) + }) + t.Run("dashboard api token is a dashboard credential", func(t *testing.T) { + require.True(t, IsDashboardCredentialValue(dashboardPAT)) + }) + t.Run("foreign signed jwt is not a dashboard credential", func(t *testing.T) { + require.False(t, IsDashboardCredentialValue(foreignJWT)) + }) + t.Run("unknown api token is not a dashboard credential", func(t *testing.T) { + require.False(t, IsDashboardCredentialValue(model.APITokenPrefix+"unknown-to-the-dashboard")) + }) + t.Run("random string is not a dashboard credential", func(t *testing.T) { + require.False(t, IsDashboardCredentialValue("random-not-a-token")) + }) + t.Run("empty value is not a dashboard credential", func(t *testing.T) { + require.False(t, IsDashboardCredentialValue("")) + }) + t.Run("bearer scheme is not part of the value contract", func(t *testing.T) { + // The query and cookie channels carry the bare value; a value that + // itself starts with "Bearer " is not something the panel issues and + // fails signature verification. + require.False(t, IsDashboardCredentialValue("Bearer "+dashboardJWT)) + }) +} + func TestIsDashboardCredentialFailsClosedWhenParserUnavailable(t *testing.T) { setupDashboardCredentialTest(t) dashboardCredentialJWTParser = nil diff --git a/cmd/dashboard/dashboard_credential_wiring_test.go b/cmd/dashboard/dashboard_credential_wiring_test.go new file mode 100644 index 00000000..95be89ff --- /dev/null +++ b/cmd/dashboard/dashboard_credential_wiring_test.go @@ -0,0 +1,27 @@ +package main + +import ( + "testing" + + "github.com/nezhahq/nezha/cmd/dashboard/rpc" +) + +// TestWireNATDashboardCredentialGate pins the NAT ingress wiring contract: +// main() must connect the real dashboard-credential classifiers +// (controller.IsDashboardCredential / controller.IsDashboardCredentialValue) +// through wireNATDashboardCredentialGate before any listener serves traffic. +// That the wired functions really classify per channel is pinned end to end +// by the controller package's real-classifier NAT flow test. +func TestWireNATDashboardCredentialGate(t *testing.T) { + if rpc.NATDashboardCredentialGateWired() { + t.Fatal("NAT dashboard credential gate already wired before the test") + } + + wireNATDashboardCredentialGate() + + if !rpc.NATDashboardCredentialGateWired() { + t.Fatal("wireNATDashboardCredentialGate left the NAT dashboard credential gate unwired") + } + // Leave the process in the wired state main() establishes. + t.Cleanup(wireNATDashboardCredentialGate) +} diff --git a/cmd/dashboard/main.go b/cmd/dashboard/main.go index 50d02590..4b64c441 100644 --- a/cmd/dashboard/main.go +++ b/cmd/dashboard/main.go @@ -169,11 +169,7 @@ func main() { grpcHandler := rpc.ServeRPC() httpHandler := controller.ServeWeb(frontendDist) controller.InitUpgrader() - // The NAT ingress strips dashboard-issued Authorization values (panel JWT / - // panel PAT) before tunneling a request to the agent and forwards foreign - // credentials untouched. ServeWeb built the JWT parser the classifier - // reuses, so the gate is wired right after it. - rpc.SetNATDashboardCredentialGate(controller.IsDashboardCredential) + wireNATDashboardCredentialGate() muxHandler := newHTTPandGRPCMux(httpHandler, grpcHandler) muxServerHTTP := &http.Server{ @@ -233,6 +229,18 @@ func main() { close(errChan) } +// wireNATDashboardCredentialGate connects the NAT ingress to the real +// dashboard-credential classifiers. The NAT ingress strips dashboard-issued +// credentials (panel JWT / panel PAT) from every channel the panel's own +// TokenLookup accepts — the Authorization header, the ?token= query parameter +// and the nz-jwt cookie — before tunneling a request to the agent, and +// forwards foreign values untouched. ServeWeb built the JWT parser the +// classifiers reuse, so the gate is wired right after it and before any +// listener starts serving. +func wireNATDashboardCredentialGate() { + rpc.SetNATDashboardCredentialGate(controller.IsDashboardCredential, controller.IsDashboardCredentialValue) +} + func newHTTPandGRPCMux(httpHandler http.Handler, grpcHandler http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { natConfig := singleton.NATShared.GetNATConfigByDomain(r.Host) diff --git a/cmd/dashboard/rpc/nat.go b/cmd/dashboard/rpc/nat.go index 5c519493..7b8be72b 100644 --- a/cmd/dashboard/rpc/nat.go +++ b/cmd/dashboard/rpc/nat.go @@ -84,10 +84,11 @@ func ServeNAT(w http.ResponseWriter, r *http.Request, natConfig *model.NAT) { // dashboard — the panel-signed JWT (verified by signature only, expiry // deliberately ignored) and the panel API token — must never become origin // credentials for the configured NAT backend. prepareNATCapability has - // already stripped every Authorization value IsDashboardCredential - // classified as dashboard-issued; whatever remains is either absent or a - // foreign credential, which is forwarded untouched because the NAT backend - // may authenticate with credentials of its own. + // already stripped them from every channel the panel's own TokenLookup + // accepts (Authorization header, ?token= query parameter, nz-jwt cookie); + // whatever remains on those channels is either absent or a foreign + // credential, which is forwarded untouched because the NAT backend may + // authenticate with credentials of its own. wWrapped, err := utils.NewRequestWrapper(r, w) if err != nil { return diff --git a/cmd/dashboard/rpc/nat_capability_agentcompat.go b/cmd/dashboard/rpc/nat_capability_agentcompat.go index e2c3bc67..16fdf503 100644 --- a/cmd/dashboard/rpc/nat_capability_agentcompat.go +++ b/cmd/dashboard/rpc/nat_capability_agentcompat.go @@ -23,23 +23,25 @@ func prepareNATCapability(request *http.Request, natConfig *model.NAT) (natCapab values := request.Header.Values(agentcompatcontract.IOStreamCapabilityHeader) if len(values) == 0 { // No capability header: legacy NAT path. Strip dashboard-issued - // Authorization values (panel JWT or panel PAT) so they never become - // origin credentials; foreign ones are ordinary request data. - stripDashboardCredential(request) + // credentials (panel JWT or panel PAT) from the Authorization header, + // the ?token= query parameter and the nz-jwt cookie so they never + // become origin credentials; foreign values are ordinary request data. + stripDashboardCredentials(request) return natCapabilityLease{}, nil } request.Header.Del(agentcompatcontract.IOStreamCapabilityHeader) if len(values) != 1 || values[0] == "" { // Invalid capability: nothing is forwarded, but dashboard-issued // credentials still must not survive into errors or task data. - stripDashboardCredential(request) + stripDashboardCredentials(request) return natCapabilityLease{}, errors.New("invalid NAT capability") } access, handle, err := serviceRPC.NezhaHandlerSingleton.ConsumeAgentCompatNATCapabilityForProfile(values[0], natConfig.ServerID, natConfig.ID) // From here the request bytes may reach the agent (active lease) or the - // request ends in a 503; either way dashboard-issued Authorization values - // are stripped, foreign ones are forwarded untouched. - stripDashboardCredential(request) + // request ends in a 503; either way dashboard-issued credentials are + // stripped from every TokenLookup channel, foreign ones are forwarded + // untouched. + stripDashboardCredentials(request) if err != nil { return natCapabilityLease{}, errors.New("invalid NAT capability") } diff --git a/cmd/dashboard/rpc/nat_capability_agentcompat_test.go b/cmd/dashboard/rpc/nat_capability_agentcompat_test.go index e1e369b4..2bfae4a8 100644 --- a/cmd/dashboard/rpc/nat_capability_agentcompat_test.go +++ b/cmd/dashboard/rpc/nat_capability_agentcompat_test.go @@ -7,6 +7,7 @@ import ( "context" "log" "net/http" + "net/url" "strings" "testing" @@ -62,16 +63,21 @@ func TestPrepareNATCapabilityRejectsDuplicateHeaderAfterRemovingAllValues(t *tes func TestPrepareNATCapabilityAgentCompatStripsDashboardCredentials(t *testing.T) { // Given — legacy path without a capability header, then the invalid - // capability path: both must drop dashboard-issued Authorization values. + // capability path: both must drop dashboard-issued credentials from the + // Authorization header, the ?token= query parameter and the nz-jwt cookie. installNATCredentialTestGate(t) for name, credential := range map[string]string{ - "dashboard jwt": natTestDashboardJWT, - "expired dashboard jwt": natTestDashboardExpiredJWT, - "dashboard api token": natTestDashboardAPIToken, + "dashboard jwt": natTestDashboardJWTValue, + "expired dashboard jwt": natTestDashboardExpiredJWTValue, + "dashboard api token": natTestDashboardPATValue, } { t.Run(name+"/legacy path", func(t *testing.T) { - request := &http.Request{Header: make(http.Header)} - request.Header.Set("Authorization", credential) + request := &http.Request{ + Header: make(http.Header), + URL: &url.URL{Path: "/nat", RawQuery: "keep=1&token=" + credential}, + } + request.Header.Set("Authorization", "Bearer "+credential) + request.Header.Set("Cookie", "sid=abc; nz-jwt="+credential) lease, err := prepareNATCapability(request, &model.NAT{Common: model.Common{ID: 91}, ServerID: 81}) @@ -82,13 +88,23 @@ func TestPrepareNATCapabilityAgentCompatStripsDashboardCredentials(t *testing.T) t.Fatal("legacy NAT path unexpectedly activated") } if got := request.Header.Get("Authorization"); got != "" { - t.Fatalf("legacy NAT path retained dashboard credential %q", got) + t.Fatalf("legacy NAT path retained dashboard credential in Authorization %q", got) + } + if got := request.URL.RawQuery; got != "keep=1" { + t.Fatalf("legacy NAT path retained dashboard credential in query %q", got) + } + if got := request.Header.Get("Cookie"); got != "sid=abc" { + t.Fatalf("legacy NAT path retained dashboard credential in cookie %q", got) } }) t.Run(name+"/invalid capability path", func(t *testing.T) { - request := &http.Request{Header: make(http.Header)} + request := &http.Request{ + Header: make(http.Header), + URL: &url.URL{Path: "/nat", RawQuery: "keep=1&token=" + credential}, + } request.Header.Set(agentcompatcontract.IOStreamCapabilityHeader, "malformed") - request.Header.Set("Authorization", credential) + request.Header.Set("Authorization", "Bearer "+credential) + request.Header.Set("Cookie", "sid=abc; nz-jwt="+credential) lease, err := prepareNATCapability(request, &model.NAT{Common: model.Common{ID: 91}, ServerID: 81}) @@ -99,7 +115,13 @@ func TestPrepareNATCapabilityAgentCompatStripsDashboardCredentials(t *testing.T) t.Fatal("malformed capability unexpectedly activated") } if got := request.Header.Get("Authorization"); got != "" { - t.Fatalf("invalid capability path retained dashboard credential %q", got) + t.Fatalf("invalid capability path retained dashboard credential in Authorization %q", got) + } + if got := request.URL.RawQuery; got != "keep=1" { + t.Fatalf("invalid capability path retained dashboard credential in query %q", got) + } + if got := request.Header.Get("Cookie"); got != "sid=abc" { + t.Fatalf("invalid capability path retained dashboard credential in cookie %q", got) } }) } @@ -124,9 +146,13 @@ func TestPrepareNATCapabilityAgentCompatStripsDashboardCredentialAfterConsumingC if err != nil { t.Fatalf("register capability: %v", err) } - request := &http.Request{Header: make(http.Header)} + request := &http.Request{ + Header: make(http.Header), + URL: &url.URL{Path: "/nat", RawQuery: "keep=1&token=" + natTestDashboardJWTValue}, + } request.Header.Set(agentcompatcontract.IOStreamCapabilityHeader, capability.String()) request.Header.Set("Authorization", natTestDashboardJWT) + request.Header.Set("Cookie", "sid=abc; nz-jwt="+natTestDashboardJWTValue) // When lease, err := prepareNATCapability(request, &model.NAT{Common: model.Common{ID: 91}, ServerID: 81}) @@ -139,7 +165,13 @@ func TestPrepareNATCapabilityAgentCompatStripsDashboardCredentialAfterConsumingC t.Fatal("valid NAT capability did not activate") } if got := request.Header.Get("Authorization"); got != "" { - t.Fatalf("dashboard credential retained after capability consumption: %q", got) + t.Fatalf("dashboard credential retained in Authorization after capability consumption: %q", got) + } + if got := request.URL.RawQuery; got != "keep=1" { + t.Fatalf("dashboard credential retained in query after capability consumption: %q", got) + } + if got := request.Header.Get("Cookie"); got != "sid=abc" { + t.Fatalf("dashboard credential retained in cookie after capability consumption: %q", got) } } @@ -180,6 +212,40 @@ func TestPrepareNATCapabilityAgentCompatKeepsForeignAndMissingAuthorization(t *t } } +func TestPrepareNATCapabilityAgentCompatKeepsForeignAndMissingQueryAndCookie(t *testing.T) { + // Given — legacy path: foreign query tokens and cookies are ordinary + // request data, and absent channels must stay untouched. + installNATCredentialTestGate(t) + for name, tc := range map[string]struct{ rawQuery, cookie string }{ + "foreign values": {"keep=1&token=" + natTestForeignJWTValue + "&a=%2Fx", "sid=abc; nz-jwt=" + natTestForeignJWTValue}, + "empty values": {"keep=1&token=", "nz-jwt="}, + "missing channel": {"keep=1", "sid=abc"}, + } { + t.Run(name, func(t *testing.T) { + request := &http.Request{ + Header: make(http.Header), + URL: &url.URL{Path: "/nat", RawQuery: tc.rawQuery}, + } + request.Header.Set("Cookie", tc.cookie) + + lease, err := prepareNATCapability(request, &model.NAT{Common: model.Common{ID: 91}, ServerID: 81}) + + if err != nil { + t.Fatalf("legacy NAT path returned error: %v", err) + } + if lease.active { + t.Fatal("legacy NAT path unexpectedly activated") + } + if got := request.URL.RawQuery; got != tc.rawQuery { + t.Fatalf("legacy NAT path changed query to %q, want %q", got, tc.rawQuery) + } + if got := request.Header.Get("Cookie"); got != tc.cookie { + t.Fatalf("legacy NAT path changed cookie to %q, want %q", got, tc.cookie) + } + }) + } +} + func TestServeNATAgentCompatSensitiveHeadersStayOutOfErrorsAndLogs(t *testing.T) { installNATCredentialTestGate(t) request := &http.Request{Header: make(http.Header)} diff --git a/cmd/dashboard/rpc/nat_capability_default.go b/cmd/dashboard/rpc/nat_capability_default.go index 865c8f08..b0480ce6 100644 --- a/cmd/dashboard/rpc/nat_capability_default.go +++ b/cmd/dashboard/rpc/nat_capability_default.go @@ -19,10 +19,11 @@ type natCapabilityLease struct { func prepareNATCapability(request *http.Request, _ *model.NAT) (natCapabilityLease, error) { // The default build has no capability protocol: every request takes the - // legacy NAT path, so any dashboard-issued Authorization value (panel JWT - // or panel PAT) is stripped before the request is tunneled to the agent. - // Foreign Authorization values are ordinary request data and stay put. - stripDashboardCredential(request) + // legacy NAT path, so any dashboard-issued credential (panel JWT or panel + // PAT) carried by the Authorization header, the ?token= query parameter or + // the nz-jwt cookie is stripped before the request is tunneled to the + // agent. Foreign values are ordinary request data and stay put. + stripDashboardCredentials(request) return natCapabilityLease{}, nil } diff --git a/cmd/dashboard/rpc/nat_capability_default_test.go b/cmd/dashboard/rpc/nat_capability_default_test.go index 71154084..40827939 100644 --- a/cmd/dashboard/rpc/nat_capability_default_test.go +++ b/cmd/dashboard/rpc/nat_capability_default_test.go @@ -4,6 +4,7 @@ package rpc import ( "net/http" + "net/url" "testing" "github.com/nezhahq/nezha/model" @@ -39,13 +40,17 @@ func TestPrepareNATCapabilityDefaultStripsDashboardCredentials(t *testing.T) { // Given installNATCredentialTestGate(t) for name, credential := range map[string]string{ - "dashboard jwt": natTestDashboardJWT, - "expired dashboard jwt": natTestDashboardExpiredJWT, - "dashboard api token": natTestDashboardAPIToken, + "dashboard jwt": natTestDashboardJWTValue, + "expired dashboard jwt": natTestDashboardExpiredJWTValue, + "dashboard api token": natTestDashboardPATValue, } { t.Run(name, func(t *testing.T) { - request := &http.Request{Header: make(http.Header)} - request.Header.Set("Authorization", credential) + request := &http.Request{ + Header: make(http.Header), + URL: &url.URL{Path: "/nat", RawQuery: "keep=1&token=" + credential}, + } + request.Header.Set("Authorization", "Bearer "+credential) + request.Header.Set("Cookie", "sid=abc; nz-jwt="+credential) // When lease, err := prepareNATCapability(request, &model.NAT{Common: model.Common{ID: 91}, ServerID: 81}) @@ -58,7 +63,13 @@ func TestPrepareNATCapabilityDefaultStripsDashboardCredentials(t *testing.T) { t.Fatal("default NAT capability hook unexpectedly activated") } if got := request.Header.Get("Authorization"); got != "" { - t.Fatalf("default NAT capability hook retained dashboard credential %q", got) + t.Fatalf("default NAT capability hook retained dashboard credential in Authorization %q", got) + } + if got := request.URL.RawQuery; got != "keep=1" { + t.Fatalf("default NAT capability hook retained dashboard credential in query %q", got) + } + if got := request.Header.Get("Cookie"); got != "sid=abc" { + t.Fatalf("default NAT capability hook retained dashboard credential in cookie %q", got) } }) } @@ -101,3 +112,38 @@ func TestPrepareNATCapabilityDefaultKeepsForeignAndMissingAuthorization(t *testi }) } } + +func TestPrepareNATCapabilityDefaultKeepsForeignAndMissingQueryAndCookie(t *testing.T) { + // Given + installNATCredentialTestGate(t) + for name, tc := range map[string]struct{ rawQuery, cookie string }{ + "foreign values": {"keep=1&token=" + natTestForeignJWTValue + "&a=%2Fx", "sid=abc; nz-jwt=" + natTestForeignJWTValue}, + "empty values": {"keep=1&token=", "nz-jwt="}, + "missing channel": {"keep=1", "sid=abc"}, + } { + t.Run(name, func(t *testing.T) { + request := &http.Request{ + Header: make(http.Header), + URL: &url.URL{Path: "/nat", RawQuery: tc.rawQuery}, + } + request.Header.Set("Cookie", tc.cookie) + + // When + lease, err := prepareNATCapability(request, &model.NAT{Common: model.Common{ID: 91}, ServerID: 81}) + + // Then + if err != nil { + t.Fatalf("default NAT capability hook returned error: %v", err) + } + if lease.active { + t.Fatal("default NAT capability hook unexpectedly activated") + } + if got := request.URL.RawQuery; got != tc.rawQuery { + t.Fatalf("default NAT capability hook changed query to %q, want %q", got, tc.rawQuery) + } + if got := request.Header.Get("Cookie"); got != tc.cookie { + t.Fatalf("default NAT capability hook changed cookie to %q, want %q", got, tc.cookie) + } + }) + } +} diff --git a/cmd/dashboard/rpc/nat_credential.go b/cmd/dashboard/rpc/nat_credential.go index eb6470a8..a9e82f4a 100644 --- a/cmd/dashboard/rpc/nat_credential.go +++ b/cmd/dashboard/rpc/nat_credential.go @@ -1,29 +1,69 @@ package rpc -import "net/http" +import ( + "net/http" + "net/url" + "strings" +) -// natDashboardCredentialGate classifies an Authorization header value as a -// credential issued by this dashboard (panel-signed JWT or panel API token). -// main() injects controller.IsDashboardCredential; the setter keeps the rpc -// package free of a dependency on the controller package, the same pattern as -// SetReceiptGateListener and SetMCPKillSwitchObserver. -var natDashboardCredentialGate func(authz string) bool +// Channel names of the panel's own TokenLookup (controller/jwt.go +// "header: Authorization, query: token, cookie: nz-jwt"). A dashboard JWT +// can therefore reach the NAT ingress through the Authorization header, the +// ?token= query parameter or the nz-jwt cookie, and all three must be +// classified before the request is tunneled to the agent. +const ( + natDashboardJWTCookieName = "nz-jwt" + natDashboardJWTQueryParameter = "token" +) -// SetNATDashboardCredentialGate wires the dashboard-credential classifier used -// by the NAT ingress. Passing nil restores the fail-closed default in -// stripDashboardCredential. -func SetNATDashboardCredentialGate(fn func(authz string) bool) { - natDashboardCredentialGate = fn +var ( + // natDashboardAuthorizationGate classifies an Authorization header value + // (case-sensitive "Bearer " scheme included) as a credential issued by + // this dashboard. + natDashboardAuthorizationGate func(authz string) bool + // natDashboardCredentialValueGate classifies a raw credential value — the + // part after the "Bearer " scheme, exactly as the ?token= query parameter + // and the nz-jwt cookie carry it — as a credential issued by this + // dashboard. + natDashboardCredentialValueGate func(value string) bool +) + +// SetNATDashboardCredentialGate wires the dashboard-credential classifiers +// used by the NAT ingress. main() injects +// controller.IsDashboardCredential and controller.IsDashboardCredentialValue; +// the setter keeps the rpc package free of a dependency on the controller +// package, the same pattern as SetReceiptGateListener and +// SetMCPKillSwitchObserver. A nil function fails closed: every value on that +// channel is then treated as dashboard-issued and stripped. +func SetNATDashboardCredentialGate(authorizationClass func(authz string) bool, valueClass func(value string) bool) { + natDashboardAuthorizationGate = authorizationClass + natDashboardCredentialValueGate = valueClass } -// stripDashboardCredential removes the Authorization header when it carries a -// credential issued by this dashboard (panel-signed JWT or panel API token). -// Such credentials authenticate the dashboard itself and must never become -// origin credentials for the NAT backend. Foreign credentials are forwarded -// untouched: the NAT backend may authenticate with credentials of its own, and -// this dashboard must not make policy for them. -func stripDashboardCredential(request *http.Request) { - if natDashboardCredentialGate == nil { +// NATDashboardCredentialGateWired reports whether the dashboard-credential +// classifiers have been wired. It exists so the main() wiring contract can be +// pinned by a test. +func NATDashboardCredentialGateWired() bool { + return natDashboardAuthorizationGate != nil && natDashboardCredentialValueGate != nil +} + +// stripDashboardCredentials removes credentials issued by this dashboard from +// every channel the panel's own TokenLookup accepts: the Authorization +// header, the ?token= query parameter and the nz-jwt cookie. Such credentials +// authenticate the dashboard itself and must never become origin credentials +// for the NAT backend. Foreign values are forwarded untouched: the NAT +// backend may authenticate with credentials of its own, and this dashboard +// must not make policy for them. +func stripDashboardCredentials(request *http.Request) { + stripDashboardAuthorizationHeader(request) + stripDashboardCredentialQuery(request) + stripDashboardCredentialCookie(request) +} + +// stripDashboardAuthorizationHeader removes the Authorization header when it +// carries a dashboard-issued credential. +func stripDashboardAuthorizationHeader(request *http.Request) { + if natDashboardAuthorizationGate == nil { // Gate not wired (early startup or tests): fail closed and keep the // historical behavior of stripping every Authorization header. request.Header.Del("Authorization") @@ -33,9 +73,116 @@ func stripDashboardCredential(request *http.Request) { // dashboard-issued value in it must still not survive into the tunnel, so // the whole header is dropped when one is recognized. for _, value := range request.Header.Values("Authorization") { - if natDashboardCredentialGate(value) { + if natDashboardAuthorizationGate(value) { request.Header.Del("Authorization") return } } } + +// stripDashboardCredentialQuery removes ?token= query parameters whose value +// is a dashboard-issued credential. Every other element of the query string — +// including foreign token values — is preserved byte-for-byte, so the +// forwarded request keeps the exact query the client sent. +func stripDashboardCredentialQuery(request *http.Request) { + if request.URL == nil || request.URL.RawQuery == "" { + return + } + elements := strings.Split(request.URL.RawQuery, "&") + kept := elements[:0] + changed := false + for _, element := range elements { + if isDashboardCredentialQueryElement(element) { + changed = true + continue + } + kept = append(kept, element) + } + if changed { + request.URL.RawQuery = strings.Join(kept, "&") + } +} + +// isDashboardCredentialQueryElement reports whether a raw query element +// carries a dashboard-issued credential in the panel's token parameter. +func isDashboardCredentialQueryElement(element string) bool { + rawName, rawValue, _ := strings.Cut(element, "=") + // The panel reads the parameter by its decoded name (gin-jwt c.Query), + // so an escaped key must be decoded before comparing. + name, err := url.QueryUnescape(rawName) + if err != nil || name != natDashboardJWTQueryParameter { + return false + } + if natDashboardCredentialValueGate == nil { + return true // fail closed + } + // A value that fails to decode is one the panel's own query parsing drops + // as well, so it can never be a panel credential. + decoded, err := url.QueryUnescape(rawValue) + if err != nil { + return false + } + return natDashboardCredentialValueGate(decoded) +} + +// stripDashboardCredentialCookie removes nz-jwt cookies whose value is a +// dashboard-issued credential. All other cookie pairs — including foreign +// nz-jwt values — are preserved verbatim. +func stripDashboardCredentialCookie(request *http.Request) { + lines := request.Header.Values("Cookie") + if len(lines) == 0 { + return + } + changed := false + kept := make([]string, 0, len(lines)) + for _, line := range lines { + nextLine, lineChanged := stripDashboardCredentialCookieLine(line) + changed = changed || lineChanged + if nextLine != "" { + kept = append(kept, nextLine) + } + } + if !changed { + return + } + request.Header.Del("Cookie") + for _, line := range kept { + request.Header.Add("Cookie", line) + } +} + +// stripDashboardCredentialCookieLine rewrites a single Cookie header line, +// dropping the dashboard-issued nz-jwt pairs. Kept pairs keep their raw form, +// whitespace included — except that a pair following a dropped one no longer +// carries the removed pair's share of the "; " separator. +func stripDashboardCredentialCookieLine(line string) (string, bool) { + pairs := strings.Split(line, ";") + kept := pairs[:0] + droppedBeforeFirstKept := true + changed := false + for _, pair := range pairs { + name, value, _ := strings.Cut(pair, "=") + if strings.TrimSpace(name) == natDashboardJWTCookieName && + (natDashboardCredentialValueGate == nil || isDashboardCredentialCookieValue(value)) { + changed = true + continue + } + if droppedBeforeFirstKept { + pair = strings.TrimLeft(pair, " ") + } + droppedBeforeFirstKept = false + kept = append(kept, pair) + } + if !changed { + return line, false + } + return strings.Join(kept, ";"), true +} + +// isDashboardCredentialCookieValue classifies a raw cookie value the same way +// the panel's cookie parsing would hand it to the JWT verifier: surrounding +// whitespace and quoting are presentation, not part of the credential. +func isDashboardCredentialCookieValue(rawValue string) bool { + value := strings.Trim(strings.TrimSpace(rawValue), `"`) + return natDashboardCredentialValueGate(value) +} diff --git a/cmd/dashboard/rpc/nat_credential_test.go b/cmd/dashboard/rpc/nat_credential_test.go index 0d5690d1..2f7a2be9 100644 --- a/cmd/dashboard/rpc/nat_credential_test.go +++ b/cmd/dashboard/rpc/nat_credential_test.go @@ -1,31 +1,43 @@ package rpc import ( + "net/http" + "net/url" "testing" "github.com/nezhahq/nezha/model" ) -// Values standing in for the credentials this dashboard issues. The fake gate -// below mirrors the contract of controller.IsDashboardCredential; the real -// classification semantics — panel JWTs accepted regardless of expiry, -// foreign-signed JWTs and random strings rejected — are pinned by the -// controller package tests (dashboard_credential_test.go). +// Values standing in for the credentials this dashboard issues. The fake +// gates below mirror the contract of controller.IsDashboardCredential and +// controller.IsDashboardCredentialValue; the real classification semantics — +// panel JWTs accepted regardless of expiry, foreign-signed JWTs and random +// strings rejected — are pinned by the controller package tests +// (dashboard_credential_test.go) and by the flow test that wires the real +// classifiers into ServeNAT (dashboard_credential_nat_flow_test.go). const ( - natTestDashboardJWT = "Bearer dashboard-signed-jwt" - natTestDashboardExpiredJWT = "Bearer dashboard-signed-expired-jwt" - natTestDashboardAPIToken = "Bearer " + model.APITokenPrefix + "dashboard-issued-pat" - natTestForeignJWT = "Bearer foreign-signed-jwt" - natTestForeignRandom = "Bearer foreign-random-string" + natTestDashboardJWTValue = "dashboard-signed-jwt" + natTestDashboardExpiredJWTValue = "dashboard-signed-expired-jwt" + natTestDashboardPATValue = model.APITokenPrefix + "dashboard-issued-pat" + natTestForeignJWTValue = "foreign-signed-jwt" + natTestForeignRandomValue = "foreign-random-string" + + natTestDashboardJWT = "Bearer " + natTestDashboardJWTValue + natTestDashboardExpiredJWT = "Bearer " + natTestDashboardExpiredJWTValue + natTestDashboardAPIToken = "Bearer " + natTestDashboardPATValue + natTestForeignJWT = "Bearer " + natTestForeignJWTValue + natTestForeignRandom = "Bearer " + natTestForeignRandomValue ) -// installNATCredentialTestGate wires a fake dashboard-credential classifier +// installNATCredentialTestGate wires fake dashboard-credential classifiers // for the duration of the test: exactly the dashboard-issued test values above -// are classified as dashboard credentials, everything else is foreign. +// are classified as dashboard credentials on every channel, everything else +// is foreign. func installNATCredentialTestGate(t *testing.T) { t.Helper() - original := natDashboardCredentialGate - natDashboardCredentialGate = func(authz string) bool { + originalAuthorization := natDashboardAuthorizationGate + originalValue := natDashboardCredentialValueGate + natDashboardAuthorizationGate = func(authz string) bool { switch authz { case natTestDashboardJWT, natTestDashboardExpiredJWT, natTestDashboardAPIToken: return true @@ -33,5 +45,109 @@ func installNATCredentialTestGate(t *testing.T) { return false } } - t.Cleanup(func() { natDashboardCredentialGate = original }) + natDashboardCredentialValueGate = func(value string) bool { + switch value { + case natTestDashboardJWTValue, natTestDashboardExpiredJWTValue, natTestDashboardPATValue: + return true + default: + return false + } + } + t.Cleanup(func() { + natDashboardAuthorizationGate = originalAuthorization + natDashboardCredentialValueGate = originalValue + }) +} + +func TestStripDashboardCredentialsQueryChannel(t *testing.T) { + // Given + installNATCredentialTestGate(t) + request := &http.Request{URL: &url.URL{Path: "/nat", RawQuery: "keep=1&token=" + natTestDashboardJWTValue + "&token=" + natTestForeignJWTValue + "&a=%2Fx"}} + + // When + stripDashboardCredentials(request) + + // Then + if got := request.URL.RawQuery; got != "keep=1&token="+natTestForeignJWTValue+"&a=%2Fx" { + t.Fatalf("query channel kept %q, want dashboard token dropped and foreign values byte-preserved", got) + } +} + +func TestStripDashboardCredentialsQueryChannelEscapedValue(t *testing.T) { + // Given — a dashboard token percent-encoded in the query must still be + // recognized, while the escaped foreign element stays verbatim. + installNATCredentialTestGate(t) + request := &http.Request{URL: &url.URL{Path: "/nat", RawQuery: "token=" + url.QueryEscape(natTestDashboardJWTValue) + "&token=" + url.QueryEscape(natTestForeignJWTValue)}} + + // When + stripDashboardCredentials(request) + + // Then + if got := request.URL.RawQuery; got != "token="+url.QueryEscape(natTestForeignJWTValue) { + t.Fatalf("query channel kept %q, want escaped dashboard token dropped and foreign element verbatim", got) + } +} + +func TestStripDashboardCredentialsCookieChannel(t *testing.T) { + // Given + installNATCredentialTestGate(t) + request := &http.Request{Header: make(http.Header)} + request.Header.Set("Cookie", "sid=abc; nz-jwt="+natTestDashboardJWTValue+"; nz-jwt="+natTestForeignJWTValue+"; theme=dark") + + // When + stripDashboardCredentials(request) + + // Then + if got := request.Header.Get("Cookie"); got != "sid=abc; nz-jwt="+natTestForeignJWTValue+"; theme=dark" { + t.Fatalf("cookie channel kept %q, want dashboard pair dropped and foreign pairs verbatim", got) + } +} + +func TestStripDashboardCredentialsCookieChannelQuotedValue(t *testing.T) { + // Given — quoting is presentation the panel's cookie parsing strips before + // verifying, so a quoted dashboard token must be recognized. + installNATCredentialTestGate(t) + request := &http.Request{Header: make(http.Header)} + request.Header.Set("Cookie", `nz-jwt="`+natTestDashboardJWTValue+`"; sid=abc`) + + // When + stripDashboardCredentials(request) + + // Then + if got := request.Header.Get("Cookie"); got != "sid=abc" { + t.Fatalf("cookie channel kept %q, want quoted dashboard token dropped", got) + } +} + +func TestPrepareNATCapabilityFailsClosedWithoutGate(t *testing.T) { + // Given — unwired gates: every value on every channel is treated as + // dashboard-issued and stripped, foreign ones included. + SetNATDashboardCredentialGate(nil, nil) + t.Cleanup(func() { SetNATDashboardCredentialGate(nil, nil) }) + request := &http.Request{ + Header: make(http.Header), + URL: &url.URL{Path: "/nat", RawQuery: "keep=1&token=" + natTestForeignJWTValue}, + } + request.Header.Set("Authorization", natTestForeignRandom) + request.Header.Set("Cookie", "sid=abc; nz-jwt="+natTestForeignJWTValue) + + // When + lease, err := prepareNATCapability(request, &model.NAT{Common: model.Common{ID: 91}, ServerID: 81}) + + // Then + if err != nil { + t.Fatalf("fail-closed NAT capability hook returned error: %v", err) + } + if lease.active { + t.Fatal("fail-closed NAT capability hook unexpectedly activated") + } + if got := request.Header.Get("Authorization"); got != "" { + t.Fatalf("fail-closed hook retained Authorization %q", got) + } + if got := request.URL.RawQuery; got != "keep=1" { + t.Fatalf("fail-closed hook kept query %q, want token parameter stripped", got) + } + if got := request.Header.Get("Cookie"); got != "sid=abc" { + t.Fatalf("fail-closed hook kept cookie %q, want nz-jwt pair stripped", got) + } }