mirror of
https://github.com/Buriburizaem0n/nezha_domains.git
synced 2026-11-04 08:40:20 +00:00
fix(nat): scrub dashboard-issued credentials from all accepted token channels
The initial selective strip covered only the Authorization header, but the dashboard also accepts its JWT via the ?token= query parameter and the nz-jwt cookie (jwt.go TokenLookup channels). A dashboard credential arriving through those channels would still reach a NAT backend. Apply the same classify-then-strip rule to all three channels at NAT ingress: values verified as dashboard-issued are removed, foreign values are preserved byte-for-byte. Adds a ServeNAT flow test exercising the live classifier and a wiring test pinning the gate injection in main().
This commit is contained in:
1 parent
40240ed02b
commit
3ad985f36e
12 files changed
+806
-82
No files matched your search
@@ -43,19 +43,33 @@ func newDashboardCredentialJWTParser(authMiddleware *ginjwt.GinJWTMiddleware) *g
|
||||
// NAT backend may authenticate with credentials of its own.
|
||||
//
|
||||
// Decision policy mirrors the dashboard's own auth middlewares: only the
|
||||
// case-sensitive "Bearer " scheme is considered; PAT-shaped values are
|
||||
// resolved against the api_tokens table; everything else is treated as a JWT.
|
||||
// Whenever the classification cannot be completed (parser or config not ready,
|
||||
// database failure) the value is conservatively reported as a dashboard
|
||||
// credential so the caller strips it; only a deterministic proof that the
|
||||
// value was not issued by this dashboard (scheme mismatch, unknown PAT hash,
|
||||
// failed signature verification) returns false.
|
||||
// case-sensitive "Bearer " scheme is considered; the value after the scheme is
|
||||
// classified by IsDashboardCredentialValue, which is also the entry point for
|
||||
// the panel's scheme-less TokenLookup channels. Only a deterministic proof
|
||||
// that the value was not issued by this dashboard (scheme mismatch, unknown
|
||||
// PAT hash, failed signature verification) returns false.
|
||||
func IsDashboardCredential(authz string) bool {
|
||||
raw := strings.TrimSpace(authz)
|
||||
if !strings.HasPrefix(raw, "Bearer ") {
|
||||
return false
|
||||
}
|
||||
plaintext := strings.TrimSpace(strings.TrimPrefix(raw, "Bearer "))
|
||||
return IsDashboardCredentialValue(strings.TrimPrefix(raw, "Bearer "))
|
||||
}
|
||||
|
||||
// IsDashboardCredentialValue reports whether a raw credential value — the part
|
||||
// after the "Bearer " scheme, exactly as the panel's other TokenLookup
|
||||
// channels carry it (the ?token= query parameter and the nz-jwt cookie, see
|
||||
// initParams) — was issued by this dashboard: a panel-signed JWT or a panel
|
||||
// API token (PAT).
|
||||
//
|
||||
// Failure policy is identical to IsDashboardCredential: whenever the
|
||||
// classification cannot be completed (parser or config not ready, database
|
||||
// failure) the value is conservatively reported as a dashboard credential so
|
||||
// the caller strips it; only a deterministic proof that the value was not
|
||||
// issued by this dashboard (unknown PAT hash, failed signature verification)
|
||||
// returns false.
|
||||
func IsDashboardCredentialValue(value string) bool {
|
||||
plaintext := strings.TrimSpace(value)
|
||||
if strings.HasPrefix(plaintext, model.APITokenPrefix) {
|
||||
return isDashboardAPIToken(plaintext)
|
||||
}
|
||||
|
||||
@@ -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()...)
|
||||
}
|
||||
@@ -117,6 +117,52 @@ func TestIsDashboardCredentialClassification(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestIsDashboardCredentialValueClassification pins the scheme-less classifier
|
||||
// that backs IsDashboardCredential and drives the ?token= query parameter and
|
||||
// nz-jwt cookie channels at NAT ingress (see IsDashboardCredentialValue).
|
||||
func TestIsDashboardCredentialValueClassification(t *testing.T) {
|
||||
setupDashboardCredentialTest(t)
|
||||
|
||||
dashboardJWT := mintDashboardCredentialJWT(t, time.Hour)
|
||||
expiredDashboardJWT := mintDashboardCredentialJWT(t, -time.Hour)
|
||||
foreignJWT := mintForeignJWT(t)
|
||||
|
||||
dashboardPAT := model.APITokenPrefix + "dashboard-credential-value-pat"
|
||||
require.NoError(t, singleton.DB.Create(&model.APIToken{
|
||||
UserID: 1,
|
||||
Name: "nat-value-classification",
|
||||
TokenHash: model.HashAPIToken(dashboardPAT),
|
||||
}).Error)
|
||||
|
||||
t.Run("dashboard signed jwt is a dashboard credential", func(t *testing.T) {
|
||||
require.True(t, IsDashboardCredentialValue(dashboardJWT))
|
||||
})
|
||||
t.Run("expired dashboard signed jwt is still a dashboard credential", func(t *testing.T) {
|
||||
require.True(t, IsDashboardCredentialValue(expiredDashboardJWT))
|
||||
})
|
||||
t.Run("dashboard api token is a dashboard credential", func(t *testing.T) {
|
||||
require.True(t, IsDashboardCredentialValue(dashboardPAT))
|
||||
})
|
||||
t.Run("foreign signed jwt is not a dashboard credential", func(t *testing.T) {
|
||||
require.False(t, IsDashboardCredentialValue(foreignJWT))
|
||||
})
|
||||
t.Run("unknown api token is not a dashboard credential", func(t *testing.T) {
|
||||
require.False(t, IsDashboardCredentialValue(model.APITokenPrefix+"unknown-to-the-dashboard"))
|
||||
})
|
||||
t.Run("random string is not a dashboard credential", func(t *testing.T) {
|
||||
require.False(t, IsDashboardCredentialValue("random-not-a-token"))
|
||||
})
|
||||
t.Run("empty value is not a dashboard credential", func(t *testing.T) {
|
||||
require.False(t, IsDashboardCredentialValue(""))
|
||||
})
|
||||
t.Run("bearer scheme is not part of the value contract", func(t *testing.T) {
|
||||
// The query and cookie channels carry the bare value; a value that
|
||||
// itself starts with "Bearer " is not something the panel issues and
|
||||
// fails signature verification.
|
||||
require.False(t, IsDashboardCredentialValue("Bearer "+dashboardJWT))
|
||||
})
|
||||
}
|
||||
|
||||
func TestIsDashboardCredentialFailsClosedWhenParserUnavailable(t *testing.T) {
|
||||
setupDashboardCredentialTest(t)
|
||||
dashboardCredentialJWTParser = nil
|
||||
|
||||
@@ -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
-5
@@ -169,11 +169,7 @@ func main() {
|
||||
grpcHandler := rpc.ServeRPC()
|
||||
httpHandler := controller.ServeWeb(frontendDist)
|
||||
controller.InitUpgrader()
|
||||
// The NAT ingress strips dashboard-issued Authorization values (panel JWT /
|
||||
// panel PAT) before tunneling a request to the agent and forwards foreign
|
||||
// credentials untouched. ServeWeb built the JWT parser the classifier
|
||||
// reuses, so the gate is wired right after it.
|
||||
rpc.SetNATDashboardCredentialGate(controller.IsDashboardCredential)
|
||||
wireNATDashboardCredentialGate()
|
||||
|
||||
muxHandler := newHTTPandGRPCMux(httpHandler, grpcHandler)
|
||||
muxServerHTTP := &http.Server{
|
||||
@@ -233,6 +229,18 @@ func main() {
|
||||
close(errChan)
|
||||
}
|
||||
|
||||
// wireNATDashboardCredentialGate connects the NAT ingress to the real
|
||||
// dashboard-credential classifiers. The NAT ingress strips dashboard-issued
|
||||
// credentials (panel JWT / panel PAT) from every channel the panel's own
|
||||
// TokenLookup accepts — the Authorization header, the ?token= query parameter
|
||||
// and the nz-jwt cookie — before tunneling a request to the agent, and
|
||||
// forwards foreign values untouched. ServeWeb built the JWT parser the
|
||||
// classifiers reuse, so the gate is wired right after it and before any
|
||||
// listener starts serving.
|
||||
func wireNATDashboardCredentialGate() {
|
||||
rpc.SetNATDashboardCredentialGate(controller.IsDashboardCredential, controller.IsDashboardCredentialValue)
|
||||
}
|
||||
|
||||
func newHTTPandGRPCMux(httpHandler http.Handler, grpcHandler http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
natConfig := singleton.NATShared.GetNATConfigByDomain(r.Host)
|
||||
|
||||
@@ -84,10 +84,11 @@ func ServeNAT(w http.ResponseWriter, r *http.Request, natConfig *model.NAT) {
|
||||
// dashboard — the panel-signed JWT (verified by signature only, expiry
|
||||
// deliberately ignored) and the panel API token — must never become origin
|
||||
// credentials for the configured NAT backend. prepareNATCapability has
|
||||
// already stripped every Authorization value IsDashboardCredential
|
||||
// classified as dashboard-issued; whatever remains is either absent or a
|
||||
// foreign credential, which is forwarded untouched because the NAT backend
|
||||
// may authenticate with credentials of its own.
|
||||
// already stripped them from every channel the panel's own TokenLookup
|
||||
// accepts (Authorization header, ?token= query parameter, nz-jwt cookie);
|
||||
// whatever remains on those channels is either absent or a foreign
|
||||
// credential, which is forwarded untouched because the NAT backend may
|
||||
// authenticate with credentials of its own.
|
||||
wWrapped, err := utils.NewRequestWrapper(r, w)
|
||||
if err != nil {
|
||||
return
|
||||
|
||||
@@ -23,23 +23,25 @@ func prepareNATCapability(request *http.Request, natConfig *model.NAT) (natCapab
|
||||
values := request.Header.Values(agentcompatcontract.IOStreamCapabilityHeader)
|
||||
if len(values) == 0 {
|
||||
// No capability header: legacy NAT path. Strip dashboard-issued
|
||||
// Authorization values (panel JWT or panel PAT) so they never become
|
||||
// origin credentials; foreign ones are ordinary request data.
|
||||
stripDashboardCredential(request)
|
||||
// credentials (panel JWT or panel PAT) from the Authorization header,
|
||||
// the ?token= query parameter and the nz-jwt cookie so they never
|
||||
// become origin credentials; foreign values are ordinary request data.
|
||||
stripDashboardCredentials(request)
|
||||
return natCapabilityLease{}, nil
|
||||
}
|
||||
request.Header.Del(agentcompatcontract.IOStreamCapabilityHeader)
|
||||
if len(values) != 1 || values[0] == "" {
|
||||
// Invalid capability: nothing is forwarded, but dashboard-issued
|
||||
// credentials still must not survive into errors or task data.
|
||||
stripDashboardCredential(request)
|
||||
stripDashboardCredentials(request)
|
||||
return natCapabilityLease{}, errors.New("invalid NAT capability")
|
||||
}
|
||||
access, handle, err := serviceRPC.NezhaHandlerSingleton.ConsumeAgentCompatNATCapabilityForProfile(values[0], natConfig.ServerID, natConfig.ID)
|
||||
// From here the request bytes may reach the agent (active lease) or the
|
||||
// request ends in a 503; either way dashboard-issued Authorization values
|
||||
// are stripped, foreign ones are forwarded untouched.
|
||||
stripDashboardCredential(request)
|
||||
// request ends in a 503; either way dashboard-issued credentials are
|
||||
// stripped from every TokenLookup channel, foreign ones are forwarded
|
||||
// untouched.
|
||||
stripDashboardCredentials(request)
|
||||
if err != nil {
|
||||
return natCapabilityLease{}, errors.New("invalid NAT capability")
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -62,16 +63,21 @@ func TestPrepareNATCapabilityRejectsDuplicateHeaderAfterRemovingAllValues(t *tes
|
||||
|
||||
func TestPrepareNATCapabilityAgentCompatStripsDashboardCredentials(t *testing.T) {
|
||||
// Given — legacy path without a capability header, then the invalid
|
||||
// capability path: both must drop dashboard-issued Authorization values.
|
||||
// capability path: both must drop dashboard-issued credentials from the
|
||||
// Authorization header, the ?token= query parameter and the nz-jwt cookie.
|
||||
installNATCredentialTestGate(t)
|
||||
for name, credential := range map[string]string{
|
||||
"dashboard jwt": natTestDashboardJWT,
|
||||
"expired dashboard jwt": natTestDashboardExpiredJWT,
|
||||
"dashboard api token": natTestDashboardAPIToken,
|
||||
"dashboard jwt": natTestDashboardJWTValue,
|
||||
"expired dashboard jwt": natTestDashboardExpiredJWTValue,
|
||||
"dashboard api token": natTestDashboardPATValue,
|
||||
} {
|
||||
t.Run(name+"/legacy path", func(t *testing.T) {
|
||||
request := &http.Request{Header: make(http.Header)}
|
||||
request.Header.Set("Authorization", credential)
|
||||
request := &http.Request{
|
||||
Header: make(http.Header),
|
||||
URL: &url.URL{Path: "/nat", RawQuery: "keep=1&token=" + credential},
|
||||
}
|
||||
request.Header.Set("Authorization", "Bearer "+credential)
|
||||
request.Header.Set("Cookie", "sid=abc; nz-jwt="+credential)
|
||||
|
||||
lease, err := prepareNATCapability(request, &model.NAT{Common: model.Common{ID: 91}, ServerID: 81})
|
||||
|
||||
@@ -82,13 +88,23 @@ func TestPrepareNATCapabilityAgentCompatStripsDashboardCredentials(t *testing.T)
|
||||
t.Fatal("legacy NAT path unexpectedly activated")
|
||||
}
|
||||
if got := request.Header.Get("Authorization"); got != "" {
|
||||
t.Fatalf("legacy NAT path retained dashboard credential %q", got)
|
||||
t.Fatalf("legacy NAT path retained dashboard credential in Authorization %q", got)
|
||||
}
|
||||
if got := request.URL.RawQuery; got != "keep=1" {
|
||||
t.Fatalf("legacy NAT path retained dashboard credential in query %q", got)
|
||||
}
|
||||
if got := request.Header.Get("Cookie"); got != "sid=abc" {
|
||||
t.Fatalf("legacy NAT path retained dashboard credential in cookie %q", got)
|
||||
}
|
||||
})
|
||||
t.Run(name+"/invalid capability path", func(t *testing.T) {
|
||||
request := &http.Request{Header: make(http.Header)}
|
||||
request := &http.Request{
|
||||
Header: make(http.Header),
|
||||
URL: &url.URL{Path: "/nat", RawQuery: "keep=1&token=" + credential},
|
||||
}
|
||||
request.Header.Set(agentcompatcontract.IOStreamCapabilityHeader, "malformed")
|
||||
request.Header.Set("Authorization", credential)
|
||||
request.Header.Set("Authorization", "Bearer "+credential)
|
||||
request.Header.Set("Cookie", "sid=abc; nz-jwt="+credential)
|
||||
|
||||
lease, err := prepareNATCapability(request, &model.NAT{Common: model.Common{ID: 91}, ServerID: 81})
|
||||
|
||||
@@ -99,7 +115,13 @@ func TestPrepareNATCapabilityAgentCompatStripsDashboardCredentials(t *testing.T)
|
||||
t.Fatal("malformed capability unexpectedly activated")
|
||||
}
|
||||
if got := request.Header.Get("Authorization"); got != "" {
|
||||
t.Fatalf("invalid capability path retained dashboard credential %q", got)
|
||||
t.Fatalf("invalid capability path retained dashboard credential in Authorization %q", got)
|
||||
}
|
||||
if got := request.URL.RawQuery; got != "keep=1" {
|
||||
t.Fatalf("invalid capability path retained dashboard credential in query %q", got)
|
||||
}
|
||||
if got := request.Header.Get("Cookie"); got != "sid=abc" {
|
||||
t.Fatalf("invalid capability path retained dashboard credential in cookie %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -124,9 +146,13 @@ func TestPrepareNATCapabilityAgentCompatStripsDashboardCredentialAfterConsumingC
|
||||
if err != nil {
|
||||
t.Fatalf("register capability: %v", err)
|
||||
}
|
||||
request := &http.Request{Header: make(http.Header)}
|
||||
request := &http.Request{
|
||||
Header: make(http.Header),
|
||||
URL: &url.URL{Path: "/nat", RawQuery: "keep=1&token=" + natTestDashboardJWTValue},
|
||||
}
|
||||
request.Header.Set(agentcompatcontract.IOStreamCapabilityHeader, capability.String())
|
||||
request.Header.Set("Authorization", natTestDashboardJWT)
|
||||
request.Header.Set("Cookie", "sid=abc; nz-jwt="+natTestDashboardJWTValue)
|
||||
|
||||
// When
|
||||
lease, err := prepareNATCapability(request, &model.NAT{Common: model.Common{ID: 91}, ServerID: 81})
|
||||
@@ -139,7 +165,13 @@ func TestPrepareNATCapabilityAgentCompatStripsDashboardCredentialAfterConsumingC
|
||||
t.Fatal("valid NAT capability did not activate")
|
||||
}
|
||||
if got := request.Header.Get("Authorization"); got != "" {
|
||||
t.Fatalf("dashboard credential retained after capability consumption: %q", got)
|
||||
t.Fatalf("dashboard credential retained in Authorization after capability consumption: %q", got)
|
||||
}
|
||||
if got := request.URL.RawQuery; got != "keep=1" {
|
||||
t.Fatalf("dashboard credential retained in query after capability consumption: %q", got)
|
||||
}
|
||||
if got := request.Header.Get("Cookie"); got != "sid=abc" {
|
||||
t.Fatalf("dashboard credential retained in cookie after capability consumption: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,6 +212,40 @@ func TestPrepareNATCapabilityAgentCompatKeepsForeignAndMissingAuthorization(t *t
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareNATCapabilityAgentCompatKeepsForeignAndMissingQueryAndCookie(t *testing.T) {
|
||||
// Given — legacy path: foreign query tokens and cookies are ordinary
|
||||
// request data, and absent channels must stay untouched.
|
||||
installNATCredentialTestGate(t)
|
||||
for name, tc := range map[string]struct{ rawQuery, cookie string }{
|
||||
"foreign values": {"keep=1&token=" + natTestForeignJWTValue + "&a=%2Fx", "sid=abc; nz-jwt=" + natTestForeignJWTValue},
|
||||
"empty values": {"keep=1&token=", "nz-jwt="},
|
||||
"missing channel": {"keep=1", "sid=abc"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
request := &http.Request{
|
||||
Header: make(http.Header),
|
||||
URL: &url.URL{Path: "/nat", RawQuery: tc.rawQuery},
|
||||
}
|
||||
request.Header.Set("Cookie", tc.cookie)
|
||||
|
||||
lease, err := prepareNATCapability(request, &model.NAT{Common: model.Common{ID: 91}, ServerID: 81})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("legacy NAT path returned error: %v", err)
|
||||
}
|
||||
if lease.active {
|
||||
t.Fatal("legacy NAT path unexpectedly activated")
|
||||
}
|
||||
if got := request.URL.RawQuery; got != tc.rawQuery {
|
||||
t.Fatalf("legacy NAT path changed query to %q, want %q", got, tc.rawQuery)
|
||||
}
|
||||
if got := request.Header.Get("Cookie"); got != tc.cookie {
|
||||
t.Fatalf("legacy NAT path changed cookie to %q, want %q", got, tc.cookie)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeNATAgentCompatSensitiveHeadersStayOutOfErrorsAndLogs(t *testing.T) {
|
||||
installNATCredentialTestGate(t)
|
||||
request := &http.Request{Header: make(http.Header)}
|
||||
|
||||
@@ -19,10 +19,11 @@ type natCapabilityLease struct {
|
||||
|
||||
func prepareNATCapability(request *http.Request, _ *model.NAT) (natCapabilityLease, error) {
|
||||
// The default build has no capability protocol: every request takes the
|
||||
// legacy NAT path, so any dashboard-issued Authorization value (panel JWT
|
||||
// or panel PAT) is stripped before the request is tunneled to the agent.
|
||||
// Foreign Authorization values are ordinary request data and stay put.
|
||||
stripDashboardCredential(request)
|
||||
// legacy NAT path, so any dashboard-issued credential (panel JWT or panel
|
||||
// PAT) carried by the Authorization header, the ?token= query parameter or
|
||||
// the nz-jwt cookie is stripped before the request is tunneled to the
|
||||
// agent. Foreign values are ordinary request data and stay put.
|
||||
stripDashboardCredentials(request)
|
||||
return natCapabilityLease{}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ package rpc
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/nezhahq/nezha/model"
|
||||
@@ -39,13 +40,17 @@ func TestPrepareNATCapabilityDefaultStripsDashboardCredentials(t *testing.T) {
|
||||
// Given
|
||||
installNATCredentialTestGate(t)
|
||||
for name, credential := range map[string]string{
|
||||
"dashboard jwt": natTestDashboardJWT,
|
||||
"expired dashboard jwt": natTestDashboardExpiredJWT,
|
||||
"dashboard api token": natTestDashboardAPIToken,
|
||||
"dashboard jwt": natTestDashboardJWTValue,
|
||||
"expired dashboard jwt": natTestDashboardExpiredJWTValue,
|
||||
"dashboard api token": natTestDashboardPATValue,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
request := &http.Request{Header: make(http.Header)}
|
||||
request.Header.Set("Authorization", credential)
|
||||
request := &http.Request{
|
||||
Header: make(http.Header),
|
||||
URL: &url.URL{Path: "/nat", RawQuery: "keep=1&token=" + credential},
|
||||
}
|
||||
request.Header.Set("Authorization", "Bearer "+credential)
|
||||
request.Header.Set("Cookie", "sid=abc; nz-jwt="+credential)
|
||||
|
||||
// When
|
||||
lease, err := prepareNATCapability(request, &model.NAT{Common: model.Common{ID: 91}, ServerID: 81})
|
||||
@@ -58,7 +63,13 @@ func TestPrepareNATCapabilityDefaultStripsDashboardCredentials(t *testing.T) {
|
||||
t.Fatal("default NAT capability hook unexpectedly activated")
|
||||
}
|
||||
if got := request.Header.Get("Authorization"); got != "" {
|
||||
t.Fatalf("default NAT capability hook retained dashboard credential %q", got)
|
||||
t.Fatalf("default NAT capability hook retained dashboard credential in Authorization %q", got)
|
||||
}
|
||||
if got := request.URL.RawQuery; got != "keep=1" {
|
||||
t.Fatalf("default NAT capability hook retained dashboard credential in query %q", got)
|
||||
}
|
||||
if got := request.Header.Get("Cookie"); got != "sid=abc" {
|
||||
t.Fatalf("default NAT capability hook retained dashboard credential in cookie %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -101,3 +112,38 @@ func TestPrepareNATCapabilityDefaultKeepsForeignAndMissingAuthorization(t *testi
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareNATCapabilityDefaultKeepsForeignAndMissingQueryAndCookie(t *testing.T) {
|
||||
// Given
|
||||
installNATCredentialTestGate(t)
|
||||
for name, tc := range map[string]struct{ rawQuery, cookie string }{
|
||||
"foreign values": {"keep=1&token=" + natTestForeignJWTValue + "&a=%2Fx", "sid=abc; nz-jwt=" + natTestForeignJWTValue},
|
||||
"empty values": {"keep=1&token=", "nz-jwt="},
|
||||
"missing channel": {"keep=1", "sid=abc"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
request := &http.Request{
|
||||
Header: make(http.Header),
|
||||
URL: &url.URL{Path: "/nat", RawQuery: tc.rawQuery},
|
||||
}
|
||||
request.Header.Set("Cookie", tc.cookie)
|
||||
|
||||
// When
|
||||
lease, err := prepareNATCapability(request, &model.NAT{Common: model.Common{ID: 91}, ServerID: 81})
|
||||
|
||||
// Then
|
||||
if err != nil {
|
||||
t.Fatalf("default NAT capability hook returned error: %v", err)
|
||||
}
|
||||
if lease.active {
|
||||
t.Fatal("default NAT capability hook unexpectedly activated")
|
||||
}
|
||||
if got := request.URL.RawQuery; got != tc.rawQuery {
|
||||
t.Fatalf("default NAT capability hook changed query to %q, want %q", got, tc.rawQuery)
|
||||
}
|
||||
if got := request.Header.Get("Cookie"); got != tc.cookie {
|
||||
t.Fatalf("default NAT capability hook changed cookie to %q, want %q", got, tc.cookie)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,29 +1,69 @@
|
||||
package rpc
|
||||
|
||||
import "net/http"
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// natDashboardCredentialGate classifies an Authorization header value as a
|
||||
// credential issued by this dashboard (panel-signed JWT or panel API token).
|
||||
// main() injects controller.IsDashboardCredential; the setter keeps the rpc
|
||||
// package free of a dependency on the controller package, the same pattern as
|
||||
// SetReceiptGateListener and SetMCPKillSwitchObserver.
|
||||
var natDashboardCredentialGate func(authz string) bool
|
||||
// Channel names of the panel's own TokenLookup (controller/jwt.go
|
||||
// "header: Authorization, query: token, cookie: nz-jwt"). A dashboard JWT
|
||||
// can therefore reach the NAT ingress through the Authorization header, the
|
||||
// ?token= query parameter or the nz-jwt cookie, and all three must be
|
||||
// classified before the request is tunneled to the agent.
|
||||
const (
|
||||
natDashboardJWTCookieName = "nz-jwt"
|
||||
natDashboardJWTQueryParameter = "token"
|
||||
)
|
||||
|
||||
// SetNATDashboardCredentialGate wires the dashboard-credential classifier used
|
||||
// by the NAT ingress. Passing nil restores the fail-closed default in
|
||||
// stripDashboardCredential.
|
||||
func SetNATDashboardCredentialGate(fn func(authz string) bool) {
|
||||
natDashboardCredentialGate = fn
|
||||
var (
|
||||
// natDashboardAuthorizationGate classifies an Authorization header value
|
||||
// (case-sensitive "Bearer " scheme included) as a credential issued by
|
||||
// this dashboard.
|
||||
natDashboardAuthorizationGate func(authz string) bool
|
||||
// natDashboardCredentialValueGate classifies a raw credential value — the
|
||||
// part after the "Bearer " scheme, exactly as the ?token= query parameter
|
||||
// and the nz-jwt cookie carry it — as a credential issued by this
|
||||
// dashboard.
|
||||
natDashboardCredentialValueGate func(value string) bool
|
||||
)
|
||||
|
||||
// SetNATDashboardCredentialGate wires the dashboard-credential classifiers
|
||||
// used by the NAT ingress. main() injects
|
||||
// controller.IsDashboardCredential and controller.IsDashboardCredentialValue;
|
||||
// the setter keeps the rpc package free of a dependency on the controller
|
||||
// package, the same pattern as SetReceiptGateListener and
|
||||
// SetMCPKillSwitchObserver. A nil function fails closed: every value on that
|
||||
// channel is then treated as dashboard-issued and stripped.
|
||||
func SetNATDashboardCredentialGate(authorizationClass func(authz string) bool, valueClass func(value string) bool) {
|
||||
natDashboardAuthorizationGate = authorizationClass
|
||||
natDashboardCredentialValueGate = valueClass
|
||||
}
|
||||
|
||||
// stripDashboardCredential removes the Authorization header when it carries a
|
||||
// credential issued by this dashboard (panel-signed JWT or panel API token).
|
||||
// Such credentials authenticate the dashboard itself and must never become
|
||||
// origin credentials for the NAT backend. Foreign credentials are forwarded
|
||||
// untouched: the NAT backend may authenticate with credentials of its own, and
|
||||
// this dashboard must not make policy for them.
|
||||
func stripDashboardCredential(request *http.Request) {
|
||||
if natDashboardCredentialGate == nil {
|
||||
// NATDashboardCredentialGateWired reports whether the dashboard-credential
|
||||
// classifiers have been wired. It exists so the main() wiring contract can be
|
||||
// pinned by a test.
|
||||
func NATDashboardCredentialGateWired() bool {
|
||||
return natDashboardAuthorizationGate != nil && natDashboardCredentialValueGate != nil
|
||||
}
|
||||
|
||||
// stripDashboardCredentials removes credentials issued by this dashboard from
|
||||
// every channel the panel's own TokenLookup accepts: the Authorization
|
||||
// header, the ?token= query parameter and the nz-jwt cookie. Such credentials
|
||||
// authenticate the dashboard itself and must never become origin credentials
|
||||
// for the NAT backend. Foreign values are forwarded untouched: the NAT
|
||||
// backend may authenticate with credentials of its own, and this dashboard
|
||||
// must not make policy for them.
|
||||
func stripDashboardCredentials(request *http.Request) {
|
||||
stripDashboardAuthorizationHeader(request)
|
||||
stripDashboardCredentialQuery(request)
|
||||
stripDashboardCredentialCookie(request)
|
||||
}
|
||||
|
||||
// stripDashboardAuthorizationHeader removes the Authorization header when it
|
||||
// carries a dashboard-issued credential.
|
||||
func stripDashboardAuthorizationHeader(request *http.Request) {
|
||||
if natDashboardAuthorizationGate == nil {
|
||||
// Gate not wired (early startup or tests): fail closed and keep the
|
||||
// historical behavior of stripping every Authorization header.
|
||||
request.Header.Del("Authorization")
|
||||
@@ -33,9 +73,116 @@ func stripDashboardCredential(request *http.Request) {
|
||||
// dashboard-issued value in it must still not survive into the tunnel, so
|
||||
// the whole header is dropped when one is recognized.
|
||||
for _, value := range request.Header.Values("Authorization") {
|
||||
if natDashboardCredentialGate(value) {
|
||||
if natDashboardAuthorizationGate(value) {
|
||||
request.Header.Del("Authorization")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// stripDashboardCredentialQuery removes ?token= query parameters whose value
|
||||
// is a dashboard-issued credential. Every other element of the query string —
|
||||
// including foreign token values — is preserved byte-for-byte, so the
|
||||
// forwarded request keeps the exact query the client sent.
|
||||
func stripDashboardCredentialQuery(request *http.Request) {
|
||||
if request.URL == nil || request.URL.RawQuery == "" {
|
||||
return
|
||||
}
|
||||
elements := strings.Split(request.URL.RawQuery, "&")
|
||||
kept := elements[:0]
|
||||
changed := false
|
||||
for _, element := range elements {
|
||||
if isDashboardCredentialQueryElement(element) {
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
kept = append(kept, element)
|
||||
}
|
||||
if changed {
|
||||
request.URL.RawQuery = strings.Join(kept, "&")
|
||||
}
|
||||
}
|
||||
|
||||
// isDashboardCredentialQueryElement reports whether a raw query element
|
||||
// carries a dashboard-issued credential in the panel's token parameter.
|
||||
func isDashboardCredentialQueryElement(element string) bool {
|
||||
rawName, rawValue, _ := strings.Cut(element, "=")
|
||||
// The panel reads the parameter by its decoded name (gin-jwt c.Query),
|
||||
// so an escaped key must be decoded before comparing.
|
||||
name, err := url.QueryUnescape(rawName)
|
||||
if err != nil || name != natDashboardJWTQueryParameter {
|
||||
return false
|
||||
}
|
||||
if natDashboardCredentialValueGate == nil {
|
||||
return true // fail closed
|
||||
}
|
||||
// A value that fails to decode is one the panel's own query parsing drops
|
||||
// as well, so it can never be a panel credential.
|
||||
decoded, err := url.QueryUnescape(rawValue)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return natDashboardCredentialValueGate(decoded)
|
||||
}
|
||||
|
||||
// stripDashboardCredentialCookie removes nz-jwt cookies whose value is a
|
||||
// dashboard-issued credential. All other cookie pairs — including foreign
|
||||
// nz-jwt values — are preserved verbatim.
|
||||
func stripDashboardCredentialCookie(request *http.Request) {
|
||||
lines := request.Header.Values("Cookie")
|
||||
if len(lines) == 0 {
|
||||
return
|
||||
}
|
||||
changed := false
|
||||
kept := make([]string, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
nextLine, lineChanged := stripDashboardCredentialCookieLine(line)
|
||||
changed = changed || lineChanged
|
||||
if nextLine != "" {
|
||||
kept = append(kept, nextLine)
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return
|
||||
}
|
||||
request.Header.Del("Cookie")
|
||||
for _, line := range kept {
|
||||
request.Header.Add("Cookie", line)
|
||||
}
|
||||
}
|
||||
|
||||
// stripDashboardCredentialCookieLine rewrites a single Cookie header line,
|
||||
// dropping the dashboard-issued nz-jwt pairs. Kept pairs keep their raw form,
|
||||
// whitespace included — except that a pair following a dropped one no longer
|
||||
// carries the removed pair's share of the "; " separator.
|
||||
func stripDashboardCredentialCookieLine(line string) (string, bool) {
|
||||
pairs := strings.Split(line, ";")
|
||||
kept := pairs[:0]
|
||||
droppedBeforeFirstKept := true
|
||||
changed := false
|
||||
for _, pair := range pairs {
|
||||
name, value, _ := strings.Cut(pair, "=")
|
||||
if strings.TrimSpace(name) == natDashboardJWTCookieName &&
|
||||
(natDashboardCredentialValueGate == nil || isDashboardCredentialCookieValue(value)) {
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
if droppedBeforeFirstKept {
|
||||
pair = strings.TrimLeft(pair, " ")
|
||||
}
|
||||
droppedBeforeFirstKept = false
|
||||
kept = append(kept, pair)
|
||||
}
|
||||
if !changed {
|
||||
return line, false
|
||||
}
|
||||
return strings.Join(kept, ";"), true
|
||||
}
|
||||
|
||||
// isDashboardCredentialCookieValue classifies a raw cookie value the same way
|
||||
// the panel's cookie parsing would hand it to the JWT verifier: surrounding
|
||||
// whitespace and quoting are presentation, not part of the credential.
|
||||
func isDashboardCredentialCookieValue(rawValue string) bool {
|
||||
value := strings.Trim(strings.TrimSpace(rawValue), `"`)
|
||||
return natDashboardCredentialValueGate(value)
|
||||
}
|
||||
@@ -1,31 +1,43 @@
|
||||
package rpc
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/nezhahq/nezha/model"
|
||||
)
|
||||
|
||||
// Values standing in for the credentials this dashboard issues. The fake gate
|
||||
// below mirrors the contract of controller.IsDashboardCredential; the real
|
||||
// classification semantics — panel JWTs accepted regardless of expiry,
|
||||
// foreign-signed JWTs and random strings rejected — are pinned by the
|
||||
// controller package tests (dashboard_credential_test.go).
|
||||
// Values standing in for the credentials this dashboard issues. The fake
|
||||
// gates below mirror the contract of controller.IsDashboardCredential and
|
||||
// controller.IsDashboardCredentialValue; the real classification semantics —
|
||||
// panel JWTs accepted regardless of expiry, foreign-signed JWTs and random
|
||||
// strings rejected — are pinned by the controller package tests
|
||||
// (dashboard_credential_test.go) and by the flow test that wires the real
|
||||
// classifiers into ServeNAT (dashboard_credential_nat_flow_test.go).
|
||||
const (
|
||||
natTestDashboardJWT = "Bearer dashboard-signed-jwt"
|
||||
natTestDashboardExpiredJWT = "Bearer dashboard-signed-expired-jwt"
|
||||
natTestDashboardAPIToken = "Bearer " + model.APITokenPrefix + "dashboard-issued-pat"
|
||||
natTestForeignJWT = "Bearer foreign-signed-jwt"
|
||||
natTestForeignRandom = "Bearer foreign-random-string"
|
||||
natTestDashboardJWTValue = "dashboard-signed-jwt"
|
||||
natTestDashboardExpiredJWTValue = "dashboard-signed-expired-jwt"
|
||||
natTestDashboardPATValue = model.APITokenPrefix + "dashboard-issued-pat"
|
||||
natTestForeignJWTValue = "foreign-signed-jwt"
|
||||
natTestForeignRandomValue = "foreign-random-string"
|
||||
|
||||
natTestDashboardJWT = "Bearer " + natTestDashboardJWTValue
|
||||
natTestDashboardExpiredJWT = "Bearer " + natTestDashboardExpiredJWTValue
|
||||
natTestDashboardAPIToken = "Bearer " + natTestDashboardPATValue
|
||||
natTestForeignJWT = "Bearer " + natTestForeignJWTValue
|
||||
natTestForeignRandom = "Bearer " + natTestForeignRandomValue
|
||||
)
|
||||
|
||||
// installNATCredentialTestGate wires a fake dashboard-credential classifier
|
||||
// installNATCredentialTestGate wires fake dashboard-credential classifiers
|
||||
// for the duration of the test: exactly the dashboard-issued test values above
|
||||
// are classified as dashboard credentials, everything else is foreign.
|
||||
// are classified as dashboard credentials on every channel, everything else
|
||||
// is foreign.
|
||||
func installNATCredentialTestGate(t *testing.T) {
|
||||
t.Helper()
|
||||
original := natDashboardCredentialGate
|
||||
natDashboardCredentialGate = func(authz string) bool {
|
||||
originalAuthorization := natDashboardAuthorizationGate
|
||||
originalValue := natDashboardCredentialValueGate
|
||||
natDashboardAuthorizationGate = func(authz string) bool {
|
||||
switch authz {
|
||||
case natTestDashboardJWT, natTestDashboardExpiredJWT, natTestDashboardAPIToken:
|
||||
return true
|
||||
@@ -33,5 +45,109 @@ func installNATCredentialTestGate(t *testing.T) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
t.Cleanup(func() { natDashboardCredentialGate = original })
|
||||
natDashboardCredentialValueGate = func(value string) bool {
|
||||
switch value {
|
||||
case natTestDashboardJWTValue, natTestDashboardExpiredJWTValue, natTestDashboardPATValue:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
natDashboardAuthorizationGate = originalAuthorization
|
||||
natDashboardCredentialValueGate = originalValue
|
||||
})
|
||||
}
|
||||
|
||||
func TestStripDashboardCredentialsQueryChannel(t *testing.T) {
|
||||
// Given
|
||||
installNATCredentialTestGate(t)
|
||||
request := &http.Request{URL: &url.URL{Path: "/nat", RawQuery: "keep=1&token=" + natTestDashboardJWTValue + "&token=" + natTestForeignJWTValue + "&a=%2Fx"}}
|
||||
|
||||
// When
|
||||
stripDashboardCredentials(request)
|
||||
|
||||
// Then
|
||||
if got := request.URL.RawQuery; got != "keep=1&token="+natTestForeignJWTValue+"&a=%2Fx" {
|
||||
t.Fatalf("query channel kept %q, want dashboard token dropped and foreign values byte-preserved", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripDashboardCredentialsQueryChannelEscapedValue(t *testing.T) {
|
||||
// Given — a dashboard token percent-encoded in the query must still be
|
||||
// recognized, while the escaped foreign element stays verbatim.
|
||||
installNATCredentialTestGate(t)
|
||||
request := &http.Request{URL: &url.URL{Path: "/nat", RawQuery: "token=" + url.QueryEscape(natTestDashboardJWTValue) + "&token=" + url.QueryEscape(natTestForeignJWTValue)}}
|
||||
|
||||
// When
|
||||
stripDashboardCredentials(request)
|
||||
|
||||
// Then
|
||||
if got := request.URL.RawQuery; got != "token="+url.QueryEscape(natTestForeignJWTValue) {
|
||||
t.Fatalf("query channel kept %q, want escaped dashboard token dropped and foreign element verbatim", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripDashboardCredentialsCookieChannel(t *testing.T) {
|
||||
// Given
|
||||
installNATCredentialTestGate(t)
|
||||
request := &http.Request{Header: make(http.Header)}
|
||||
request.Header.Set("Cookie", "sid=abc; nz-jwt="+natTestDashboardJWTValue+"; nz-jwt="+natTestForeignJWTValue+"; theme=dark")
|
||||
|
||||
// When
|
||||
stripDashboardCredentials(request)
|
||||
|
||||
// Then
|
||||
if got := request.Header.Get("Cookie"); got != "sid=abc; nz-jwt="+natTestForeignJWTValue+"; theme=dark" {
|
||||
t.Fatalf("cookie channel kept %q, want dashboard pair dropped and foreign pairs verbatim", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripDashboardCredentialsCookieChannelQuotedValue(t *testing.T) {
|
||||
// Given — quoting is presentation the panel's cookie parsing strips before
|
||||
// verifying, so a quoted dashboard token must be recognized.
|
||||
installNATCredentialTestGate(t)
|
||||
request := &http.Request{Header: make(http.Header)}
|
||||
request.Header.Set("Cookie", `nz-jwt="`+natTestDashboardJWTValue+`"; sid=abc`)
|
||||
|
||||
// When
|
||||
stripDashboardCredentials(request)
|
||||
|
||||
// Then
|
||||
if got := request.Header.Get("Cookie"); got != "sid=abc" {
|
||||
t.Fatalf("cookie channel kept %q, want quoted dashboard token dropped", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareNATCapabilityFailsClosedWithoutGate(t *testing.T) {
|
||||
// Given — unwired gates: every value on every channel is treated as
|
||||
// dashboard-issued and stripped, foreign ones included.
|
||||
SetNATDashboardCredentialGate(nil, nil)
|
||||
t.Cleanup(func() { SetNATDashboardCredentialGate(nil, nil) })
|
||||
request := &http.Request{
|
||||
Header: make(http.Header),
|
||||
URL: &url.URL{Path: "/nat", RawQuery: "keep=1&token=" + natTestForeignJWTValue},
|
||||
}
|
||||
request.Header.Set("Authorization", natTestForeignRandom)
|
||||
request.Header.Set("Cookie", "sid=abc; nz-jwt="+natTestForeignJWTValue)
|
||||
|
||||
// When
|
||||
lease, err := prepareNATCapability(request, &model.NAT{Common: model.Common{ID: 91}, ServerID: 81})
|
||||
|
||||
// Then
|
||||
if err != nil {
|
||||
t.Fatalf("fail-closed NAT capability hook returned error: %v", err)
|
||||
}
|
||||
if lease.active {
|
||||
t.Fatal("fail-closed NAT capability hook unexpectedly activated")
|
||||
}
|
||||
if got := request.Header.Get("Authorization"); got != "" {
|
||||
t.Fatalf("fail-closed hook retained Authorization %q", got)
|
||||
}
|
||||
if got := request.URL.RawQuery; got != "keep=1" {
|
||||
t.Fatalf("fail-closed hook kept query %q, want token parameter stripped", got)
|
||||
}
|
||||
if got := request.Header.Get("Cookie"); got != "sid=abc" {
|
||||
t.Fatalf("fail-closed hook kept cookie %q, want nz-jwt pair stripped", got)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user