From 40240ed02bdfe4ad3914292c32cac0a52dfc719f Mon Sep 17 00:00:00 2001 From: xvkong233 <88175491+xvkong233@users.noreply.github.com> Date: Thu, 1 Oct 2026 13:56:05 +0800 Subject: [PATCH] fix(nat): strip only dashboard-issued Authorization credentials at NAT ingress Since 2640b86 (v2.2.11, 2026-07-20) the NAT ingress deleted the entire Authorization header, breaking self-hosted apps behind the tunnel that authenticate with their own Bearer tokens (e.g. the Qinglong panel login loop). Strip only credentials issued by this dashboard and forward foreign ones: - controller.IsDashboardCredential classifies an Authorization value as dashboard-issued via a signature-only JWT parser derived from the auth middleware (same key, HS256 pinned, claims validation deliberately skipped so an expired panel token is still recognized and kept out of the tunnel) or an api_tokens lookup for PAT-shaped values. Unknown PATs, foreign signatures, other schemes and missing values pass through. When classification cannot complete (parser not ready, DB nil or error) the verdict fails closed (strip) and the log carries a fixed reason only, never credential content. - Both NAT capability variants (default, and agent-compat legacy / invalid-capability / post-consume paths) replace the unconditional Del("Authorization") with stripDashboardCredential. The rpc package receives the classifier through SetNATDashboardCredentialGate, the same injection pattern as SetMCPKillSwitchObserver; an unwired gate keeps the historical strip-everything behavior. Tests: controller classification semantics (panel JWT, expired panel JWT and panel PAT stripped; foreign-signed JWT, unknown nzp_ token, random strings, non-Bearer schemes and missing header pass through; parser unavailable, DB nil and DB error fail closed with credential-free logs) plus rpc unit and flow coverage for both variants (dashboard credentials never reach the forwarded bytes; foreign and missing Authorization are forwarded untouched). Compatibility: no API, database or frontend changes; NAT backends that do not use Authorization behave exactly as before. --- cmd/dashboard/controller/controller.go | 3 + .../controller/dashboard_credential.go | 104 ++++++++++++ .../controller/dashboard_credential_test.go | 150 ++++++++++++++++++ cmd/dashboard/main.go | 5 + cmd/dashboard/rpc/nat.go | 9 +- .../rpc/nat_capability_agentcompat.go | 14 +- .../rpc/nat_capability_agentcompat_test.go | 122 ++++++++++++++ cmd/dashboard/rpc/nat_capability_default.go | 6 +- .../rpc/nat_capability_default_flow_test.go | 50 +++++- .../rpc/nat_capability_default_test.go | 74 ++++++++- .../nat_capability_flow_agentcompat_test.go | 61 ++++++- cmd/dashboard/rpc/nat_credential.go | 41 +++++ cmd/dashboard/rpc/nat_credential_test.go | 37 +++++ 13 files changed, 662 insertions(+), 14 deletions(-) create mode 100644 cmd/dashboard/controller/dashboard_credential.go create mode 100644 cmd/dashboard/controller/dashboard_credential_test.go create mode 100644 cmd/dashboard/rpc/nat_credential.go create mode 100644 cmd/dashboard/rpc/nat_credential_test.go diff --git a/cmd/dashboard/controller/controller.go b/cmd/dashboard/controller/controller.go index 1823d2c4..a3dd50b0 100644 --- a/cmd/dashboard/controller/controller.go +++ b/cmd/dashboard/controller/controller.go @@ -57,6 +57,9 @@ func routers(r *gin.Engine, frontendDist fs.FS) { if err := authMiddleware.MiddlewareInit(); err != nil { log.Fatal("authMiddleware.MiddlewareInit Error:" + err.Error()) } + // Signature-only view of the auth middleware for IsDashboardCredential: + // same key and pinned algorithm, claims validation deliberately skipped. + dashboardCredentialJWTParser = newDashboardCredentialJWTParser(authMiddleware) // /mcp — Model Context Protocol endpoint, authenticated by PAT only (闸 1 + 闸 2)。 // 不放在 /api/v1 下:MCP client 配置 URL 更短,且 MCP transport 协议演进与 REST API // 解耦。鉴权一律走 apiTokenAuthMiddleware;不接受 JWT 以避免浏览器误触。 diff --git a/cmd/dashboard/controller/dashboard_credential.go b/cmd/dashboard/controller/dashboard_credential.go new file mode 100644 index 00000000..e24ecdd7 --- /dev/null +++ b/cmd/dashboard/controller/dashboard_credential.go @@ -0,0 +1,104 @@ +package controller + +import ( + "errors" + "log" + "strings" + + jwt "github.com/golang-jwt/jwt/v4" + "gorm.io/gorm" + + ginjwt "github.com/appleboy/gin-jwt/v2" + "github.com/nezhahq/nezha/model" + "github.com/nezhahq/nezha/service/singleton" +) + +// dashboardCredentialJWTParser verifies that a JWT was signed by this +// dashboard. It is derived from the auth middleware created in routers() and +// exists only for IsDashboardCredential; it must never authenticate dashboard +// requests on its own. +var dashboardCredentialJWTParser *ginjwt.GinJWTMiddleware + +// newDashboardCredentialJWTParser derives a signature-only parser from the +// dashboard auth middleware. It keeps the exact key value and the pinned HS256 +// signing algorithm of initParams() — there is no second read of +// singleton.Conf.JWTSecretKey — and disables claims validation so expiry does +// not change the verdict: an expired token is still proof the dashboard issued +// it, and keeping expired dashboard tokens out of the NAT tunnel prevents a +// careless backend from treating "any JWT" as its own credential. The live +// auth middleware keeps full claims validation; dashboard authentication is +// not relaxed by this parser in any way. +func newDashboardCredentialJWTParser(authMiddleware *ginjwt.GinJWTMiddleware) *ginjwt.GinJWTMiddleware { + parser := *authMiddleware + parser.ParseOptions = []jwt.ParserOption{jwt.WithoutClaimsValidation()} + return &parser +} + +// IsDashboardCredential reports whether an Authorization header value was +// issued by this dashboard: a panel-signed JWT or a panel API token (PAT). +// +// NAT ingress uses this to enforce the tunnel credential invariant: values +// classified as dashboard-issued are stripped before the request is tunneled +// to the agent, while foreign credentials are forwarded untouched because the +// 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. +func IsDashboardCredential(authz string) bool { + raw := strings.TrimSpace(authz) + if !strings.HasPrefix(raw, "Bearer ") { + return false + } + plaintext := strings.TrimSpace(strings.TrimPrefix(raw, "Bearer ")) + if strings.HasPrefix(plaintext, model.APITokenPrefix) { + return isDashboardAPIToken(plaintext) + } + return isDashboardSignedJWT(plaintext) +} + +// isDashboardAPIToken resolves a PAT-shaped value against the api_tokens +// table. The plaintext token is only ever hashed here; it is never logged. +func isDashboardAPIToken(plaintext string) bool { + if singleton.DB == nil { + logDashboardCredentialUnclassifiable("api token lookup unavailable") + return true + } + var tok model.APIToken + err := singleton.DB.Where("token_hash = ?", model.HashAPIToken(plaintext)).First(&tok).Error + if err == nil { + return true + } + if errors.Is(err, gorm.ErrRecordNotFound) { + // Deterministic proof the token was not issued by this dashboard: + // forwarding it leaks nothing the dashboard ever validated. + return false + } + logDashboardCredentialUnclassifiable("api token lookup failed") + return true +} + +// isDashboardSignedJWT verifies the token signature with the dashboard's +// pinned algorithm and key. Claims validation is disabled on purpose (see +// newDashboardCredentialJWTParser): expiry does not change who issued the +// token. +func isDashboardSignedJWT(token string) bool { + if dashboardCredentialJWTParser == nil { + // routers() has not run yet (startup incomplete): fail closed. + logDashboardCredentialUnclassifiable("dashboard credential parser unavailable") + return true + } + _, err := dashboardCredentialJWTParser.ParseTokenString(token) + return err == nil +} + +// logDashboardCredentialUnclassifiable records a classification failure. The +// Authorization value itself is never included in the message. +func logDashboardCredentialUnclassifiable(reason string) { + log.Printf("NEZHA>> NAT ingress: Authorization value could not be classified (%s); stripping it as a precaution", reason) +} diff --git a/cmd/dashboard/controller/dashboard_credential_test.go b/cmd/dashboard/controller/dashboard_credential_test.go new file mode 100644 index 00000000..9ad1070e --- /dev/null +++ b/cmd/dashboard/controller/dashboard_credential_test.go @@ -0,0 +1,150 @@ +package controller + +import ( + "bytes" + "log" + "testing" + "time" + + jwt "github.com/appleboy/gin-jwt/v2" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + "github.com/nezhahq/nezha/model" + "github.com/nezhahq/nezha/service/singleton" +) + +func setupDashboardCredentialTest(t *testing.T) { + t.Helper() + originalConf, originalDB, originalParser := singleton.Conf, singleton.DB, dashboardCredentialJWTParser + t.Cleanup(func() { + singleton.Conf, singleton.DB, dashboardCredentialJWTParser = originalConf, originalDB, originalParser + }) + + singleton.Conf = &singleton.ConfigClass{Config: &model.Config{}} + singleton.Conf.JWTSecretKey = "dashboard-credential-classification-test-key" + singleton.Conf.JWTTimeout = 1 + + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + sqlDB, err := db.DB() + require.NoError(t, err) + t.Cleanup(func() { _ = sqlDB.Close() }) + require.NoError(t, db.AutoMigrate(&model.APIToken{})) + singleton.DB = db + + // Same construction path as routers(): jwt.New(initParams()). + authMiddleware, err := jwt.New(initParams()) + require.NoError(t, err) + dashboardCredentialJWTParser = newDashboardCredentialJWTParser(authMiddleware) +} + +// mintDashboardCredentialJWT signs a JWT with the dashboard key through +// initParams(), the same key source the production verifier uses. A negative +// timeout mints an already-expired token. +func mintDashboardCredentialJWT(t *testing.T, timeout time.Duration) string { + t.Helper() + params := initParams() + params.Timeout = timeout + mw, err := jwt.New(params) + require.NoError(t, err) + token, _, err := mw.TokenGenerator(nil) + require.NoError(t, err) + return token +} + +func mintForeignJWT(t *testing.T) string { + t.Helper() + params := initParams() + params.Key = []byte("foreign-signing-key-not-held-by-the-dashboard") + mw, err := jwt.New(params) + require.NoError(t, err) + token, _, err := mw.TokenGenerator(nil) + require.NoError(t, err) + return token +} + +func TestIsDashboardCredentialClassification(t *testing.T) { + setupDashboardCredentialTest(t) + + dashboardJWT := mintDashboardCredentialJWT(t, time.Hour) + expiredDashboardJWT := mintDashboardCredentialJWT(t, -time.Hour) + foreignJWT := mintForeignJWT(t) + + dashboardPAT := model.APITokenPrefix + "dashboard-credential-pat" + require.NoError(t, singleton.DB.Create(&model.APIToken{ + UserID: 1, + Name: "nat-classification", + TokenHash: model.HashAPIToken(dashboardPAT), + }).Error) + + t.Run("dashboard signed jwt is a dashboard credential", func(t *testing.T) { + require.True(t, IsDashboardCredential("Bearer "+dashboardJWT)) + }) + + t.Run("expired dashboard signed jwt is still a dashboard credential", func(t *testing.T) { + // Signature verification ignores exp on purpose: an expired token is + // still proof the dashboard issued it. + require.True(t, IsDashboardCredential("Bearer "+expiredDashboardJWT)) + }) + + t.Run("dashboard api token is a dashboard credential", func(t *testing.T) { + require.True(t, IsDashboardCredential("Bearer "+dashboardPAT)) + }) + + t.Run("foreign signed jwt is not a dashboard credential", func(t *testing.T) { + require.False(t, IsDashboardCredential("Bearer "+foreignJWT)) + }) + + t.Run("unknown api token is not a dashboard credential", func(t *testing.T) { + require.False(t, IsDashboardCredential("Bearer "+model.APITokenPrefix+"unknown-to-the-dashboard")) + }) + + t.Run("random string is not a dashboard credential", func(t *testing.T) { + require.False(t, IsDashboardCredential("Bearer random-not-a-token")) + }) + + t.Run("non bearer scheme is not a dashboard credential", func(t *testing.T) { + require.False(t, IsDashboardCredential("Basic "+dashboardJWT)) + // Scheme matching is case-sensitive, as in both dashboard auth middlewares. + require.False(t, IsDashboardCredential("bearer "+dashboardJWT)) + }) + + t.Run("empty or missing header is not a dashboard credential", func(t *testing.T) { + require.False(t, IsDashboardCredential("")) + require.False(t, IsDashboardCredential("Bearer ")) + }) +} + +func TestIsDashboardCredentialFailsClosedWhenParserUnavailable(t *testing.T) { + setupDashboardCredentialTest(t) + dashboardCredentialJWTParser = nil + + token := mintDashboardCredentialJWT(t, time.Hour) + require.True(t, IsDashboardCredential("Bearer "+token)) +} + +func TestIsDashboardCredentialFailsClosedWhenAPITokenLookupUnavailable(t *testing.T) { + setupDashboardCredentialTest(t) + singleton.DB = nil + + require.True(t, IsDashboardCredential("Bearer "+model.APITokenPrefix+"unresolvable")) +} + +func TestIsDashboardCredentialFailsClosedOnDatabaseError(t *testing.T) { + setupDashboardCredentialTest(t) + sqlDB, err := singleton.DB.DB() + require.NoError(t, err) + require.NoError(t, sqlDB.Close()) + + var logs bytes.Buffer + originalOutput := log.Writer() + log.SetOutput(&logs) + t.Cleanup(func() { log.SetOutput(originalOutput) }) + + plaintext := model.APITokenPrefix + "unresolvable-after-db-failure" + require.True(t, IsDashboardCredential("Bearer "+plaintext)) + // The credential content itself must never reach the log. + require.NotContains(t, logs.String(), plaintext) +} diff --git a/cmd/dashboard/main.go b/cmd/dashboard/main.go index 69b905db..50d02590 100644 --- a/cmd/dashboard/main.go +++ b/cmd/dashboard/main.go @@ -169,6 +169,11 @@ 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) muxHandler := newHTTPandGRPCMux(httpHandler, grpcHandler) muxServerHTTP := &http.Server{ diff --git a/cmd/dashboard/rpc/nat.go b/cmd/dashboard/rpc/nat.go index d5b3acb6..5c519493 100644 --- a/cmd/dashboard/rpc/nat.go +++ b/cmd/dashboard/rpc/nat.go @@ -80,7 +80,14 @@ func ServeNAT(w http.ResponseWriter, r *http.Request, natConfig *model.NAT) { return } - // Authorization authenticates Dashboard access before NAT ingress; it must not become an origin credential for the configured NAT backend. + // Credential invariant at NAT ingress: credentials issued by this + // 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. 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 d346ae77..e2c3bc67 100644 --- a/cmd/dashboard/rpc/nat_capability_agentcompat.go +++ b/cmd/dashboard/rpc/nat_capability_agentcompat.go @@ -22,16 +22,24 @@ type natCapabilityLease struct { func prepareNATCapability(request *http.Request, natConfig *model.NAT) (natCapabilityLease, error) { values := request.Header.Values(agentcompatcontract.IOStreamCapabilityHeader) if len(values) == 0 { - request.Header.Del("Authorization") + // 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) return natCapabilityLease{}, nil } request.Header.Del(agentcompatcontract.IOStreamCapabilityHeader) if len(values) != 1 || values[0] == "" { - request.Header.Del("Authorization") + // Invalid capability: nothing is forwarded, but dashboard-issued + // credentials still must not survive into errors or task data. + stripDashboardCredential(request) return natCapabilityLease{}, errors.New("invalid NAT capability") } access, handle, err := serviceRPC.NezhaHandlerSingleton.ConsumeAgentCompatNATCapabilityForProfile(values[0], natConfig.ServerID, natConfig.ID) - request.Header.Del("Authorization") + // 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) 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 94910c40..e1e369b4 100644 --- a/cmd/dashboard/rpc/nat_capability_agentcompat_test.go +++ b/cmd/dashboard/rpc/nat_capability_agentcompat_test.go @@ -4,6 +4,7 @@ package rpc import ( "bytes" + "context" "log" "net/http" "strings" @@ -59,7 +60,128 @@ 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. + installNATCredentialTestGate(t) + for name, credential := range map[string]string{ + "dashboard jwt": natTestDashboardJWT, + "expired dashboard jwt": natTestDashboardExpiredJWT, + "dashboard api token": natTestDashboardAPIToken, + } { + t.Run(name+"/legacy path", func(t *testing.T) { + request := &http.Request{Header: make(http.Header)} + request.Header.Set("Authorization", credential) + + 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.Header.Get("Authorization"); got != "" { + t.Fatalf("legacy NAT path retained dashboard credential %q", got) + } + }) + t.Run(name+"/invalid capability path", func(t *testing.T) { + request := &http.Request{Header: make(http.Header)} + request.Header.Set(agentcompatcontract.IOStreamCapabilityHeader, "malformed") + request.Header.Set("Authorization", credential) + + lease, err := prepareNATCapability(request, &model.NAT{Common: model.Common{ID: 91}, ServerID: 81}) + + if err == nil { + t.Fatal("malformed capability unexpectedly accepted") + } + if lease.active { + t.Fatal("malformed capability unexpectedly activated") + } + if got := request.Header.Get("Authorization"); got != "" { + t.Fatalf("invalid capability path retained dashboard credential %q", got) + } + }) + } +} + +func TestPrepareNATCapabilityAgentCompatStripsDashboardCredentialAfterConsumingCapability(t *testing.T) { + // Given — a well-formed capability that consumes successfully: the request + // is about to be tunneled, so the dashboard credential must be gone. + installNATCredentialTestGate(t) + handler := serviceRPC.NewNezhaHandler() + original := serviceRPC.NezhaHandlerSingleton + serviceRPC.NezhaHandlerSingleton = handler + t.Cleanup(func() { serviceRPC.NezhaHandlerSingleton = original }) + + capability, err := handler.RegisterAgentCompatIOStreamCapability(context.Background(), serviceRPC.AgentCompatCapabilityRegistration{ + Owner: serviceRPC.AgentCompatCapabilityOwner{PATID: 1, UserID: 2}, + Purpose: serviceRPC.AgentCompatCapabilityNAT, + TargetServerID: 81, + ResourceID: 91, + ServerAccessAllowed: true, + }) + if err != nil { + t.Fatalf("register capability: %v", err) + } + request := &http.Request{Header: make(http.Header)} + request.Header.Set(agentcompatcontract.IOStreamCapabilityHeader, capability.String()) + request.Header.Set("Authorization", natTestDashboardJWT) + + // When + lease, err := prepareNATCapability(request, &model.NAT{Common: model.Common{ID: 91}, ServerID: 81}) + + // Then + if err != nil { + t.Fatalf("valid NAT capability rejected: %v", err) + } + if !lease.active { + 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) + } +} + +func TestPrepareNATCapabilityAgentCompatKeepsForeignAndMissingAuthorization(t *testing.T) { + // Given — legacy path: foreign credentials are ordinary request data and + // an absent header must stay untouched. + installNATCredentialTestGate(t) + for name, values := range map[string][]string{ + "foreign jwt": {natTestForeignJWT}, + "foreign random": {natTestForeignRandom}, + "empty value": {""}, + "missing header": nil, + } { + t.Run(name, func(t *testing.T) { + request := &http.Request{Header: make(http.Header)} + for _, value := range values { + request.Header.Set("Authorization", value) + } + + 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") + } + got := request.Header.Values("Authorization") + if len(got) != len(values) { + t.Fatalf("legacy NAT path changed Authorization to %q, want %q", got, values) + } + for i := range values { + if got[i] != values[i] { + t.Fatalf("legacy NAT path changed Authorization to %q, want %q", got, values) + } + } + }) + } +} + func TestServeNATAgentCompatSensitiveHeadersStayOutOfErrorsAndLogs(t *testing.T) { + installNATCredentialTestGate(t) request := &http.Request{Header: make(http.Header)} request.Header.Set(agentcompatcontract.IOStreamCapabilityHeader, "capability-secret") request.Header.Set("Authorization", "Bearer deterministic-secret") diff --git a/cmd/dashboard/rpc/nat_capability_default.go b/cmd/dashboard/rpc/nat_capability_default.go index 8773477b..865c8f08 100644 --- a/cmd/dashboard/rpc/nat_capability_default.go +++ b/cmd/dashboard/rpc/nat_capability_default.go @@ -18,7 +18,11 @@ type natCapabilityLease struct { } func prepareNATCapability(request *http.Request, _ *model.NAT) (natCapabilityLease, error) { - request.Header.Del("Authorization") + // 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) return natCapabilityLease{}, nil } diff --git a/cmd/dashboard/rpc/nat_capability_default_flow_test.go b/cmd/dashboard/rpc/nat_capability_default_flow_test.go index c3698afa..30c7d71b 100644 --- a/cmd/dashboard/rpc/nat_capability_default_flow_test.go +++ b/cmd/dashboard/rpc/nat_capability_default_flow_test.go @@ -19,6 +19,7 @@ import ( func TestServeNATDefaultForwardsCapabilityHeaderAsOrdinaryData(t *testing.T) { fixture := newServeNATFixture(t) + installNATCredentialTestGate(t) connection := newServeNATConn() agent := &serveNATAgent{readErr: io.EOF, writeDone: make(chan struct{})} request := &http.Request{ @@ -28,7 +29,7 @@ func TestServeNATDefaultForwardsCapabilityHeaderAsOrdinaryData(t *testing.T) { Body: &serveNATBody{}, } request.Header.Set(agentcompatcontract.IOStreamCapabilityHeader, "ordinary-data") - request.Header.Set("Authorization", "Bearer deterministic-secret") + request.Header.Set("Authorization", natTestForeignRandom) fixture.taskStream.onSend = func(task *proto.Task) error { var nat model.TaskNAT if err := json.Unmarshal([]byte(task.Data), &nat); err != nil { @@ -58,7 +59,50 @@ func TestServeNATDefaultForwardsCapabilityHeaderAsOrdinaryData(t *testing.T) { if !strings.Contains(forwarded, strings.ToLower(agentcompatcontract.IOStreamCapabilityHeader)+": ordinary-data") { t.Fatal("default NAT flow did not forward capability header as ordinary request data") } - if strings.Contains(forwarded, "Authorization:") || strings.Contains(forwarded, "deterministic-secret") { - t.Fatal("default NAT flow forwarded Authorization credentials") + if !strings.Contains(forwarded, strings.ToLower("Authorization: "+natTestForeignRandom)) { + t.Fatal("default NAT flow stripped a foreign credential that does not belong to the dashboard") + } +} + +func TestServeNATDefaultDoesNotForwardDashboardCredentials(t *testing.T) { + fixture := newServeNATFixture(t) + installNATCredentialTestGate(t) + connection := newServeNATConn() + agent := &serveNATAgent{readErr: io.EOF, writeDone: make(chan struct{})} + request := &http.Request{ + Method: http.MethodPost, + URL: &url.URL{Scheme: "http", Host: "example.test", Path: "/nat"}, + Header: make(http.Header), + Body: &serveNATBody{}, + } + request.Header.Set("Authorization", natTestDashboardJWT) + fixture.taskStream.onSend = func(task *proto.Task) error { + var nat model.TaskNAT + if err := json.Unmarshal([]byte(task.Data), &nat); err != nil { + return err + } + return fixture.handler.AgentConnected(nat.StreamID, agent) + } + done := make(chan struct{}) + deadline, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + go func() { + ServeNAT(&serveNATResponseWriter{conn: connection}, request, &model.NAT{ServerID: fixture.server.ID, Host: "target.example"}) + close(done) + }() + select { + case <-agent.writeDone: + case <-deadline.Done(): + t.Fatal("agent did not receive ordinary NAT request") + } + _ = connection.Close() + select { + case <-done: + case <-deadline.Done(): + t.Fatal("ServeNAT did not finish") + } + forwarded := strings.ToLower(string(agent.writtenBytes())) + if strings.Contains(forwarded, "authorization:") || strings.Contains(forwarded, "dashboard-signed-jwt") { + t.Fatal("default NAT flow forwarded dashboard credentials") } } diff --git a/cmd/dashboard/rpc/nat_capability_default_test.go b/cmd/dashboard/rpc/nat_capability_default_test.go index 7ff5e686..71154084 100644 --- a/cmd/dashboard/rpc/nat_capability_default_test.go +++ b/cmd/dashboard/rpc/nat_capability_default_test.go @@ -12,9 +12,10 @@ import ( func TestPrepareNATCapabilityDefaultPreservesHeaderAsOrdinaryRequestData(t *testing.T) { // Given + installNATCredentialTestGate(t) request := &http.Request{Header: make(http.Header)} request.Header.Set(agentcompatcontract.IOStreamCapabilityHeader, "ordinary-data") - request.Header.Set("Authorization", "Bearer deterministic-secret") + request.Header.Set("Authorization", natTestForeignRandom) // When lease, err := prepareNATCapability(request, &model.NAT{Common: model.Common{ID: 91}, ServerID: 81}) @@ -29,7 +30,74 @@ func TestPrepareNATCapabilityDefaultPreservesHeaderAsOrdinaryRequestData(t *test if got := request.Header.Get(agentcompatcontract.IOStreamCapabilityHeader); got != "ordinary-data" { t.Fatalf("default NAT capability hook changed header to %q", got) } - if got := request.Header.Get("Authorization"); got != "" { - t.Fatalf("default NAT capability hook retained Authorization %q", got) + if got := request.Header.Get("Authorization"); got != natTestForeignRandom { + t.Fatalf("default NAT capability hook changed foreign Authorization to %q", got) + } +} + +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, + } { + t.Run(name, func(t *testing.T) { + request := &http.Request{Header: make(http.Header)} + request.Header.Set("Authorization", credential) + + // 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.Header.Get("Authorization"); got != "" { + t.Fatalf("default NAT capability hook retained dashboard credential %q", got) + } + }) + } +} + +func TestPrepareNATCapabilityDefaultKeepsForeignAndMissingAuthorization(t *testing.T) { + // Given + installNATCredentialTestGate(t) + for name, values := range map[string][]string{ + "foreign jwt": {natTestForeignJWT}, + "foreign random": {natTestForeignRandom}, + "empty value": {""}, + "missing header": nil, + } { + t.Run(name, func(t *testing.T) { + request := &http.Request{Header: make(http.Header)} + for _, value := range values { + request.Header.Set("Authorization", value) + } + + // 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") + } + got := request.Header.Values("Authorization") + if len(got) != len(values) { + t.Fatalf("default NAT capability hook changed Authorization to %q, want %q", got, values) + } + for i := range values { + if got[i] != values[i] { + t.Fatalf("default NAT capability hook changed Authorization to %q, want %q", got, values) + } + } + }) } } diff --git a/cmd/dashboard/rpc/nat_capability_flow_agentcompat_test.go b/cmd/dashboard/rpc/nat_capability_flow_agentcompat_test.go index 2ed5a003..0bfe381a 100644 --- a/cmd/dashboard/rpc/nat_capability_flow_agentcompat_test.go +++ b/cmd/dashboard/rpc/nat_capability_flow_agentcompat_test.go @@ -23,6 +23,7 @@ import ( func TestServeNATAgentCompatPublishesExactStreamAfterRequestTransfer(t *testing.T) { fixture := newServeNATFixture(t) + installNATCredentialTestGate(t) capability, err := fixture.handler.RegisterAgentCompatIOStreamCapability(context.Background(), serviceRPC.AgentCompatCapabilityRegistration{ Owner: serviceRPC.AgentCompatCapabilityOwner{PATID: 1, UserID: 2}, Purpose: serviceRPC.AgentCompatCapabilityNAT, @@ -44,7 +45,7 @@ func TestServeNATAgentCompatPublishesExactStreamAfterRequestTransfer(t *testing. Body: io.NopCloser(strings.NewReader("payload")), } request.Header.Set(agentcompatcontract.IOStreamCapabilityHeader, capability.String()) - request.Header.Set("Authorization", "Bearer deterministic-secret") + request.Header.Set("Authorization", natTestForeignJWT) request.Header.Set("X-Ordinary-NAT", "ordinary") fixture.taskStream.onSend = func(task *proto.Task) error { var nat model.TaskNAT @@ -106,8 +107,62 @@ func TestServeNATAgentCompatPublishesExactStreamAfterRequestTransfer(t *testing. require.Contains(t, forwarded, expected) } require.NotContains(t, forwarded, agentcompatcontract.IOStreamCapabilityHeader) - require.NotContains(t, forwarded, "Authorization:") - require.NotContains(t, forwarded, "deterministic-secret") + // Foreign credentials are ordinary request data for the NAT backend and + // must survive the transfer; dashboard-issued values are stripped before + // any byte leaves the dashboard. + require.Contains(t, forwarded, "Authorization: "+natTestForeignJWT) +} + +func TestServeNATAgentCompatDoesNotForwardDashboardCredentials(t *testing.T) { + fixture := newServeNATFixture(t) + installNATCredentialTestGate(t) + capability, err := fixture.handler.RegisterAgentCompatIOStreamCapability(context.Background(), serviceRPC.AgentCompatCapabilityRegistration{ + Owner: serviceRPC.AgentCompatCapabilityOwner{PATID: 1, UserID: 2}, + Purpose: serviceRPC.AgentCompatCapabilityNAT, + TargetServerID: fixture.server.ID, + ResourceID: 91, + ServerAccessAllowed: true, + }) + require.NoError(t, err) + connection := newServeNATConn() + writer := &serveNATResponseWriter{conn: connection} + agent := &serveNATAgent{readErr: io.EOF, writeDone: make(chan struct{})} + request := &http.Request{ + Method: http.MethodPost, + URL: &url.URL{Scheme: "http", Host: "example.test", Path: "/nat"}, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("payload")), + } + request.Header.Set(agentcompatcontract.IOStreamCapabilityHeader, capability.String()) + request.Header.Set("Authorization", natTestDashboardJWT) + fixture.taskStream.onSend = func(task *proto.Task) error { + var nat model.TaskNAT + if err := json.Unmarshal([]byte(task.Data), &nat); err != nil { + return err + } + return fixture.handler.AgentConnected(nat.StreamID, agent) + } + done := make(chan struct{}) + go func() { + ServeNAT(writer, request, &model.NAT{Common: model.Common{ID: 91}, ServerID: fixture.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 complete") + } + forwarded := strings.ToLower(string(agent.writtenBytes())) + require.NotContains(t, forwarded, "authorization:") + for _, dashboardCredential := range []string{natTestDashboardJWT, natTestDashboardExpiredJWT, natTestDashboardAPIToken} { + require.NotContains(t, forwarded, strings.ToLower(dashboardCredential)) + } } type orderedNATAgent struct { diff --git a/cmd/dashboard/rpc/nat_credential.go b/cmd/dashboard/rpc/nat_credential.go new file mode 100644 index 00000000..eb6470a8 --- /dev/null +++ b/cmd/dashboard/rpc/nat_credential.go @@ -0,0 +1,41 @@ +package rpc + +import "net/http" + +// 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 + +// 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 +} + +// 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 { + // Gate not wired (early startup or tests): fail closed and keep the + // historical behavior of stripping every Authorization header. + request.Header.Del("Authorization") + return + } + // A multi-valued Authorization header is malformed traffic, but any + // 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) { + request.Header.Del("Authorization") + return + } + } +} diff --git a/cmd/dashboard/rpc/nat_credential_test.go b/cmd/dashboard/rpc/nat_credential_test.go new file mode 100644 index 00000000..0d5690d1 --- /dev/null +++ b/cmd/dashboard/rpc/nat_credential_test.go @@ -0,0 +1,37 @@ +package rpc + +import ( + "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). +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" +) + +// installNATCredentialTestGate wires a fake dashboard-credential classifier +// for the duration of the test: exactly the dashboard-issued test values above +// are classified as dashboard credentials, everything else is foreign. +func installNATCredentialTestGate(t *testing.T) { + t.Helper() + original := natDashboardCredentialGate + natDashboardCredentialGate = func(authz string) bool { + switch authz { + case natTestDashboardJWT, natTestDashboardExpiredJWT, natTestDashboardAPIToken: + return true + default: + return false + } + } + t.Cleanup(func() { natDashboardCredentialGate = original }) +}