Merge pull request #1241 from xvkong233/fix/nat-selective-authorization-strip

fix(nat): strip only dashboard-issued Authorization credentials at NAT ingress
This commit is contained in:
naiba authored and GitHub committed 2026-10-01 19:25:36 +08:00
commit b71188f104
15 files changed
+1386 -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,118 @@
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; 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
}
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)
}
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,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()...)
}
@@ -0,0 +1,196 @@
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 "))
})
}
// 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
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)
}
@@ -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)
}
+13
View File
@@ -169,6 +169,7 @@ func main() {
grpcHandler := rpc.ServeRPC()
httpHandler := controller.ServeWeb(frontendDist)
controller.InitUpgrader()
wireNATDashboardCredentialGate()
muxHandler := newHTTPandGRPCMux(httpHandler, grpcHandler)
muxServerHTTP := &http.Server{
@@ -228,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)
+9 -1
View File
@@ -80,7 +80,15 @@ 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 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
@@ -22,16 +22,26 @@ 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
// 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] == "" {
request.Header.Del("Authorization")
// Invalid capability: nothing is forwarded, but dashboard-issued
// credentials still must not survive into errors or task data.
stripDashboardCredentials(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 credentials are
// stripped from every TokenLookup channel, foreign ones are forwarded
// untouched.
stripDashboardCredentials(request)
if err != nil {
return natCapabilityLease{}, errors.New("invalid NAT capability")
}
@@ -4,8 +4,10 @@ package rpc
import (
"bytes"
"context"
"log"
"net/http"
"net/url"
"strings"
"testing"
@@ -59,7 +61,193 @@ 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 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": 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),
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})
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 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),
URL: &url.URL{Path: "/nat", RawQuery: "keep=1&token=" + credential},
}
request.Header.Set(agentcompatcontract.IOStreamCapabilityHeader, "malformed")
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})
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 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)
}
})
}
}
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),
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})
// 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 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)
}
}
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 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)}
request.Header.Set(agentcompatcontract.IOStreamCapabilityHeader, "capability-secret")
request.Header.Set("Authorization", "Bearer deterministic-secret")
+6 -1
View File
@@ -18,7 +18,12 @@ 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 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
}
@@ -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")
}
}
@@ -4,6 +4,7 @@ package rpc
import (
"net/http"
"net/url"
"testing"
"github.com/nezhahq/nezha/model"
@@ -12,9 +13,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 +31,119 @@ 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": natTestDashboardJWTValue,
"expired dashboard jwt": natTestDashboardExpiredJWTValue,
"dashboard api token": natTestDashboardPATValue,
} {
t.Run(name, func(t *testing.T) {
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})
// 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 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)
}
})
}
}
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)
}
}
})
}
}
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)
}
})
}
}
@@ -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 {
+188
View File
@@ -0,0 +1,188 @@
package rpc
import (
"net/http"
"net/url"
"strings"
)
// 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"
)
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
}
// 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")
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 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)
}
+153
View File
@@ -0,0 +1,153 @@
package rpc
import (
"net/http"
"net/url"
"testing"
"github.com/nezhahq/nezha/model"
)
// 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 (
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 fake dashboard-credential classifiers
// for the duration of the test: exactly the dashboard-issued test values above
// are classified as dashboard credentials on every channel, everything else
// is foreign.
func installNATCredentialTestGate(t *testing.T) {
t.Helper()
originalAuthorization := natDashboardAuthorizationGate
originalValue := natDashboardCredentialValueGate
natDashboardAuthorizationGate = func(authz string) bool {
switch authz {
case natTestDashboardJWT, natTestDashboardExpiredJWT, natTestDashboardAPIToken:
return true
default:
return false
}
}
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)
}
}