mirror of
https://github.com/Buriburizaem0n/nezha_domains.git
synced 2026-11-04 16:40:21 +00:00
fix(nat): scrub dashboard-issued credentials from all accepted token channels
The initial selective strip covered only the Authorization header, but the dashboard also accepts its JWT via the ?token= query parameter and the nz-jwt cookie (jwt.go TokenLookup channels). A dashboard credential arriving through those channels would still reach a NAT backend. Apply the same classify-then-strip rule to all three channels at NAT ingress: values verified as dashboard-issued are removed, foreign values are preserved byte-for-byte. Adds a ServeNAT flow test exercising the live classifier and a wiring test pinning the gate injection in main().
This commit is contained in:
1 parent
40240ed02b
commit
3ad985f36e
12 files changed
+806
-82
No files matched your search
@@ -1,31 +1,43 @@
|
||||
package rpc
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/nezhahq/nezha/model"
|
||||
)
|
||||
|
||||
// Values standing in for the credentials this dashboard issues. The fake gate
|
||||
// below mirrors the contract of controller.IsDashboardCredential; the real
|
||||
// classification semantics — panel JWTs accepted regardless of expiry,
|
||||
// foreign-signed JWTs and random strings rejected — are pinned by the
|
||||
// controller package tests (dashboard_credential_test.go).
|
||||
// Values standing in for the credentials this dashboard issues. The fake
|
||||
// gates below mirror the contract of controller.IsDashboardCredential and
|
||||
// controller.IsDashboardCredentialValue; the real classification semantics —
|
||||
// panel JWTs accepted regardless of expiry, foreign-signed JWTs and random
|
||||
// strings rejected — are pinned by the controller package tests
|
||||
// (dashboard_credential_test.go) and by the flow test that wires the real
|
||||
// classifiers into ServeNAT (dashboard_credential_nat_flow_test.go).
|
||||
const (
|
||||
natTestDashboardJWT = "Bearer dashboard-signed-jwt"
|
||||
natTestDashboardExpiredJWT = "Bearer dashboard-signed-expired-jwt"
|
||||
natTestDashboardAPIToken = "Bearer " + model.APITokenPrefix + "dashboard-issued-pat"
|
||||
natTestForeignJWT = "Bearer foreign-signed-jwt"
|
||||
natTestForeignRandom = "Bearer foreign-random-string"
|
||||
natTestDashboardJWTValue = "dashboard-signed-jwt"
|
||||
natTestDashboardExpiredJWTValue = "dashboard-signed-expired-jwt"
|
||||
natTestDashboardPATValue = model.APITokenPrefix + "dashboard-issued-pat"
|
||||
natTestForeignJWTValue = "foreign-signed-jwt"
|
||||
natTestForeignRandomValue = "foreign-random-string"
|
||||
|
||||
natTestDashboardJWT = "Bearer " + natTestDashboardJWTValue
|
||||
natTestDashboardExpiredJWT = "Bearer " + natTestDashboardExpiredJWTValue
|
||||
natTestDashboardAPIToken = "Bearer " + natTestDashboardPATValue
|
||||
natTestForeignJWT = "Bearer " + natTestForeignJWTValue
|
||||
natTestForeignRandom = "Bearer " + natTestForeignRandomValue
|
||||
)
|
||||
|
||||
// installNATCredentialTestGate wires a fake dashboard-credential classifier
|
||||
// installNATCredentialTestGate wires fake dashboard-credential classifiers
|
||||
// for the duration of the test: exactly the dashboard-issued test values above
|
||||
// are classified as dashboard credentials, everything else is foreign.
|
||||
// are classified as dashboard credentials on every channel, everything else
|
||||
// is foreign.
|
||||
func installNATCredentialTestGate(t *testing.T) {
|
||||
t.Helper()
|
||||
original := natDashboardCredentialGate
|
||||
natDashboardCredentialGate = func(authz string) bool {
|
||||
originalAuthorization := natDashboardAuthorizationGate
|
||||
originalValue := natDashboardCredentialValueGate
|
||||
natDashboardAuthorizationGate = func(authz string) bool {
|
||||
switch authz {
|
||||
case natTestDashboardJWT, natTestDashboardExpiredJWT, natTestDashboardAPIToken:
|
||||
return true
|
||||
@@ -33,5 +45,109 @@ func installNATCredentialTestGate(t *testing.T) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
t.Cleanup(func() { natDashboardCredentialGate = original })
|
||||
natDashboardCredentialValueGate = func(value string) bool {
|
||||
switch value {
|
||||
case natTestDashboardJWTValue, natTestDashboardExpiredJWTValue, natTestDashboardPATValue:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
natDashboardAuthorizationGate = originalAuthorization
|
||||
natDashboardCredentialValueGate = originalValue
|
||||
})
|
||||
}
|
||||
|
||||
func TestStripDashboardCredentialsQueryChannel(t *testing.T) {
|
||||
// Given
|
||||
installNATCredentialTestGate(t)
|
||||
request := &http.Request{URL: &url.URL{Path: "/nat", RawQuery: "keep=1&token=" + natTestDashboardJWTValue + "&token=" + natTestForeignJWTValue + "&a=%2Fx"}}
|
||||
|
||||
// When
|
||||
stripDashboardCredentials(request)
|
||||
|
||||
// Then
|
||||
if got := request.URL.RawQuery; got != "keep=1&token="+natTestForeignJWTValue+"&a=%2Fx" {
|
||||
t.Fatalf("query channel kept %q, want dashboard token dropped and foreign values byte-preserved", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripDashboardCredentialsQueryChannelEscapedValue(t *testing.T) {
|
||||
// Given — a dashboard token percent-encoded in the query must still be
|
||||
// recognized, while the escaped foreign element stays verbatim.
|
||||
installNATCredentialTestGate(t)
|
||||
request := &http.Request{URL: &url.URL{Path: "/nat", RawQuery: "token=" + url.QueryEscape(natTestDashboardJWTValue) + "&token=" + url.QueryEscape(natTestForeignJWTValue)}}
|
||||
|
||||
// When
|
||||
stripDashboardCredentials(request)
|
||||
|
||||
// Then
|
||||
if got := request.URL.RawQuery; got != "token="+url.QueryEscape(natTestForeignJWTValue) {
|
||||
t.Fatalf("query channel kept %q, want escaped dashboard token dropped and foreign element verbatim", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripDashboardCredentialsCookieChannel(t *testing.T) {
|
||||
// Given
|
||||
installNATCredentialTestGate(t)
|
||||
request := &http.Request{Header: make(http.Header)}
|
||||
request.Header.Set("Cookie", "sid=abc; nz-jwt="+natTestDashboardJWTValue+"; nz-jwt="+natTestForeignJWTValue+"; theme=dark")
|
||||
|
||||
// When
|
||||
stripDashboardCredentials(request)
|
||||
|
||||
// Then
|
||||
if got := request.Header.Get("Cookie"); got != "sid=abc; nz-jwt="+natTestForeignJWTValue+"; theme=dark" {
|
||||
t.Fatalf("cookie channel kept %q, want dashboard pair dropped and foreign pairs verbatim", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripDashboardCredentialsCookieChannelQuotedValue(t *testing.T) {
|
||||
// Given — quoting is presentation the panel's cookie parsing strips before
|
||||
// verifying, so a quoted dashboard token must be recognized.
|
||||
installNATCredentialTestGate(t)
|
||||
request := &http.Request{Header: make(http.Header)}
|
||||
request.Header.Set("Cookie", `nz-jwt="`+natTestDashboardJWTValue+`"; sid=abc`)
|
||||
|
||||
// When
|
||||
stripDashboardCredentials(request)
|
||||
|
||||
// Then
|
||||
if got := request.Header.Get("Cookie"); got != "sid=abc" {
|
||||
t.Fatalf("cookie channel kept %q, want quoted dashboard token dropped", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareNATCapabilityFailsClosedWithoutGate(t *testing.T) {
|
||||
// Given — unwired gates: every value on every channel is treated as
|
||||
// dashboard-issued and stripped, foreign ones included.
|
||||
SetNATDashboardCredentialGate(nil, nil)
|
||||
t.Cleanup(func() { SetNATDashboardCredentialGate(nil, nil) })
|
||||
request := &http.Request{
|
||||
Header: make(http.Header),
|
||||
URL: &url.URL{Path: "/nat", RawQuery: "keep=1&token=" + natTestForeignJWTValue},
|
||||
}
|
||||
request.Header.Set("Authorization", natTestForeignRandom)
|
||||
request.Header.Set("Cookie", "sid=abc; nz-jwt="+natTestForeignJWTValue)
|
||||
|
||||
// When
|
||||
lease, err := prepareNATCapability(request, &model.NAT{Common: model.Common{ID: 91}, ServerID: 81})
|
||||
|
||||
// Then
|
||||
if err != nil {
|
||||
t.Fatalf("fail-closed NAT capability hook returned error: %v", err)
|
||||
}
|
||||
if lease.active {
|
||||
t.Fatal("fail-closed NAT capability hook unexpectedly activated")
|
||||
}
|
||||
if got := request.Header.Get("Authorization"); got != "" {
|
||||
t.Fatalf("fail-closed hook retained Authorization %q", got)
|
||||
}
|
||||
if got := request.URL.RawQuery; got != "keep=1" {
|
||||
t.Fatalf("fail-closed hook kept query %q, want token parameter stripped", got)
|
||||
}
|
||||
if got := request.Header.Get("Cookie"); got != "sid=abc" {
|
||||
t.Fatalf("fail-closed hook kept cookie %q, want nz-jwt pair stripped", got)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user