Merge upstream monitoring features preserving domain extensions and require fixed Go toolchain

This commit is contained in:
Codex committed 2026-10-22 23:50:03 +08:00
commit 71dd21269b
92 files changed
+8557 -874

No files matched your search

+18
View File
@@ -172,6 +172,15 @@ func validateRule(c *gin.Context, r *model.AlertRule) error {
}
if len(r.Rules) > 0 {
for _, rule := range r.Rules {
if rule == nil {
return singleton.Localizer.ErrorT("rule is not set")
}
if !rule.IsSupportedType() {
return singleton.Localizer.ErrorT("unsupported rule type")
}
if !rule.HasSafeThresholds() {
return singleton.Localizer.ErrorT("invalid rule thresholds")
}
switch rule.Cover {
case model.RuleCoverAll, model.RuleCoverIgnoreAll:
default:
@@ -182,10 +191,19 @@ func validateRule(c *gin.Context, r *model.AlertRule) error {
if rule.Duration < 3 {
return singleton.Localizer.ErrorT("duration need to be at least 3")
}
if rule.Duration > model.MaxAlertRuleDuration {
return singleton.Localizer.ErrorT("duration is too large")
}
} else {
if !rule.HasSafeCycleUnit() {
return singleton.Localizer.ErrorT("invalid cycle unit")
}
if rule.CycleInterval < 1 {
return singleton.Localizer.ErrorT("cycle_interval need to be at least 1")
}
if rule.CycleInterval > model.MaxAlertRuleCycleInterval {
return singleton.Localizer.ErrorT("cycle_interval is too large")
}
if rule.CycleStart == nil {
return singleton.Localizer.ErrorT("cycle_start is not set")
}
@@ -0,0 +1,67 @@
package controller
import (
"math"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/nezhahq/nezha/model"
)
func TestValidateRuleRejectsCrashPayloadsFromMember(t *testing.T) {
ctx := newMemberValidationContext(t)
cycleStart := time.Now().Add(-time.Hour)
tests := []struct {
name string
rule *model.Rule
}{
{
name: "unknown rule type",
rule: &model.Rule{Type: "attacker_controlled", Duration: 3, Cover: model.RuleCoverAll},
},
{
name: "duration overflows int",
rule: &model.Rule{Type: "offline", Duration: math.MaxUint64, Cover: model.RuleCoverAll},
},
{
name: "cycle interval overflows int",
rule: &model.Rule{
Type: "transfer_in_cycle",
CycleStart: &cycleStart,
CycleInterval: math.MaxUint64,
Cover: model.RuleCoverAll,
},
},
{
name: "nil rule",
rule: nil,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
alert := &model.AlertRule{
Common: model.Common{UserID: 200},
Name: "security regression",
Rules: []*model.Rule{test.rule},
}
require.Error(t, validateRule(ctx, alert))
})
}
}
func TestValidateRuleAcceptsSafeDurationBoundary(t *testing.T) {
ctx := newMemberValidationContext(t)
alert := &model.AlertRule{
Common: model.Common{UserID: 200},
Name: "duration boundary",
Rules: []*model.Rule{{
Type: "offline",
Duration: model.MaxAlertRuleDuration,
Cover: model.RuleCoverAll,
}},
}
require.NoError(t, validateRule(ctx, alert))
}
+4
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 以避免浏览器误触。
@@ -101,6 +104,7 @@ func routers(r *gin.Engine, frontendDist fs.FS) {
optionalAuth.GET("/service/:id/history", restScopeMiddleware(model.ScopeServiceRead), commonHandler(getServiceHistory))
optionalAuth.GET("/server/:id/service", restScopeMiddleware(model.ScopeServiceRead), commonHandler(listServerServices))
optionalAuth.GET("/server/:id/metrics", restScopeMiddleware(model.ScopeServerRead), commonHandler(getServerMetrics))
optionalAuth.GET("/server/:id/online-history", restScopeMiddleware(model.ScopeServerRead), commonHandler(getServerOnlineHistory))
// CSRF middleware applies group-wide. Safe methods short-circuit and
// PAT bearer requests bypass — so the only callers gated are
@@ -109,12 +109,14 @@ func TestListDDNS_RedactsCredentials(t *testing.T) {
defer setupTenancyTest(t)()
p := model.DDNSProfile{
Common: model.Common{UserID: 10},
Name: "cf",
Provider: "cloudflare",
AccessID: "id",
AccessSecret: "super-secret-token",
WebhookHeaders: `{"Authorization":"Bearer xxx"}`,
Common: model.Common{UserID: 10},
Name: "cf",
Provider: "cloudflare",
AccessID: "id",
AccessSecret: "super-secret-token",
WebhookURL: "https://ddns.example/update?token=embedded-secret",
WebhookRequestBody: `{"token":"embedded-secret"}`,
WebhookHeaders: `{"Authorization":"Bearer xxx"}`,
}
require.NoError(t, singleton.DB.Create(&p).Error)
singleton.DDNSShared.InsertForTest(&p)
@@ -124,12 +126,18 @@ func TestListDDNS_RedactsCredentials(t *testing.T) {
require.NoError(t, err)
require.Len(t, out, 1)
require.Empty(t, out[0].AccessSecret, "access_secret must be redacted in list response")
require.Empty(t, out[0].WebhookURL, "webhook_url must be redacted in list response")
require.Empty(t, out[0].WebhookRequestBody, "webhook_request_body must be redacted in list response")
require.Empty(t, out[0].WebhookHeaders, "webhook_headers must be redacted in list response")
require.Equal(t, "id", out[0].AccessID, "non-secret fields must be preserved")
var stored model.DDNSProfile
require.NoError(t, singleton.DB.First(&stored, p.ID).Error)
require.Equal(t, "super-secret-token", stored.AccessSecret, "redaction must not mutate stored data")
require.Equal(t, "https://ddns.example/update?token=embedded-secret", stored.WebhookURL,
"redaction must not mutate stored webhook URL")
require.Equal(t, `{"token":"embedded-secret"}`, stored.WebhookRequestBody,
"redaction must not mutate stored webhook body")
}
func TestListNotification_RedactsCredentials(t *testing.T) {
@@ -164,14 +172,15 @@ func TestUpdateDDNS_EmptySecretPreservesStored(t *testing.T) {
defer setupTenancyTest(t)()
existing := model.DDNSProfile{
Common: model.Common{UserID: 10},
Name: "cf",
Provider: "webhook",
AccessID: "id",
AccessSecret: "keep-me",
WebhookURL: "http://127.0.0.1/",
WebhookMethod: 1,
WebhookHeaders: `{"X-Token":"keep-header"}`,
Common: model.Common{UserID: 10},
Name: "cf",
Provider: "webhook",
AccessID: "id",
AccessSecret: "keep-me",
WebhookURL: "http://127.0.0.1/",
WebhookRequestBody: `{"X-Token":"keep-body"}`,
WebhookMethod: 1,
WebhookHeaders: `{"X-Token":"keep-header"}`,
}
require.NoError(t, singleton.DB.Create(&existing).Error)
singleton.DDNSShared.InsertForTest(&existing)
@@ -181,10 +190,11 @@ func TestUpdateDDNS_EmptySecretPreservesStored(t *testing.T) {
"provider": "webhook",
"access_id": "id",
"access_secret": "",
"webhook_url": "http://127.0.0.1/",
"webhook_url": "",
"webhook_method": 1,
"webhook_request_type": 1,
"webhook_headers": "",
"webhook_request_body": "",
"max_retries": 3,
})
c.Params = gin.Params{{Key: "id", Value: itoa(existing.ID)}}
@@ -197,6 +207,10 @@ func TestUpdateDDNS_EmptySecretPreservesStored(t *testing.T) {
require.Equal(t, "keep-me", after.AccessSecret, "empty submitted secret must preserve stored value")
require.Equal(t, `{"X-Token":"keep-header"}`, after.WebhookHeaders,
"empty submitted headers must preserve stored value")
require.Equal(t, "http://127.0.0.1/", after.WebhookURL,
"empty submitted webhook URL must preserve stored value")
require.Equal(t, `{"X-Token":"keep-body"}`, after.WebhookRequestBody,
"empty submitted webhook body must preserve stored value")
}
func TestUpdateDDNS_NonEmptySecretOverwrites(t *testing.T) {
@@ -0,0 +1,162 @@
package controller
import (
"context"
"log"
"strings"
jwt "github.com/golang-jwt/jwt/v4"
"gorm.io/gorm"
"gorm.io/gorm/logger"
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 dashboard-credential classification; 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 {
classified := ClassifyDashboardCredentialValues(context.Background(), []string{value})
if len(classified) != 1 {
return true
}
return classified[0]
}
// ClassifyDashboardCredentialValues classifies a bounded request batch. JWTs
// are checked independently, while all PAT-shaped values are hashed and
// resolved with one silent IN query. This prevents attacker-controlled NAT
// query parameters or cookies from amplifying one HTTP request into thousands
// of SQLite queries and record-not-found log entries.
func ClassifyDashboardCredentialValues(ctx context.Context, values []string) []bool {
if ctx == nil {
ctx = context.Background()
}
results := make([]bool, len(values))
patIndexes := make(map[string][]int)
for i, value := range values {
plaintext := strings.TrimSpace(value)
if strings.HasPrefix(plaintext, model.APITokenPrefix) {
hash := model.HashAPIToken(plaintext)
patIndexes[hash] = append(patIndexes[hash], i)
continue
}
results[i] = isDashboardSignedJWT(plaintext)
}
if len(patIndexes) == 0 {
return results
}
if singleton.DB == nil {
logDashboardCredentialUnclassifiable("api token lookup unavailable")
for _, indexes := range patIndexes {
for _, i := range indexes {
results[i] = true
}
}
return results
}
hashes := make([]string, 0, len(patIndexes))
for hash := range patIndexes {
hashes = append(hashes, hash)
}
type tokenHashRow struct {
TokenHash string
}
var rows []tokenHashRow
err := singleton.DB.WithContext(ctx).
Session(&gorm.Session{Logger: logger.Discard}).
Model(&model.APIToken{}).
Select("token_hash").
Where("token_hash IN ?", hashes).
Find(&rows).Error
if err != nil {
logDashboardCredentialUnclassifiable("api token lookup failed")
for _, indexes := range patIndexes {
for _, i := range indexes {
results[i] = true
}
}
return results
}
for _, row := range rows {
for _, i := range patIndexes[row.TokenHash] {
results[i] = true
}
}
return results
}
// 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
// credential value itself is never included in the message.
func logDashboardCredentialUnclassifiable(reason string) {
log.Printf("NEZHA>> NAT ingress: credential 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(ClassifyDashboardCredentialValues)
t.Cleanup(func() { rpc.SetNATDashboardCredentialGate(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,226 @@
package controller
import (
"bytes"
"context"
"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 TestClassifyDashboardCredentialValuesBatchesPATLookup(t *testing.T) {
setupDashboardCredentialTest(t)
dashboardPAT := model.APITokenPrefix + "dashboard-batch-pat"
require.NoError(t, singleton.DB.Create(&model.APIToken{
UserID: 1,
Name: "nat-batch-classification",
TokenHash: model.HashAPIToken(dashboardPAT),
}).Error)
queryCount := 0
const callbackName = "test:count-dashboard-credential-batch-query"
require.NoError(t, singleton.DB.Callback().Query().Before("gorm:query").Register(callbackName, func(*gorm.DB) {
queryCount++
}))
t.Cleanup(func() { singleton.DB.Callback().Query().Remove(callbackName) })
values := []string{
dashboardPAT,
model.APITokenPrefix + "unknown-one",
dashboardPAT,
model.APITokenPrefix + "unknown-two",
}
classified := ClassifyDashboardCredentialValues(context.Background(), values)
require.Equal(t, []bool{true, false, true, false}, classified)
require.Equal(t, 1, queryCount, "all PAT hashes in one request must use one database query")
}
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)
}
+8 -2
View File
@@ -34,6 +34,8 @@ func listDDNS(c *gin.Context) ([]*model.DDNSProfile, error) {
// 不影响 singleton 内原始数据。
for _, p := range ddnsProfiles {
p.AccessSecret = ""
p.WebhookURL = ""
p.WebhookRequestBody = ""
p.WebhookHeaders = ""
}
@@ -144,10 +146,8 @@ func updateDDNS(c *gin.Context) (any, error) {
p.Provider = df.Provider
p.Domains = df.Domains
p.AccessID = df.AccessID
p.WebhookURL = df.WebhookURL
p.WebhookMethod = df.WebhookMethod
p.WebhookRequestType = df.WebhookRequestType
p.WebhookRequestBody = df.WebhookRequestBody
// 凭据在列表接口已脱敏,前端无法回填;空值视为"不修改",保留旧值避免误清空。
if df.AccessSecret != "" {
@@ -156,6 +156,12 @@ func updateDDNS(c *gin.Context) (any, error) {
if df.WebhookHeaders != "" {
p.WebhookHeaders = df.WebhookHeaders
}
if df.WebhookURL != "" {
p.WebhookURL = df.WebhookURL
}
if df.WebhookRequestBody != "" {
p.WebhookRequestBody = df.WebhookRequestBody
}
for n, domain := range p.Domains {
// IDN to ASCII
+14
View File
@@ -15,6 +15,19 @@ import (
"github.com/nezhahq/nezha/service/singleton"
)
// The official file-manager client uploads in 1 MiB WebSocket messages. Cap
// each complete message at that protocol boundary so gorilla/websocket cannot
// buffer an attacker-sized frame before the IO stream relay sees it.
const fileManagerWebSocketInputLimit int64 = 1024 * 1024
type websocketReadLimiter interface {
SetReadLimit(limit int64)
}
func limitFileManagerWebSocketInput(conn websocketReadLimiter) {
conn.SetReadLimit(fileManagerWebSocketInputLimit)
}
// Create FM session
// @Summary Create FM session
// @Description Create an "attached" FM. It is advised to only call this within a terminal session.
@@ -103,6 +116,7 @@ func fmStream(c *gin.Context) (any, error) {
if err != nil {
return nil, newWsError("%v", err)
}
limitFileManagerWebSocketInput(wsConn)
conn := websocketx.NewConn(wsConn)
pingTransport := newWebsocketPingTransport(conn, wsConn.Close)
stopPing := startWebsocketPingTicker(c.Request.Context(), time.Second*10, pingTransport)
+1 -1
View File
@@ -149,7 +149,7 @@ func identityHandler() func(c *gin.Context) any {
}
currentIP := c.GetString(model.CtxKeyRealIPStr)
if sess.IP != currentIP {
if !singleton.Conf.JWTIPChangeAllowed() && sess.IP != currentIP {
c.Set(model.CtxKeyIsIPMismatch, true)
return nil
}
@@ -193,6 +193,61 @@ func TestIdentityHandlerFlagsIPMismatch(t *testing.T) {
assert.True(t, verify.GetBool(model.CtxKeyIsIPMismatch))
}
func TestIdentityHandlerAllowsIPMismatchWhenConfigured(t *testing.T) {
cleanup := setupJWTSessionTest(t)
defer cleanup()
singleton.Conf.SetJWTIPChangeAllowed(true)
ctx := newCtxForUser(0, "1.2.3.4", "ua")
user := model.User{Common: model.Common{ID: 100}, TokenVersion: 7}
claims, err := issueJWTSession(ctx, &user, 1)
require.NoError(t, err)
verify := newCtxForUser(0, "9.9.9.9", "ua")
verify.Set("JWT_PAYLOAD", jwt.MapClaims{
jwtClaimUserID: claims[jwtClaimUserID],
jwtClaimKeyID: claims[jwtClaimKeyID],
})
identity := identityHandler()(verify)
require.NotNil(t, identity, "IP mismatch must be accepted when allow_jwt_ip_change is enabled")
assert.False(t, verify.GetBool(model.CtxKeyIsIPMismatch))
}
func TestIdentityHandlerConcurrentIPChangeSetting(t *testing.T) {
cleanup := setupJWTSessionTest(t)
defer cleanup()
ctx := newCtxForUser(0, "1.2.3.4", "ua")
user := model.User{Common: model.Common{ID: 100}, TokenVersion: 7}
claims, err := issueJWTSession(ctx, &user, 1)
require.NoError(t, err)
stop, done := make(chan struct{}), make(chan struct{})
go func() {
defer close(done)
for {
select {
case <-stop:
return
default:
singleton.Conf.SetJWTIPChangeAllowed(true)
singleton.Conf.SetJWTIPChangeAllowed(false)
}
}
}()
defer func() {
close(stop)
<-done
}()
for i := 0; i < 100; i++ {
verify := newCtxForUser(0, "1.2.3.4", "ua")
verify.Set("JWT_PAYLOAD", jwt.MapClaims(claims))
require.NotNil(t, identityHandler()(verify), "matching IP must pass during setting updates")
}
}
func TestIdentityHandlerRejectsUnknownKeyID(t *testing.T) {
cleanup := setupJWTSessionTest(t)
defer cleanup()
+6 -1
View File
@@ -39,7 +39,12 @@ func setupMCPTest(t *testing.T) (func(), uint64) {
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.User{}, &model.APIToken{}, &model.MCPAuditLog{}, &model.Server{}, &model.WAF{}))
singleton.DB = db
singleton.Conf = &singleton.ConfigClass{Config: &model.Config{JWTTimeout: 1}}
singleton.Conf = &singleton.ConfigClass{Config: &model.Config{
JWTTimeout: 1,
ConfigDashboard: model.ConfigDashboard{
DashboardHost: "example.com",
},
}}
singleton.Conf.SetMCPEnabled(true)
user := model.User{Common: model.Common{ID: 100}, Username: "alice", Role: model.RoleMember}
+38 -7
View File
@@ -13,6 +13,7 @@ import (
"io"
"math"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
@@ -91,6 +92,37 @@ var (
transferSecretVal string
)
var errMCPTransferHostNotConfigured = errors.New("MCP transfer URL host is not an operator-declared dashboard host")
// mcpTransferURLBase never reflects an untrusted request Host into a bearer
// transfer URL. DashboardHost is the canonical public authority; deployments
// that intentionally serve multiple names must declare them as reserved hosts.
func mcpTransferURLBase(c *gin.Context) (string, error) {
if c == nil || c.Request == nil || singleton.Conf == nil {
return "", errMCPTransferHostNotConfigured
}
requestHost := strings.TrimSpace(c.Request.Host)
host := strings.TrimSpace(singleton.Conf.DashboardHost)
if host == "" {
if !singleton.IsReservedDashboardHost(requestHost) {
return "", errMCPTransferHostNotConfigured
}
host = requestHost
}
parsed, err := url.Parse("//" + host)
if err != nil || parsed.Host == "" || parsed.Host != host || parsed.User != nil || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" {
return "", errMCPTransferHostNotConfigured
}
scheme := "https"
if c.Request.TLS == nil && c.Request.Header.Get("X-Forwarded-Proto") != "https" {
scheme = "http"
}
return scheme + "://" + host, nil
}
// transferHMACSecret 返回进程内随机生成的 HMAC key。
// 这是有意设计:transferEntries 本身也只活在内存 sync.Map 里,dashboard
// 重启等价于全部 token 失效;让 secret 也随进程随机,可以避免“secret 来自
@@ -315,6 +347,10 @@ func mintTransferTool(c *gin.Context, serverID uint64, path string, ttlSeconds i
if err := validateTransferPath(path); err != nil {
return nil, err
}
baseURL, err := mcpTransferURLBase(c)
if err != nil {
return nil, err
}
ttl := time.Duration(ttlSeconds) * time.Second
if ttl <= 0 {
ttl = transferTokenTTLDefault
@@ -348,14 +384,9 @@ func mintTransferTool(c *gin.Context, serverID uint64, path string, ttlSeconds i
if err != nil {
return nil, err
}
scheme := "https"
if c.Request.TLS == nil && c.Request.Header.Get("X-Forwarded-Proto") != "https" {
scheme = "http"
}
host := c.Request.Host
url := fmt.Sprintf("%s://%s/mcp/%s/%s", scheme, host, dir, t)
transferURL := fmt.Sprintf("%s/mcp/%s/%s", baseURL, dir, t)
return map[string]any{
"url": url,
"url": transferURL,
"method": map[transferDirection]string{transferDirDownload: "GET", transferDirUpload: "POST"}[dir],
"expires_at": entry.ExpiresAt,
}, nil
@@ -0,0 +1,80 @@
package controller
import (
"net/http"
"testing"
"github.com/stretchr/testify/require"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/service/singleton"
)
func TestMCPTransferURLPinsUntrustedRequestHostToDashboardHost(t *testing.T) {
cleanup, uid := setupMCPTest(t)
defer cleanup()
PurgeTransferEntries()
t.Cleanup(func() { PurgeTransferEntries() })
tok, _ := mkToken(t, uid, []string{model.ScopeServerRead}, nil)
c, _ := mcpCallCtx(t, tok, uid, nil)
c.Request.Host = "attacker.example"
c.Request.Header.Set("X-Forwarded-Proto", "https")
out, err := mintTransferTool(c, 7, "/srv/file", 60, transferDirDownload, transferEntry{})
require.NoError(t, err)
result := out.(map[string]any)
require.Contains(t, result["url"], "https://example.com/mcp/download/")
require.NotContains(t, result["url"], "attacker.example")
}
func TestMCPTransferURLRejectsUntrustedHostWithoutCanonicalHost(t *testing.T) {
cleanup, uid := setupMCPTest(t)
defer cleanup()
PurgeTransferEntries()
t.Cleanup(func() { PurgeTransferEntries() })
tok, _ := mkToken(t, uid, []string{model.ScopeServerRead}, nil)
singleton.Conf.DashboardHost = ""
singleton.Conf.InstallHost = ""
singleton.Conf.ListenHost = ""
singleton.Conf.ReservedHosts = ""
c, _ := mcpCallCtx(t, tok, uid, nil)
c.Request = c.Request.Clone(c.Request.Context())
c.Request.Method = http.MethodPost
c.Request.Host = "attacker.example"
_, err := mintTransferTool(c, 7, "/srv/file", 60, transferDirDownload, transferEntry{})
require.ErrorIs(t, err, errMCPTransferHostNotConfigured)
entries := 0
transferEntries.Range(func(_, _ any) bool {
entries++
return true
})
require.Zero(t, entries, "host validation must happen before a bearer transfer token is minted")
}
func TestMCPTransferURLAllowsOperatorReservedHost(t *testing.T) {
cleanup, _ := setupMCPTest(t)
defer cleanup()
singleton.Conf.DashboardHost = ""
singleton.Conf.ReservedHosts = "mcp.example.com"
c, _ := mcpCallCtx(t, nil, 0, nil)
c.Request.Host = "mcp.example.com"
c.Request.Header.Set("X-Forwarded-Proto", "https")
base, err := mcpTransferURLBase(c)
require.NoError(t, err)
require.Equal(t, "https://mcp.example.com", base)
}
func TestMCPTransferURLRejectsMalformedConfiguredHost(t *testing.T) {
cleanup, _ := setupMCPTest(t)
defer cleanup()
singleton.Conf.DashboardHost = "https://example.com/path"
c, _ := mcpCallCtx(t, nil, 0, nil)
_, err := mcpTransferURLBase(c)
require.ErrorIs(t, err, errMCPTransferHostNotConfigured)
}
+6
View File
@@ -1,6 +1,7 @@
package controller
import (
"net/http"
"slices"
"strconv"
@@ -12,6 +13,8 @@ import (
"github.com/nezhahq/nezha/service/singleton"
)
const notificationBatchDeleteMaxBodyBytes = 1 << 20
// List notification
// @Summary List notification
// @Security BearerAuth
@@ -176,6 +179,9 @@ func updateNotification(c *gin.Context) (any, error) {
// @Router /batch-delete/notification [post]
func batchDeleteNotification(c *gin.Context) (any, error) {
var n []uint64
if c.Request != nil && c.Request.Body != nil {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, notificationBatchDeleteMaxBodyBytes)
}
if err := c.ShouldBindJSON(&n); err != nil {
return nil, err
}
@@ -0,0 +1,25 @@
package controller
import (
"bytes"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
func TestBatchDeleteNotificationCapsRequestBody(t *testing.T) {
body := append(bytes.Repeat([]byte{' '}, notificationBatchDeleteMaxBodyBytes+1), '[', ']')
req := httptest.NewRequest(http.MethodPost, "/api/v1/batch-delete/notification", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = req
_, err := batchDeleteNotification(c)
if err == nil || !strings.Contains(err.Error(), "request body too large") {
t.Fatalf("oversized request body was not rejected by MaxBytesReader: %v", err)
}
}
+121
View File
@@ -0,0 +1,121 @@
package controller
import (
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/service/singleton"
)
// Get server online history
// @Summary Get server online history
// @Security BearerAuth
// @Schemes
// @Description Get online periods and aggregated uptime statistics of a specific server
// @Tags common
// @Param id path uint true "Server ID"
// @Param days query int false "Query window in days (default: configured online_stats_days, clamped to it)"
// @Produce json
// @Success 200 {object} model.CommonResponse[model.OnlineHistoryResponse]
// @Router /server/{id}/online-history [get]
func getServerOnlineHistory(c *gin.Context) (*model.OnlineHistoryResponse, error) {
idStr := c.Param("id")
serverID, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
return nil, err
}
server, ok := singleton.ServerShared.Get(serverID)
if !ok || server == nil {
return nil, singleton.Localizer.ErrorT("server not found")
}
if !userCanViewServer(c, server) {
return nil, singleton.Localizer.ErrorT("unauthorized")
}
_, isMember := c.Get(model.CtxKeyAuthorizedUser)
maxDays := singleton.Conf.OnlineHistoryRetentionDays()
if maxDays < model.MinOnlineStatsDays || maxDays > model.MaxOnlineStatsDays {
maxDays = model.DefaultOnlineStatsDays
}
days := maxDays
if daysStr := c.Query("days"); daysStr != "" {
d, err := strconv.Atoi(daysStr)
if err != nil || d < 1 {
return nil, singleton.Localizer.ErrorT("invalid days parameter")
}
// 查询窗口收敛到后台设置的统计范围内
if d > maxDays {
d = maxDays
}
days = d
}
if !isMember && days > 1 {
return nil, singleton.Localizer.ErrorT("unauthorized: only 1d data available for guests")
}
now := time.Now()
windowEnd := now
windowStart := model.OnlineHistoryWindowStart(now, days, singleton.Loc)
sessions, err := singleton.OnlineHistoryShared.Sessions(serverID, windowStart, windowEnd)
if err != nil {
return nil, newGormError("%v", err)
}
periods := model.ClipOnlineSessions(sessions, windowStart, windowEnd)
daily := model.DailyOnlineBreakdown(periods, windowStart, windowEnd, singleton.Loc)
// 总时长取各日之和,与 daily[] 严格对账(每日累计原始时长后统一取整,
// 口径见 DailyOnlineBreakdown)
var totalSeconds uint64
for _, d := range daily {
totalSeconds += d.OnlineSeconds
}
windowSeconds := uint64(windowEnd.Sub(windowStart).Seconds())
onlineRate := 0.0
if windowSeconds > 0 {
onlineRate = float64(totalSeconds) / float64(windowSeconds) * 100
}
runtime := server.RuntimeSnapshot()
currentlyOnline := !runtime.LastActive.IsZero() && now.Sub(runtime.LastActive) <= model.OnlineActiveWindow
var lastActive *time.Time
if !runtime.LastActive.IsZero() {
lastActiveUTC := runtime.LastActive.UTC()
lastActive = &lastActiveUTC
}
// 响应统一以 UTC 序列化,避免窗口时间与库中读回的会话时间混用
// 本地偏移与 Z 两种写法(时段为值拷贝,不影响调用方)
utcPeriods := make([]model.OnlinePeriod, len(periods))
for i, p := range periods {
utcPeriods[i] = model.OnlinePeriod{Start: p.Start.UTC(), End: p.End.UTC(), Ongoing: p.Ongoing}
}
return &model.OnlineHistoryResponse{
ServerID: serverID,
ServerName: server.Name,
Days: days,
MaxDays: maxDays,
Timezone: singleton.Loc.String(),
TzOffset: tzOffsetSeconds(now, singleton.Loc),
WindowStart: windowStart.UTC(),
WindowEnd: windowEnd.UTC(),
TotalOnlineSeconds: totalSeconds,
OnlineRate: onlineRate,
CurrentlyOnline: currentlyOnline,
LastActive: lastActive,
Periods: utcPeriods,
Daily: daily,
}, nil
}
// tzOffsetSeconds 返回指定时区在给定时刻相对 UTC 的偏移秒数。
func tzOffsetSeconds(at time.Time, loc *time.Location) int {
_, offset := at.In(loc).Zone()
return offset
}
@@ -0,0 +1,356 @@
package controller
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/pkg/i18n"
"github.com/nezhahq/nezha/service/singleton"
)
func setupOnlineHistoryControllerTest(t *testing.T) (visibleServer, hiddenServer *model.Server) {
t.Helper()
originalDB := singleton.DB
originalLocalizer := singleton.Localizer
originalServer := singleton.ServerShared
originalConf := singleton.Conf
originalLoc := singleton.Loc
if singleton.Localizer == nil {
singleton.Localizer = i18n.NewLocalizer("en_US", "nezha", "translations", i18n.Translations)
}
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
sqlDB, err := db.DB()
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.User{}, &model.Server{}, &model.OnlineSession{}))
singleton.DB = db
singleton.ServerShared = singleton.NewEmptyServerClassForTest()
singleton.Conf = &singleton.ConfigClass{Config: &model.Config{}}
singleton.Conf.OnlineStatsDays = 30
singleton.Loc = time.FixedZone("TEST", 8*3600)
visibleServer = &model.Server{
Common: model.Common{ID: 1},
Name: "visible",
UUID: "online-history-visible",
LastActive: time.Now(),
}
hiddenServer = &model.Server{
Common: model.Common{ID: 2},
Name: "hidden",
UUID: "online-history-hidden",
HideForGuest: true,
}
singleton.ServerShared.InsertForTest(visibleServer)
singleton.ServerShared.InsertForTest(hiddenServer)
t.Cleanup(func() {
_ = sqlDB.Close()
singleton.DB = originalDB
singleton.Localizer = originalLocalizer
singleton.ServerShared = originalServer
singleton.Conf = originalConf
singleton.Loc = originalLoc
})
return visibleServer, hiddenServer
}
func onlineHistoryCtx(t *testing.T, serverID string, query string) *gin.Context {
t.Helper()
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/server/"+serverID+"/online-history?"+query, nil)
c.Params = gin.Params{{Key: "id", Value: serverID}}
return c
}
func onlineHistoryCtxAsMember(t *testing.T, serverID string, query string) *gin.Context {
t.Helper()
c := onlineHistoryCtx(t, serverID, query)
c.Set(model.CtxKeyAuthorizedUser, &model.User{Common: model.Common{ID: 100}, Role: model.RoleMember})
return c
}
func seedOnlineSessions(t *testing.T, serverID uint64, now time.Time) []model.OnlineSession {
t.Helper()
now = now.Truncate(time.Second)
sessions := []model.OnlineSession{
{ServerID: serverID, StartedAt: now.Add(-48 * time.Hour), EndedAt: now.Add(-47 * time.Hour), LastActiveAt: now.Add(-47 * time.Hour)},
{ServerID: serverID, StartedAt: now.Add(-25 * time.Hour), EndedAt: now.Add(-23*time.Hour - 30*time.Minute), LastActiveAt: now.Add(-23 * time.Hour)},
{ServerID: serverID, StartedAt: now.Add(-10 * time.Minute), Ongoing: true, LastActiveAt: now},
// 超出默认 7 天窗口、但在 30 天窗口内的会话
{ServerID: serverID, StartedAt: now.Add(-20 * time.Hour * 24), EndedAt: now.Add(-20*time.Hour*24 + time.Hour), LastActiveAt: now.Add(-20 * time.Hour * 24)},
}
for i := range sessions {
require.NoError(t, singleton.DB.Create(&sessions[i]).Error)
}
return sessions
}
func TestGetServerOnlineHistoryDefaultsToConfiguredWindow(t *testing.T) {
visible, _ := setupOnlineHistoryControllerTest(t)
now := time.Now()
seedOnlineSessions(t, visible.ID, now)
// 不带 days 参数:使用后台设置的统计范围(30 天)
c := onlineHistoryCtxAsMember(t, "1", "")
resp, err := getServerOnlineHistory(c)
require.NoError(t, err)
require.Equal(t, 30, resp.Days)
require.Equal(t, 30, resp.MaxDays)
// 4 个会话全部落在 30 天窗口内
require.Len(t, resp.Periods, 4)
require.True(t, resp.CurrentlyOnline)
}
func TestGetServerOnlineHistoryDefaultIsSevenDays(t *testing.T) {
visible, _ := setupOnlineHistoryControllerTest(t)
now := time.Now()
seedOnlineSessions(t, visible.ID, now)
// 出厂默认统计范围为 7 天
singleton.Conf.OnlineStatsDays = model.DefaultOnlineStatsDays
c := onlineHistoryCtxAsMember(t, "1", "")
resp, err := getServerOnlineHistory(c)
require.NoError(t, err)
require.Equal(t, 7, resp.Days)
// 20 天前的会话不进入 7 天窗口
require.Len(t, resp.Periods, 3)
}
func TestGetServerOnlineHistoryAggregatesAndClampsWindow(t *testing.T) {
visible, _ := setupOnlineHistoryControllerTest(t)
now := time.Now()
seedOnlineSessions(t, visible.ID, now)
c := onlineHistoryCtxAsMember(t, "1", "days=9999")
resp, err := getServerOnlineHistory(c)
require.NoError(t, err)
// 请求超出后台设置窗口时收敛到设置值
require.Equal(t, 30, resp.Days)
// 聚合正确性:三个已结束/进行中会话都计入,进行中会话按当前时间截断
var total uint64
for _, p := range resp.Periods {
total += uint64(p.End.Sub(p.Start).Seconds())
}
require.Equal(t, resp.TotalOnlineSeconds, total)
require.GreaterOrEqual(t, resp.TotalOnlineSeconds, uint64(3600+5400+599))
// 每日拆分合计等于总时长
var dailySum uint64
for _, d := range resp.Daily {
dailySum += d.OnlineSeconds
}
require.Equal(t, resp.TotalOnlineSeconds, dailySum)
// 在线率按窗口秒数计算
windowSeconds := resp.WindowEnd.Sub(resp.WindowStart).Seconds()
require.InDelta(t, float64(resp.TotalOnlineSeconds)/windowSeconds*100, resp.OnlineRate, 0.001)
}
func TestGetServerOnlineHistoryInvalidDays(t *testing.T) {
setupOnlineHistoryControllerTest(t)
for _, q := range []string{"days=abc", "days=0", "days=-1"} {
c := onlineHistoryCtxAsMember(t, "1", q)
_, err := getServerOnlineHistory(c)
require.EqualError(t, err, singleton.Localizer.ErrorT("invalid days parameter").Error(), "query %q should be rejected", q)
}
}
func TestGetServerOnlineHistoryUnknownServer(t *testing.T) {
setupOnlineHistoryControllerTest(t)
c := onlineHistoryCtx(t, "424242", "")
_, err := getServerOnlineHistory(c)
require.Error(t, err)
}
func TestGetServerOnlineHistoryGuestLimitedToOneDay(t *testing.T) {
visible, _ := setupOnlineHistoryControllerTest(t)
now := time.Now()
seedOnlineSessions(t, visible.ID, now)
// 游客不带 days 参数时默认为配置窗口(>1 天),与 metrics 的游客语义一致被拒绝
_, err := getServerOnlineHistory(onlineHistoryCtx(t, "1", ""))
require.Error(t, err)
// 游客显式请求多天被拒绝,1 天可用
_, err = getServerOnlineHistory(onlineHistoryCtx(t, "1", "days=3"))
require.Error(t, err)
resp, err := getServerOnlineHistory(onlineHistoryCtx(t, "1", "days=1"))
require.NoError(t, err)
require.Equal(t, 1, resp.Days)
// 与 1 天窗口相交的两个会话:一个被裁剪到窗口起点,一个进行中
require.Len(t, resp.Periods, 2)
// 登录用户不受游客窗口限制
resp, err = getServerOnlineHistory(onlineHistoryCtxAsMember(t, "1", "days=30"))
require.NoError(t, err)
require.Equal(t, 30, resp.Days)
require.Len(t, resp.Periods, 4)
}
func TestGetServerOnlineHistoryPermission(t *testing.T) {
visible, _ := setupOnlineHistoryControllerTest(t)
// 游客可以查看未隐藏服务器(窗口受限于 1 天)
guest := onlineHistoryCtx(t, "1", "days=1")
resp, err := getServerOnlineHistory(guest)
require.NoError(t, err)
require.Equal(t, visible.ID, resp.ServerID)
// 游客不能查看 HideForGuest 服务器
guestHidden := onlineHistoryCtx(t, "2", "days=1")
_, err = getServerOnlineHistory(guestHidden)
require.EqualError(t, err, singleton.Localizer.ErrorT("unauthorized").Error())
// 成员不能查看他人隐藏服务器
member := onlineHistoryCtx(t, "2", "days=7")
member.Set(model.CtxKeyAuthorizedUser, &model.User{Common: model.Common{ID: 100}, Role: model.RoleMember})
_, err = getServerOnlineHistory(member)
require.Error(t, err)
}
func patchOnlineStatsDays(t *testing.T, days any) error {
t.Helper()
gin.SetMode(gin.TestMode)
body, err := json.Marshal(map[string]any{"user_template": "user-dist", "online_stats_days": days})
require.NoError(t, err)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPatch, "/api/v1/setting", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
_, err = updateConfig(c)
return err
}
func TestUpdateConfigOnlineStatsDaysRange(t *testing.T) {
originalLocalizer := singleton.Localizer
if singleton.Localizer == nil {
singleton.Localizer = i18n.NewLocalizer("en_US", "nezha", "translations", i18n.Translations)
}
t.Cleanup(func() { singleton.Localizer = originalLocalizer })
templates := []model.FrontendTemplate{{Path: "user-dist", IsAdmin: false}}
originalFrontendTemplates := singleton.FrontendTemplates
singleton.FrontendTemplates = templates
t.Cleanup(func() { singleton.FrontendTemplates = originalFrontendTemplates })
conf := &singleton.ConfigClass{Config: &model.Config{}}
configPath := filepath.Join(t.TempDir(), "config.yaml")
require.NoError(t, conf.Read(configPath, templates))
originalConf := singleton.Conf
singleton.Conf = conf
t.Cleanup(func() { singleton.Conf = originalConf })
require.Equal(t, model.DefaultOnlineStatsDays, conf.OnlineStatsDays)
// 合法边界值
for _, d := range []int{model.MinOnlineStatsDays, 30, model.MaxOnlineStatsDays} {
require.NoError(t, patchOnlineStatsDays(t, d), "days=%d should be accepted", d)
require.Equal(t, d, conf.OnlineStatsDays)
}
// 越界值整体拒绝且不修改现值
for _, d := range []int{6, model.MaxOnlineStatsDays + 1, 0, -5} {
require.Error(t, patchOnlineStatsDays(t, d), "days=%d should be rejected", d)
require.Equal(t, model.MaxOnlineStatsDays, conf.OnlineStatsDays)
}
// 非法值必须整体拒绝:校验先于任何对 Conf 的写入完成,其他字段不得
// 半提交(评审复现场景:错误返回后 SiteName 不能被请求内容覆盖)
conf.SiteName = "existing-site"
require.Error(t, patchOnlineStatsDays(t, 6))
require.Equal(t, "existing-site", conf.SiteName)
conf.SiteName = ""
// 字段缺省时保留现值(部分 PATCH 语义)
require.NoError(t, patchOnlineStatsDays(t, nil))
require.Equal(t, model.MaxOnlineStatsDays, conf.OnlineStatsDays)
// 重启持久化断言:updateConfig 显式 Save 后,重新从磁盘读取的配置
// 必须带上刚写入的值(持久化不再依赖 MCP 过渡块的副作用)
reloaded := &singleton.ConfigClass{Config: &model.Config{}}
require.NoError(t, reloaded.Read(configPath, templates))
require.Equal(t, model.MaxOnlineStatsDays, reloaded.OnlineStatsDays)
}
func TestSettingFormOnlineStatsDaysDecode(t *testing.T) {
var absent model.SettingForm
require.NoError(t, json.NewDecoder(bytes.NewReader([]byte(`{"site_name":"x"}`))).Decode(&absent))
require.Nil(t, absent.OnlineStatsDays, "omitted key must decode to nil")
var explicit model.SettingForm
require.NoError(t, json.NewDecoder(bytes.NewReader([]byte(`{"online_stats_days":3650}`))).Decode(&explicit))
require.NotNil(t, explicit.OnlineStatsDays)
require.Equal(t, 3650, *explicit.OnlineStatsDays)
}
func TestOnlineHistoryReadsPendingCloseBoundary(t *testing.T) {
setupOnlineHistoryControllerTest(t)
previous := singleton.OnlineHistoryShared
t.Cleanup(func() { singleton.OnlineHistoryShared = previous })
r := singleton.NewOnlineHistoryRecorder()
singleton.OnlineHistoryShared = r
start := time.Now().UTC().Add(-time.Hour).Truncate(time.Second)
r.RecordOnline(1, start)
require.NoError(t, singleton.DB.Exec(`CREATE TRIGGER reject_close BEFORE UPDATE ON online_sessions WHEN NEW.ongoing = 0 BEGIN SELECT RAISE(ABORT, 'close failure'); END`).Error)
r.RecordOffline(1, start.Add(time.Minute))
r.RecordOnline(1, start.Add(30*time.Minute))
resp, err := getServerOnlineHistory(onlineHistoryCtx(t, "1", "days=1"))
require.NoError(t, err)
require.Len(t, resp.Periods, 2)
require.False(t, resp.Periods[0].Ongoing)
require.True(t, resp.Periods[0].End.Equal(start.Add(time.Minute)))
require.Less(t, resp.TotalOnlineSeconds, uint64(1900))
}
func TestOnlineHistoryQueryWithNonUTCSession(t *testing.T) {
setupOnlineHistoryControllerTest(t)
now := time.Now()
offset := time.FixedZone("UTC+12", 12*3600)
session := model.OnlineSession{ServerID: 1, StartedAt: now.Add(-time.Hour).In(offset), LastActiveAt: now.Add(-time.Minute).In(offset), Ongoing: true}
require.NoError(t, singleton.DB.Create(&session).Error)
resp, err := getServerOnlineHistory(onlineHistoryCtx(t, "1", "days=1"))
require.NoError(t, err)
require.Len(t, resp.Periods, 1)
require.True(t, resp.Periods[0].Start.Equal(session.StartedAt))
require.InDelta(t, 3600, resp.TotalOnlineSeconds, 2)
}
func TestUpdateConfigFailedSavePreservesRetention(t *testing.T) {
server, _ := setupOnlineHistoryControllerTest(t)
require.NoError(t, singleton.DB.Create(server).Error)
templates := []model.FrontendTemplate{{Path: "user-dist"}}
previous := singleton.FrontendTemplates
singleton.FrontendTemplates = templates
t.Cleanup(func() { singleton.FrontendTemplates = previous })
path := filepath.Join(t.TempDir(), "config.yaml")
require.NoError(t, singleton.Conf.Read(path, templates))
singleton.Conf.OnlineStatsDays = 30
require.NoError(t, singleton.Conf.Save())
// Replacing the destination with a directory deterministically fails Save even as root.
require.NoError(t, os.Remove(path))
require.NoError(t, os.Mkdir(path, 0700))
now := time.Now().UTC()
old := model.OnlineSession{ServerID: 1, StartedAt: now.AddDate(0, 0, -15), EndedAt: now.AddDate(0, 0, -14)}
require.NoError(t, singleton.DB.Create(&old).Error)
require.Error(t, patchOnlineStatsDays(t, 7))
require.Equal(t, 30, singleton.Conf.OnlineStatsDays)
singleton.PruneOnlineSessions()
var retained model.OnlineSession
require.NoError(t, singleton.DB.First(&retained, old.ID).Error)
require.Equal(t, old.ID, retained.ID)
}
+12 -13
View File
@@ -236,10 +236,9 @@ func getServerConfig(c *gin.Context) (string, error) {
}
s, ok := singleton.ServerShared.Get(id)
if !ok {
return "", nil
}
if !s.HasPermission(c) {
// Foreign and unknown IDs deliberately share one response so sequential
// server IDs cannot be enumerated across tenants.
if !ok || !s.HasPermission(c) || !patAllowsServer(c, id) {
return "", singleton.Localizer.ErrorT("permission denied")
}
if s.GetTaskStream() == nil {
@@ -293,16 +292,16 @@ func setServerConfig(c *gin.Context) (*model.ServerTaskResponse, error) {
slist := singleton.ServerShared.GetList()
servers := make([]*model.Server, 0, len(configForm.Servers))
for _, sid := range configForm.Servers {
if s, ok := slist[sid]; ok {
if !s.HasPermission(c) {
return nil, singleton.Localizer.ErrorT("permission denied")
}
if s.GetTaskStream() == nil {
resp.Offline = append(resp.Offline, s.ID)
continue
}
servers = append(servers, s)
s, ok := slist[sid]
// Match getServerConfig: do not reveal whether a denied ID exists.
if !ok || !s.HasPermission(c) || !patAllowsServer(c, sid) {
return nil, singleton.Localizer.ErrorT("permission denied")
}
if s.GetTaskStream() == nil {
resp.Offline = append(resp.Offline, s.ID)
continue
}
servers = append(servers, s)
}
var wg sync.WaitGroup
@@ -0,0 +1,54 @@
package controller
import (
"net/http"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/service/singleton"
)
func TestGetServerConfigForeignAndUnknownIDsAreIndistinguishable(t *testing.T) {
cleanup, _ := setupMCPTest(t)
defer cleanup()
require.NoError(t, singleton.DB.Create(&model.User{Common: model.Common{ID: 200}, Username: "bob", Role: model.RoleMember}).Error)
tok, _ := mkToken(t, 200, []string{model.ScopeServerRead}, nil)
probe := func(id string) error {
c, _ := patRequestCtx(t, tok, 200, http.MethodGet, "/api/v1/server/config/"+id, nil)
c.Params = gin.Params{{Key: "id", Value: id}}
_, err := getServerConfig(c)
return err
}
foreignErr := probe("7")
unknownErr := probe("999999")
require.Error(t, foreignErr)
require.Error(t, unknownErr)
require.Equal(t, foreignErr.Error(), unknownErr.Error())
}
func TestSetServerConfigForeignAndUnknownIDsAreIndistinguishable(t *testing.T) {
cleanup, _ := setupMCPTest(t)
defer cleanup()
require.NoError(t, singleton.DB.Create(&model.User{Common: model.Common{ID: 200}, Username: "bob", Role: model.RoleMember}).Error)
tok, _ := mkToken(t, 200, []string{model.ScopeServerWrite}, nil)
probe := func(id uint64) error {
c, _ := patRequestCtx(t, tok, 200, http.MethodPost, "/api/v1/server/config", model.ServerConfigForm{
Servers: []uint64{id},
Config: "{}",
})
_, err := setServerConfig(c)
return err
}
foreignErr := probe(7)
unknownErr := probe(999999)
require.Error(t, foreignErr)
require.Error(t, unknownErr)
require.Equal(t, foreignErr.Error(), unknownErr.Error())
}
+30 -10
View File
@@ -2,6 +2,7 @@ package controller
import (
"errors"
"fmt"
"log"
"strings"
@@ -29,7 +30,7 @@ func listConfig(c *gin.Context) (*model.SettingResponse, error) {
isAdmin = user.Role.IsAdmin()
}
config := *singleton.Conf
config := singleton.Conf.SettingsSnapshot()
config.Language = strings.ReplaceAll(config.Language, "_", "-")
conf := model.SettingResponse{
@@ -92,11 +93,20 @@ func updateConfig(c *gin.Context) (any, error) {
if !userTemplateValid {
return nil, errors.New("invalid user template")
}
// 全部输入校验必须先于任何对 singleton.Conf 的写入:任一字段非法都应
// 整体拒绝,避免"请求以错误收场、先处理的字段却已生效"的半提交状态
if sf.OnlineStatsDays != nil &&
(*sf.OnlineStatsDays < model.MinOnlineStatsDays || *sf.OnlineStatsDays > model.MaxOnlineStatsDays) {
return nil, fmt.Errorf("online_stats_days must be between %d and %d", model.MinOnlineStatsDays, model.MaxOnlineStatsDays)
}
singleton.Conf.Language = strings.ReplaceAll(sf.Language, "-", "_")
singleton.Conf.EnableIPChangeNotification = sf.EnableIPChangeNotification
singleton.Conf.EnablePlainIPInNotification = sf.EnablePlainIPInNotification
if sf.AllowJWTIPChange != nil {
singleton.Conf.SetJWTIPChangeAllowed(*sf.AllowJWTIPChange)
}
singleton.Conf.Cover = sf.Cover
singleton.Conf.InstallHost = sf.InstallHost
singleton.Conf.DashboardHost = sf.DashboardHost
@@ -124,18 +134,22 @@ func updateConfig(c *gin.Context) (any, error) {
singleton.Conf.DomainExpiryNotificationDays = sf.DomainExpiryNotificationDays
singleton.Conf.ServerExpiryNotificationDays = sf.ServerExpiryNotificationDays
mcpWasEnabled := singleton.Conf.MCPEnabled()
mcpNext := resolveSettingEnableMCP(sf.EnableMCP, mcpWasEnabled)
if err := applyEnableMCPTransition(
mcpWasEnabled, mcpNext,
singleton.Conf.SetMCPEnabled,
singleton.Conf.Save,
fireMCPKillSwitch,
); err != nil {
if err := singleton.Conf.CommitOnlineHistoryRetention(sf.OnlineStatsDays, func() error {
mcpWasEnabled := singleton.Conf.MCPEnabled()
mcpNext := resolveSettingEnableMCP(sf.EnableMCP, mcpWasEnabled)
return applyEnableMCPTransition(
mcpWasEnabled, mcpNext,
singleton.Conf.SetMCPEnabled,
singleton.Conf.Save,
fireMCPKillSwitch,
)
}); err != nil {
return nil, newGormError("%v", err)
}
// applyEnableMCPTransition already persisted every setting, even when MCP
// is unchanged. A second save could fail after irreversible cleanup.
singleton.OnUpdateLang(singleton.Conf.Language)
singleton.InitTelegramBot()
return nil, nil
@@ -174,6 +188,12 @@ func fireMCPKillSwitch() {
// the current config to avoid accidentally tripping the kill switch on
// partial PATCH calls that omit enable_mcp.
func resolveSettingEnableMCP(formValue *bool, current bool) bool {
return resolveOptionalBool(formValue, current)
}
// resolveOptionalBool preserves a setting for partial PATCH requests when its
// boolean field is absent, while still allowing an explicit false value.
func resolveOptionalBool(formValue *bool, current bool) bool {
if formValue == nil {
return current
}
@@ -0,0 +1,26 @@
package controller
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
"github.com/nezhahq/nezha/model"
)
func TestSettingForm_AllowJWTIPChangePreservesAbsentAndDecodesExplicitValues(t *testing.T) {
var omitted model.SettingForm
require.NoError(t, json.Unmarshal([]byte(`{"site_name":"X"}`), &omitted))
require.Nil(t, omitted.AllowJWTIPChange)
var enabled model.SettingForm
require.NoError(t, json.Unmarshal([]byte(`{"allow_jwt_ip_change":true}`), &enabled))
require.NotNil(t, enabled.AllowJWTIPChange)
require.True(t, *enabled.AllowJWTIPChange)
var disabled model.SettingForm
require.NoError(t, json.Unmarshal([]byte(`{"allow_jwt_ip_change":false}`), &disabled))
require.NotNil(t, disabled.AllowJWTIPChange)
require.False(t, *disabled.AllowJWTIPChange)
}
@@ -0,0 +1,49 @@
package controller
import (
"go/ast"
"go/parser"
"go/token"
"testing"
"github.com/stretchr/testify/require"
)
// The transition helper owns Save (including unchanged MCP settings). A direct
// second Save after it can fail after persistence and irreversible revocation.
func TestUpdateConfigHasSinglePersistenceOwner(t *testing.T) {
file, err := parser.ParseFile(token.NewFileSet(), "setting.go", nil, 0)
require.NoError(t, err)
transitions, directSaves := 0, 0
found := false
for _, decl := range file.Decls {
fn, ok := decl.(*ast.FuncDecl)
if !ok || fn.Name.Name != "updateConfig" {
continue
}
found = true
ast.Inspect(fn.Body, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
if ident, ok := call.Fun.(*ast.Ident); ok && ident.Name == "applyEnableMCPTransition" {
transitions++
}
if selector, ok := call.Fun.(*ast.SelectorExpr); ok && selector.Sel.Name == "Save" {
directSaves++
}
return true
})
}
require.True(t, found)
require.Equal(t, 1, transitions)
require.Zero(t, directSaves)
}
func TestUnchangedMCPTransitionStillSavesOnce(t *testing.T) {
saves := 0
err := applyEnableMCPTransition(true, true, func(bool) {}, func() error { saves++; return nil }, func() { t.Error("unchanged MCP must not revoke streams") })
require.NoError(t, err)
require.Equal(t, 1, saves)
}
@@ -0,0 +1,28 @@
package controller
import "testing"
type readLimitRecorder struct {
limit int64
}
func (r *readLimitRecorder) SetReadLimit(limit int64) {
r.limit = limit
}
func TestTerminalWebSocketInputLimitAllowsBoundedPaste(t *testing.T) {
if terminalWebSocketInputLimit != 512*1024+64 {
t.Fatalf("terminal WebSocket input limit = %d, want 512 KiB plus control-byte allowance", terminalWebSocketInputLimit)
}
}
func TestFileManagerWebSocketInputLimitMatchesUploadChunk(t *testing.T) {
if fileManagerWebSocketInputLimit != 1024*1024 {
t.Fatalf("file-manager WebSocket input limit = %d, want official client's 1 MiB upload chunk", fileManagerWebSocketInputLimit)
}
recorder := new(readLimitRecorder)
limitFileManagerWebSocketInput(recorder)
if recorder.limit != fileManagerWebSocketInputLimit {
t.Fatalf("file-manager WebSocket configured limit = %d, want %d", recorder.limit, fileManagerWebSocketInputLimit)
}
}
+6 -3
View File
@@ -171,6 +171,12 @@ const transferStreamPingInterval = 30 * time.Second
// @Success 200 {object} model.ServerTransfer
// @Router /ws/transfer [get]
func transferStream(c *gin.Context) (any, error) {
subID, ch, err := singleton.ServerTransferShared.Subscribe(getUid(c))
if err != nil {
return nil, err
}
defer singleton.ServerTransferShared.Unsubscribe(subID)
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
return nil, newWsError("%v", err)
@@ -180,9 +186,6 @@ func transferStream(c *gin.Context) (any, error) {
deregisterPAT := registerPATConnection(c, func() { _ = conn.Close() })
defer deregisterPAT()
subID, ch := singleton.ServerTransferShared.Subscribe()
defer singleton.ServerTransferShared.Unsubscribe(subID)
// Pings keep the socket warm even when the broker is quiet. Without this
// a long idle period followed by a transfer event would race against
// upstream proxy idle-timeouts that may have already closed the conn.
@@ -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 batched dashboard-credential classifier
// (controller.ClassifyDashboardCredentialValues)
// 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)
}
+14
View File
@@ -0,0 +1,14 @@
package main
import (
"net/http"
"time"
)
const dashboardReadHeaderTimeout = 5 * time.Second
func newDashboardHTTPServer(handler http.Handler) *http.Server {
// ReadHeaderTimeout protects incomplete HTTP/1 requests and is cleared after
// headers. Unlike ReadTimeout it does not impose a lifetime on HTTP/2 streams.
return &http.Server{Handler: handler, ReadHeaderTimeout: dashboardReadHeaderTimeout}
}
+107
View File
@@ -0,0 +1,107 @@
package main
import (
"crypto/tls"
"io"
"net"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestDashboardRejectsIncompleteHTTPHeaders(t *testing.T) {
for _, useTLS := range []bool{false, true} {
name := "http"
if useTLS {
name = "https"
}
t.Run(name, func(t *testing.T) {
server := httptest.NewUnstartedServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Error("incomplete headers reached handler") }))
server.Config = newDashboardHTTPServer(server.Config.Handler)
require.Positive(t, server.Config.ReadHeaderTimeout)
server.Config.ReadHeaderTimeout = 50 * time.Millisecond
if useTLS {
server.StartTLS()
} else {
server.Start()
}
defer server.Close()
var conn net.Conn
var err error
if useTLS {
conn, err = tls.Dial("tcp", server.Listener.Addr().String(), &tls.Config{InsecureSkipVerify: true})
} else {
conn, err = net.Dial("tcp", server.Listener.Addr().String())
}
require.NoError(t, err)
defer conn.Close()
require.NoError(t, conn.SetDeadline(time.Now().Add(2*time.Second)))
_, err = io.WriteString(conn, "GET / HTTP/1.1\r\nHost: dashboard\r\nX-Incomplete: ")
require.NoError(t, err)
_, err = io.ReadAll(conn)
if networkErr, ok := err.(net.Error); ok {
require.False(t, networkErr.Timeout(), "server did not close incomplete request")
}
})
}
}
func TestDashboardHeaderTimeoutDoesNotCancelHTTP2Stream(t *testing.T) {
for _, useTLS := range []bool{false, true} {
name := "h2c"
if useTLS {
name = "h2"
}
t.Run(name, func(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.ProtoMajor != 2 {
t.Errorf("expected HTTP/2, got %s", r.Proto)
}
require.NoError(t, http.NewResponseController(w).Flush())
body, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf("long-lived request body: %v", err)
return
}
_, _ = w.Write(body)
})
server := httptest.NewUnstartedServer(handler)
server.Config = newDashboardHTTPServer(handler)
server.Config.ReadHeaderTimeout = 250 * time.Millisecond
server.Config.Protocols = new(http.Protocols)
server.Config.Protocols.SetHTTP1(true)
server.Config.Protocols.SetHTTP2(true)
server.Config.Protocols.SetUnencryptedHTTP2(true)
server.EnableHTTP2 = true
if useTLS {
server.StartTLS()
} else {
server.Start()
}
defer server.Close()
transport := &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, Protocols: new(http.Protocols)}
transport.Protocols.SetHTTP2(true)
transport.Protocols.SetUnencryptedHTTP2(true)
defer transport.CloseIdleConnections()
client := &http.Client{Transport: transport, Timeout: 10 * time.Second}
reader, writer := io.Pipe()
defer reader.Close()
defer writer.Close()
request, err := http.NewRequest(http.MethodPost, server.URL, reader)
require.NoError(t, err)
response, err := client.Do(request)
require.NoError(t, err)
defer response.Body.Close()
time.Sleep(4 * server.Config.ReadHeaderTimeout)
_, err = writer.Write([]byte("still streaming"))
require.NoError(t, err)
require.NoError(t, writer.Close())
body, err := io.ReadAll(response.Body)
require.NoError(t, err)
require.Equal(t, "still streaming", string(body))
})
}
}
+26 -6
View File
@@ -12,7 +12,6 @@ import (
"os"
"runtime/debug"
"strings"
"time"
_ "time/tzdata"
"github.com/gin-gonic/gin"
@@ -86,6 +85,16 @@ func initSystem(bus chan<- *model.Service) error {
return err
}
// 在线会话兜底清扫:状态流异常断开(失联)时由这里关闭进行中的会话
if _, err := singleton.CronShared.AddFunc("@every 30s", singleton.SweepOnlineSessions); err != nil {
return err
}
// 在线会话保留清理:超出后台设置的统计窗口后删除
if _, err := singleton.CronShared.AddFunc("0 30 4 * * *", singleton.PruneOnlineSessions); err != nil {
return err
}
if err := singleton.StartJWTSessionGC(); err != nil {
return err
}
@@ -178,12 +187,11 @@ func main() {
grpcHandler := rpc.ServeRPC()
httpHandler := controller.ServeWeb(frontendDist)
controller.InitUpgrader()
wireNATDashboardCredentialGate()
muxHandler := newHTTPandGRPCMux(httpHandler, grpcHandler)
muxServerHTTP := &http.Server{
Handler: muxHandler,
ReadHeaderTimeout: time.Second * 5,
}
muxServerHTTP := newDashboardHTTPServer(muxHandler)
muxServerHTTP.Protocols = new(http.Protocols)
muxServerHTTP.Protocols.SetHTTP1(true)
muxServerHTTP.Protocols.SetUnencryptedHTTP2(true)
@@ -191,9 +199,9 @@ func main() {
var muxServerHTTPS *http.Server
if singleton.Conf.HTTPS.ListenPort != 0 {
muxServerHTTPS = &http.Server{
ReadHeaderTimeout: dashboardReadHeaderTimeout,
Addr: fmt.Sprintf("%s:%d", singleton.Conf.ListenHost, singleton.Conf.HTTPS.ListenPort),
Handler: muxHandler,
ReadHeaderTimeout: time.Second * 5,
TLSConfig: &tls.Config{
InsecureSkipVerify: singleton.Conf.HTTPS.InsecureTLS,
},
@@ -237,6 +245,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
// classifier reuses, so the gate is wired right after it and before any
// listener starts serving.
func wireNATDashboardCredentialGate() {
rpc.SetNATDashboardCredentialGate(controller.ClassifyDashboardCredentialValues)
}
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 {
+308
View File
@@ -0,0 +1,308 @@
package rpc
import (
"context"
"net/http"
"net/url"
"strings"
"sync"
)
// 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"
// Keep credential classification work constant even when an unauthenticated
// NAT request contains thousands of token parameters or cookies. Values
// beyond this per-request budget fail closed and are stripped.
maxNATDashboardCredentialValues = 16
)
var (
natDashboardCredentialGateMu sync.RWMutex
// natDashboardCredentialValueGate classifies a bounded batch of raw
// credential values. Batching lets the controller resolve all PAT hashes in
// one query instead of turning attacker-controlled query/cookie fan-out into
// one database query per value.
natDashboardCredentialValueGate func(ctx context.Context, values []string) []bool
)
// SetNATDashboardCredentialGate wires the dashboard-credential classifier
// used by the NAT ingress. main() injects
// controller.ClassifyDashboardCredentialValues;
// 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(valueClass func(ctx context.Context, values []string) []bool) {
natDashboardCredentialGateMu.Lock()
natDashboardCredentialValueGate = valueClass
natDashboardCredentialGateMu.Unlock()
}
// NATDashboardCredentialGateWired reports whether the dashboard-credential
// classifier has been wired. It exists so the main() wiring contract can be
// pinned by a test.
func NATDashboardCredentialGateWired() bool {
natDashboardCredentialGateMu.RLock()
wired := natDashboardCredentialValueGate != nil
natDashboardCredentialGateMu.RUnlock()
return wired
}
// 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) {
classification := classifyNATDashboardCredentialValues(request)
stripDashboardAuthorizationHeader(request, classification)
stripDashboardCredentialQuery(request, classification)
stripDashboardCredentialCookie(request, classification)
}
type natDashboardCredentialClassification struct {
wired bool
limited bool
issued map[string]bool
}
func (c natDashboardCredentialClassification) isDashboardCredential(value string) bool {
if !c.wired {
return true
}
if issued, ok := c.issued[value]; ok {
return issued
}
// Only candidate values collected from this request reach this method. A
// missing result therefore means the distinct-value budget was exceeded;
// fail closed so a dashboard credential cannot be hidden after junk values.
return c.limited
}
func classifyNATDashboardCredentialValues(request *http.Request) natDashboardCredentialClassification {
natDashboardCredentialGateMu.RLock()
gate := natDashboardCredentialValueGate
natDashboardCredentialGateMu.RUnlock()
classification := natDashboardCredentialClassification{
wired: gate != nil,
issued: make(map[string]bool),
}
if gate == nil {
return classification
}
values, limited := collectNATDashboardCredentialValues(request)
classification.limited = limited
results := gate(request.Context(), values)
if len(results) != len(values) {
// A broken classifier must not turn into credential forwarding.
for _, value := range values {
classification.issued[value] = true
}
classification.limited = true
return classification
}
for i, value := range values {
classification.issued[value] = results[i]
}
return classification
}
func collectNATDashboardCredentialValues(request *http.Request) ([]string, bool) {
values := make([]string, 0, maxNATDashboardCredentialValues)
seen := make(map[string]struct{}, maxNATDashboardCredentialValues)
limited := false
add := func(value string) {
if _, ok := seen[value]; ok {
return
}
if len(values) == maxNATDashboardCredentialValues {
limited = true
return
}
seen[value] = struct{}{}
values = append(values, value)
}
for _, authorization := range request.Header.Values("Authorization") {
raw := strings.TrimSpace(authorization)
if strings.HasPrefix(raw, "Bearer ") {
add(strings.TrimPrefix(raw, "Bearer "))
}
}
if request.URL != nil {
for _, element := range strings.Split(request.URL.RawQuery, "&") {
if value, ok := dashboardCredentialQueryElementValue(element); ok {
add(value)
}
}
}
for _, line := range request.Header.Values("Cookie") {
for _, pair := range strings.Split(line, ";") {
if value, ok := dashboardCredentialCookiePairValue(pair); ok {
add(value)
}
}
}
return values, limited
}
// stripDashboardAuthorizationHeader removes the Authorization header when it
// carries a dashboard-issued credential.
func stripDashboardAuthorizationHeader(request *http.Request, classification natDashboardCredentialClassification) {
if !classification.wired {
// 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") {
raw := strings.TrimSpace(value)
if strings.HasPrefix(raw, "Bearer ") && classification.isDashboardCredential(strings.TrimPrefix(raw, "Bearer ")) {
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, classification natDashboardCredentialClassification) {
if request.URL == nil || request.URL.RawQuery == "" {
return
}
elements := strings.Split(request.URL.RawQuery, "&")
kept := elements[:0]
changed := false
for _, element := range elements {
if !classification.wired && dashboardCredentialQueryElementName(element) {
changed = true
continue
}
value, candidate := dashboardCredentialQueryElementValue(element)
if candidate && classification.isDashboardCredential(value) {
changed = true
continue
}
kept = append(kept, element)
}
if changed {
request.URL.RawQuery = strings.Join(kept, "&")
}
}
// dashboardCredentialQueryElementValue extracts the decoded value from a raw
// query element carrying the panel's token parameter.
func dashboardCredentialQueryElementValue(element string) (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
}
// 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 decoded, true
}
func dashboardCredentialQueryElementName(element string) bool {
rawName, _, _ := strings.Cut(element, "=")
name, err := url.QueryUnescape(rawName)
return err == nil && name == natDashboardJWTQueryParameter
}
// 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, classification natDashboardCredentialClassification) {
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, classification)
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, classification natDashboardCredentialClassification) (string, bool) {
pairs := strings.Split(line, ";")
kept := pairs[:0]
droppedBeforeFirstKept := true
changed := false
for _, pair := range pairs {
if !classification.wired && dashboardCredentialCookiePairName(pair) {
changed = true
continue
}
value, candidate := dashboardCredentialCookiePairValue(pair)
if candidate && classification.isDashboardCredential(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
}
// dashboardCredentialCookiePairValue extracts a cookie value the same way the
// panel hands it to the JWT verifier: surrounding whitespace/quotes are
// presentation, then Gin applies QueryUnescape.
func dashboardCredentialCookiePairValue(pair string) (string, bool) {
name, rawValue, found := strings.Cut(pair, "=")
if !found || strings.TrimSpace(name) != natDashboardJWTCookieName {
return "", false
}
value := strings.Trim(strings.TrimSpace(rawValue), `"`)
decoded, err := url.QueryUnescape(value)
if err != nil {
return "", false
}
return decoded, true
}
func dashboardCredentialCookiePairName(pair string) bool {
name, _, found := strings.Cut(pair, "=")
return found && strings.TrimSpace(name) == natDashboardJWTCookieName
}
+202
View File
@@ -0,0 +1,202 @@
package rpc
import (
"context"
"fmt"
"net/http"
"net/url"
"strings"
"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()
natDashboardCredentialGateMu.RLock()
original := natDashboardCredentialValueGate
natDashboardCredentialGateMu.RUnlock()
SetNATDashboardCredentialGate(func(_ context.Context, values []string) []bool {
results := make([]bool, len(values))
for i, value := range values {
switch value {
case natTestDashboardJWTValue, natTestDashboardExpiredJWTValue, natTestDashboardPATValue:
results[i] = true
}
}
return results
})
t.Cleanup(func() {
SetNATDashboardCredentialGate(original)
})
}
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 TestStripDashboardCredentialsCookieChannelEscapedValue(t *testing.T) {
// Gin's cookie TokenLookup URL-decodes the value before JWT validation.
// NAT must classify the same decoded value or an encoded dashboard JWT can
// be accepted by the panel and then leak unchanged to the backend.
installNATCredentialTestGate(t)
request := &http.Request{Header: make(http.Header)}
escaped := strings.ReplaceAll(natTestDashboardJWTValue, "-", "%2D")
request.Header.Set("Cookie", "nz-jwt="+escaped+"; sid=abc")
stripDashboardCredentials(request)
if got := request.Header.Get("Cookie"); got != "sid=abc" {
t.Fatalf("cookie channel kept %q, want escaped dashboard token dropped", got)
}
}
func TestStripDashboardCredentialsDeduplicatesAndBoundsClassification(t *testing.T) {
natDashboardCredentialGateMu.RLock()
original := natDashboardCredentialValueGate
natDashboardCredentialGateMu.RUnlock()
t.Cleanup(func() { SetNATDashboardCredentialGate(original) })
classifiedValues := 0
gateCalls := 0
SetNATDashboardCredentialGate(func(_ context.Context, values []string) []bool {
gateCalls++
classifiedValues += len(values)
return make([]bool, len(values))
})
query := make(url.Values)
for range 100 {
query.Add("token", model.APITokenPrefix+"same-value")
}
for i := range 100 {
query.Add("token", fmt.Sprintf("%sunique-%d", model.APITokenPrefix, i))
}
request := &http.Request{Header: make(http.Header), URL: &url.URL{RawQuery: query.Encode()}}
stripDashboardCredentials(request)
if gateCalls != 1 {
t.Fatalf("classifier called %d times, want one batched call", gateCalls)
}
if classifiedValues > maxNATDashboardCredentialValues {
t.Fatalf("classified %d credential values, want at most %d per request", classifiedValues, maxNATDashboardCredentialValues)
}
if strings.Contains(request.URL.RawQuery, model.APITokenPrefix+"unique-99") {
t.Fatal("credential beyond the classification budget survived; overflow must fail closed")
}
}
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)
t.Cleanup(func() { SetNATDashboardCredentialGate(nil) })
request := &http.Request{
Header: make(http.Header),
URL: &url.URL{Path: "/nat", RawQuery: "keep=1&token=" + natTestForeignJWTValue + "&token=%ZZ"},
}
request.Header.Set("Authorization", natTestForeignRandom)
request.Header.Set("Cookie", "sid=abc; nz-jwt="+natTestForeignJWTValue+"; nz-jwt=%ZZ")
// 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)
}
}