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.
This commit is contained in:
xvkong233 authored and naiba committed 2026-10-01 11:17:45 +00:00
1 parent 36b742a7e9
commit 40240ed02b
13 files changed
+662 -14

No files matched your search

+3
View File
@@ -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 以避免浏览器误触。
@@ -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)
}
@@ -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)
}
+5
View File
@@ -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{
+8 -1
View File
@@ -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
@@ -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")
}
@@ -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")
+5 -1
View File
@@ -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
}
@@ -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")
}
}
@@ -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)
}
}
})
}
}
@@ -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 {
+41
View File
@@ -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
}
}
}
+37
View File
@@ -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 })
}