fix(nat): scrub dashboard-issued credentials from all accepted token channels

The initial selective strip covered only the Authorization header, but the
dashboard also accepts its JWT via the ?token= query parameter and the
nz-jwt cookie (jwt.go TokenLookup channels). A dashboard credential arriving
through those channels would still reach a NAT backend.

Apply the same classify-then-strip rule to all three channels at NAT
ingress: values verified as dashboard-issued are removed, foreign values
are preserved byte-for-byte. Adds a ServeNAT flow test exercising the live
classifier and a wiring test pinning the gate injection in main().
This commit is contained in:
xvkong233 authored and naiba committed 2026-10-01 11:17:45 +00:00
1 parent 40240ed02b
commit 3ad985f36e
12 files changed
+806 -82

No files matched your search

+131 -15
View File
@@ -1,31 +1,43 @@
package rpc
import (
"net/http"
"net/url"
"testing"
"github.com/nezhahq/nezha/model"
)
// Values standing in for the credentials this dashboard issues. The fake gate
// below mirrors the contract of controller.IsDashboardCredential; the real
// classification semantics — panel JWTs accepted regardless of expiry,
// foreign-signed JWTs and random strings rejected — are pinned by the
// controller package tests (dashboard_credential_test.go).
// Values standing in for the credentials this dashboard issues. The fake
// gates below mirror the contract of controller.IsDashboardCredential and
// controller.IsDashboardCredentialValue; the real classification semantics —
// panel JWTs accepted regardless of expiry, foreign-signed JWTs and random
// strings rejected — are pinned by the controller package tests
// (dashboard_credential_test.go) and by the flow test that wires the real
// classifiers into ServeNAT (dashboard_credential_nat_flow_test.go).
const (
natTestDashboardJWT = "Bearer dashboard-signed-jwt"
natTestDashboardExpiredJWT = "Bearer dashboard-signed-expired-jwt"
natTestDashboardAPIToken = "Bearer " + model.APITokenPrefix + "dashboard-issued-pat"
natTestForeignJWT = "Bearer foreign-signed-jwt"
natTestForeignRandom = "Bearer foreign-random-string"
natTestDashboardJWTValue = "dashboard-signed-jwt"
natTestDashboardExpiredJWTValue = "dashboard-signed-expired-jwt"
natTestDashboardPATValue = model.APITokenPrefix + "dashboard-issued-pat"
natTestForeignJWTValue = "foreign-signed-jwt"
natTestForeignRandomValue = "foreign-random-string"
natTestDashboardJWT = "Bearer " + natTestDashboardJWTValue
natTestDashboardExpiredJWT = "Bearer " + natTestDashboardExpiredJWTValue
natTestDashboardAPIToken = "Bearer " + natTestDashboardPATValue
natTestForeignJWT = "Bearer " + natTestForeignJWTValue
natTestForeignRandom = "Bearer " + natTestForeignRandomValue
)
// installNATCredentialTestGate wires a fake dashboard-credential classifier
// installNATCredentialTestGate wires fake dashboard-credential classifiers
// for the duration of the test: exactly the dashboard-issued test values above
// are classified as dashboard credentials, everything else is foreign.
// are classified as dashboard credentials on every channel, everything else
// is foreign.
func installNATCredentialTestGate(t *testing.T) {
t.Helper()
original := natDashboardCredentialGate
natDashboardCredentialGate = func(authz string) bool {
originalAuthorization := natDashboardAuthorizationGate
originalValue := natDashboardCredentialValueGate
natDashboardAuthorizationGate = func(authz string) bool {
switch authz {
case natTestDashboardJWT, natTestDashboardExpiredJWT, natTestDashboardAPIToken:
return true
@@ -33,5 +45,109 @@ func installNATCredentialTestGate(t *testing.T) {
return false
}
}
t.Cleanup(func() { natDashboardCredentialGate = original })
natDashboardCredentialValueGate = func(value string) bool {
switch value {
case natTestDashboardJWTValue, natTestDashboardExpiredJWTValue, natTestDashboardPATValue:
return true
default:
return false
}
}
t.Cleanup(func() {
natDashboardAuthorizationGate = originalAuthorization
natDashboardCredentialValueGate = originalValue
})
}
func TestStripDashboardCredentialsQueryChannel(t *testing.T) {
// Given
installNATCredentialTestGate(t)
request := &http.Request{URL: &url.URL{Path: "/nat", RawQuery: "keep=1&token=" + natTestDashboardJWTValue + "&token=" + natTestForeignJWTValue + "&a=%2Fx"}}
// When
stripDashboardCredentials(request)
// Then
if got := request.URL.RawQuery; got != "keep=1&token="+natTestForeignJWTValue+"&a=%2Fx" {
t.Fatalf("query channel kept %q, want dashboard token dropped and foreign values byte-preserved", got)
}
}
func TestStripDashboardCredentialsQueryChannelEscapedValue(t *testing.T) {
// Given — a dashboard token percent-encoded in the query must still be
// recognized, while the escaped foreign element stays verbatim.
installNATCredentialTestGate(t)
request := &http.Request{URL: &url.URL{Path: "/nat", RawQuery: "token=" + url.QueryEscape(natTestDashboardJWTValue) + "&token=" + url.QueryEscape(natTestForeignJWTValue)}}
// When
stripDashboardCredentials(request)
// Then
if got := request.URL.RawQuery; got != "token="+url.QueryEscape(natTestForeignJWTValue) {
t.Fatalf("query channel kept %q, want escaped dashboard token dropped and foreign element verbatim", got)
}
}
func TestStripDashboardCredentialsCookieChannel(t *testing.T) {
// Given
installNATCredentialTestGate(t)
request := &http.Request{Header: make(http.Header)}
request.Header.Set("Cookie", "sid=abc; nz-jwt="+natTestDashboardJWTValue+"; nz-jwt="+natTestForeignJWTValue+"; theme=dark")
// When
stripDashboardCredentials(request)
// Then
if got := request.Header.Get("Cookie"); got != "sid=abc; nz-jwt="+natTestForeignJWTValue+"; theme=dark" {
t.Fatalf("cookie channel kept %q, want dashboard pair dropped and foreign pairs verbatim", got)
}
}
func TestStripDashboardCredentialsCookieChannelQuotedValue(t *testing.T) {
// Given — quoting is presentation the panel's cookie parsing strips before
// verifying, so a quoted dashboard token must be recognized.
installNATCredentialTestGate(t)
request := &http.Request{Header: make(http.Header)}
request.Header.Set("Cookie", `nz-jwt="`+natTestDashboardJWTValue+`"; sid=abc`)
// When
stripDashboardCredentials(request)
// Then
if got := request.Header.Get("Cookie"); got != "sid=abc" {
t.Fatalf("cookie channel kept %q, want quoted dashboard token dropped", got)
}
}
func TestPrepareNATCapabilityFailsClosedWithoutGate(t *testing.T) {
// Given — unwired gates: every value on every channel is treated as
// dashboard-issued and stripped, foreign ones included.
SetNATDashboardCredentialGate(nil, nil)
t.Cleanup(func() { SetNATDashboardCredentialGate(nil, nil) })
request := &http.Request{
Header: make(http.Header),
URL: &url.URL{Path: "/nat", RawQuery: "keep=1&token=" + natTestForeignJWTValue},
}
request.Header.Set("Authorization", natTestForeignRandom)
request.Header.Set("Cookie", "sid=abc; nz-jwt="+natTestForeignJWTValue)
// When
lease, err := prepareNATCapability(request, &model.NAT{Common: model.Common{ID: 91}, ServerID: 81})
// Then
if err != nil {
t.Fatalf("fail-closed NAT capability hook returned error: %v", err)
}
if lease.active {
t.Fatal("fail-closed NAT capability hook unexpectedly activated")
}
if got := request.Header.Get("Authorization"); got != "" {
t.Fatalf("fail-closed hook retained Authorization %q", got)
}
if got := request.URL.RawQuery; got != "keep=1" {
t.Fatalf("fail-closed hook kept query %q, want token parameter stripped", got)
}
if got := request.Header.Get("Cookie"); got != "sid=abc" {
t.Fatalf("fail-closed hook kept cookie %q, want nz-jwt pair stripped", got)
}
}