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

+5 -4
View File
@@ -84,10 +84,11 @@ func ServeNAT(w http.ResponseWriter, r *http.Request, natConfig *model.NAT) {
// dashboard — the panel-signed JWT (verified by signature only, expiry
// deliberately ignored) and the panel API token — must never become origin
// credentials for the configured NAT backend. prepareNATCapability has
// already stripped every Authorization value IsDashboardCredential
// classified as dashboard-issued; whatever remains is either absent or a
// foreign credential, which is forwarded untouched because the NAT backend
// may authenticate with credentials of its own.
// already stripped them from every channel the panel's own TokenLookup
// accepts (Authorization header, ?token= query parameter, nz-jwt cookie);
// whatever remains on those channels is either absent or a foreign
// credential, which is forwarded untouched because the NAT backend may
// authenticate with credentials of its own.
wWrapped, err := utils.NewRequestWrapper(r, w)
if err != nil {
return
@@ -23,23 +23,25 @@ func prepareNATCapability(request *http.Request, natConfig *model.NAT) (natCapab
values := request.Header.Values(agentcompatcontract.IOStreamCapabilityHeader)
if len(values) == 0 {
// No capability header: legacy NAT path. Strip dashboard-issued
// Authorization values (panel JWT or panel PAT) so they never become
// origin credentials; foreign ones are ordinary request data.
stripDashboardCredential(request)
// credentials (panel JWT or panel PAT) from the Authorization header,
// the ?token= query parameter and the nz-jwt cookie so they never
// become origin credentials; foreign values are ordinary request data.
stripDashboardCredentials(request)
return natCapabilityLease{}, nil
}
request.Header.Del(agentcompatcontract.IOStreamCapabilityHeader)
if len(values) != 1 || values[0] == "" {
// Invalid capability: nothing is forwarded, but dashboard-issued
// credentials still must not survive into errors or task data.
stripDashboardCredential(request)
stripDashboardCredentials(request)
return natCapabilityLease{}, errors.New("invalid NAT capability")
}
access, handle, err := serviceRPC.NezhaHandlerSingleton.ConsumeAgentCompatNATCapabilityForProfile(values[0], natConfig.ServerID, natConfig.ID)
// From here the request bytes may reach the agent (active lease) or the
// request ends in a 503; either way dashboard-issued Authorization values
// are stripped, foreign ones are forwarded untouched.
stripDashboardCredential(request)
// request ends in a 503; either way dashboard-issued credentials are
// stripped from every TokenLookup channel, foreign ones are forwarded
// untouched.
stripDashboardCredentials(request)
if err != nil {
return natCapabilityLease{}, errors.New("invalid NAT capability")
}
@@ -7,6 +7,7 @@ import (
"context"
"log"
"net/http"
"net/url"
"strings"
"testing"
@@ -62,16 +63,21 @@ func TestPrepareNATCapabilityRejectsDuplicateHeaderAfterRemovingAllValues(t *tes
func TestPrepareNATCapabilityAgentCompatStripsDashboardCredentials(t *testing.T) {
// Given — legacy path without a capability header, then the invalid
// capability path: both must drop dashboard-issued Authorization values.
// capability path: both must drop dashboard-issued credentials from the
// Authorization header, the ?token= query parameter and the nz-jwt cookie.
installNATCredentialTestGate(t)
for name, credential := range map[string]string{
"dashboard jwt": natTestDashboardJWT,
"expired dashboard jwt": natTestDashboardExpiredJWT,
"dashboard api token": natTestDashboardAPIToken,
"dashboard jwt": natTestDashboardJWTValue,
"expired dashboard jwt": natTestDashboardExpiredJWTValue,
"dashboard api token": natTestDashboardPATValue,
} {
t.Run(name+"/legacy path", func(t *testing.T) {
request := &http.Request{Header: make(http.Header)}
request.Header.Set("Authorization", credential)
request := &http.Request{
Header: make(http.Header),
URL: &url.URL{Path: "/nat", RawQuery: "keep=1&token=" + credential},
}
request.Header.Set("Authorization", "Bearer "+credential)
request.Header.Set("Cookie", "sid=abc; nz-jwt="+credential)
lease, err := prepareNATCapability(request, &model.NAT{Common: model.Common{ID: 91}, ServerID: 81})
@@ -82,13 +88,23 @@ func TestPrepareNATCapabilityAgentCompatStripsDashboardCredentials(t *testing.T)
t.Fatal("legacy NAT path unexpectedly activated")
}
if got := request.Header.Get("Authorization"); got != "" {
t.Fatalf("legacy NAT path retained dashboard credential %q", got)
t.Fatalf("legacy NAT path retained dashboard credential in Authorization %q", got)
}
if got := request.URL.RawQuery; got != "keep=1" {
t.Fatalf("legacy NAT path retained dashboard credential in query %q", got)
}
if got := request.Header.Get("Cookie"); got != "sid=abc" {
t.Fatalf("legacy NAT path retained dashboard credential in cookie %q", got)
}
})
t.Run(name+"/invalid capability path", func(t *testing.T) {
request := &http.Request{Header: make(http.Header)}
request := &http.Request{
Header: make(http.Header),
URL: &url.URL{Path: "/nat", RawQuery: "keep=1&token=" + credential},
}
request.Header.Set(agentcompatcontract.IOStreamCapabilityHeader, "malformed")
request.Header.Set("Authorization", credential)
request.Header.Set("Authorization", "Bearer "+credential)
request.Header.Set("Cookie", "sid=abc; nz-jwt="+credential)
lease, err := prepareNATCapability(request, &model.NAT{Common: model.Common{ID: 91}, ServerID: 81})
@@ -99,7 +115,13 @@ func TestPrepareNATCapabilityAgentCompatStripsDashboardCredentials(t *testing.T)
t.Fatal("malformed capability unexpectedly activated")
}
if got := request.Header.Get("Authorization"); got != "" {
t.Fatalf("invalid capability path retained dashboard credential %q", got)
t.Fatalf("invalid capability path retained dashboard credential in Authorization %q", got)
}
if got := request.URL.RawQuery; got != "keep=1" {
t.Fatalf("invalid capability path retained dashboard credential in query %q", got)
}
if got := request.Header.Get("Cookie"); got != "sid=abc" {
t.Fatalf("invalid capability path retained dashboard credential in cookie %q", got)
}
})
}
@@ -124,9 +146,13 @@ func TestPrepareNATCapabilityAgentCompatStripsDashboardCredentialAfterConsumingC
if err != nil {
t.Fatalf("register capability: %v", err)
}
request := &http.Request{Header: make(http.Header)}
request := &http.Request{
Header: make(http.Header),
URL: &url.URL{Path: "/nat", RawQuery: "keep=1&token=" + natTestDashboardJWTValue},
}
request.Header.Set(agentcompatcontract.IOStreamCapabilityHeader, capability.String())
request.Header.Set("Authorization", natTestDashboardJWT)
request.Header.Set("Cookie", "sid=abc; nz-jwt="+natTestDashboardJWTValue)
// When
lease, err := prepareNATCapability(request, &model.NAT{Common: model.Common{ID: 91}, ServerID: 81})
@@ -139,7 +165,13 @@ func TestPrepareNATCapabilityAgentCompatStripsDashboardCredentialAfterConsumingC
t.Fatal("valid NAT capability did not activate")
}
if got := request.Header.Get("Authorization"); got != "" {
t.Fatalf("dashboard credential retained after capability consumption: %q", got)
t.Fatalf("dashboard credential retained in Authorization after capability consumption: %q", got)
}
if got := request.URL.RawQuery; got != "keep=1" {
t.Fatalf("dashboard credential retained in query after capability consumption: %q", got)
}
if got := request.Header.Get("Cookie"); got != "sid=abc" {
t.Fatalf("dashboard credential retained in cookie after capability consumption: %q", got)
}
}
@@ -180,6 +212,40 @@ func TestPrepareNATCapabilityAgentCompatKeepsForeignAndMissingAuthorization(t *t
}
}
func TestPrepareNATCapabilityAgentCompatKeepsForeignAndMissingQueryAndCookie(t *testing.T) {
// Given — legacy path: foreign query tokens and cookies are ordinary
// request data, and absent channels must stay untouched.
installNATCredentialTestGate(t)
for name, tc := range map[string]struct{ rawQuery, cookie string }{
"foreign values": {"keep=1&token=" + natTestForeignJWTValue + "&a=%2Fx", "sid=abc; nz-jwt=" + natTestForeignJWTValue},
"empty values": {"keep=1&token=", "nz-jwt="},
"missing channel": {"keep=1", "sid=abc"},
} {
t.Run(name, func(t *testing.T) {
request := &http.Request{
Header: make(http.Header),
URL: &url.URL{Path: "/nat", RawQuery: tc.rawQuery},
}
request.Header.Set("Cookie", tc.cookie)
lease, err := prepareNATCapability(request, &model.NAT{Common: model.Common{ID: 91}, ServerID: 81})
if err != nil {
t.Fatalf("legacy NAT path returned error: %v", err)
}
if lease.active {
t.Fatal("legacy NAT path unexpectedly activated")
}
if got := request.URL.RawQuery; got != tc.rawQuery {
t.Fatalf("legacy NAT path changed query to %q, want %q", got, tc.rawQuery)
}
if got := request.Header.Get("Cookie"); got != tc.cookie {
t.Fatalf("legacy NAT path changed cookie to %q, want %q", got, tc.cookie)
}
})
}
}
func TestServeNATAgentCompatSensitiveHeadersStayOutOfErrorsAndLogs(t *testing.T) {
installNATCredentialTestGate(t)
request := &http.Request{Header: make(http.Header)}
+5 -4
View File
@@ -19,10 +19,11 @@ type natCapabilityLease struct {
func prepareNATCapability(request *http.Request, _ *model.NAT) (natCapabilityLease, error) {
// The default build has no capability protocol: every request takes the
// legacy NAT path, so any dashboard-issued Authorization value (panel JWT
// or panel PAT) is stripped before the request is tunneled to the agent.
// Foreign Authorization values are ordinary request data and stay put.
stripDashboardCredential(request)
// legacy NAT path, so any dashboard-issued credential (panel JWT or panel
// PAT) carried by the Authorization header, the ?token= query parameter or
// the nz-jwt cookie is stripped before the request is tunneled to the
// agent. Foreign values are ordinary request data and stay put.
stripDashboardCredentials(request)
return natCapabilityLease{}, nil
}
@@ -4,6 +4,7 @@ package rpc
import (
"net/http"
"net/url"
"testing"
"github.com/nezhahq/nezha/model"
@@ -39,13 +40,17 @@ func TestPrepareNATCapabilityDefaultStripsDashboardCredentials(t *testing.T) {
// Given
installNATCredentialTestGate(t)
for name, credential := range map[string]string{
"dashboard jwt": natTestDashboardJWT,
"expired dashboard jwt": natTestDashboardExpiredJWT,
"dashboard api token": natTestDashboardAPIToken,
"dashboard jwt": natTestDashboardJWTValue,
"expired dashboard jwt": natTestDashboardExpiredJWTValue,
"dashboard api token": natTestDashboardPATValue,
} {
t.Run(name, func(t *testing.T) {
request := &http.Request{Header: make(http.Header)}
request.Header.Set("Authorization", credential)
request := &http.Request{
Header: make(http.Header),
URL: &url.URL{Path: "/nat", RawQuery: "keep=1&token=" + credential},
}
request.Header.Set("Authorization", "Bearer "+credential)
request.Header.Set("Cookie", "sid=abc; nz-jwt="+credential)
// When
lease, err := prepareNATCapability(request, &model.NAT{Common: model.Common{ID: 91}, ServerID: 81})
@@ -58,7 +63,13 @@ func TestPrepareNATCapabilityDefaultStripsDashboardCredentials(t *testing.T) {
t.Fatal("default NAT capability hook unexpectedly activated")
}
if got := request.Header.Get("Authorization"); got != "" {
t.Fatalf("default NAT capability hook retained dashboard credential %q", got)
t.Fatalf("default NAT capability hook retained dashboard credential in Authorization %q", got)
}
if got := request.URL.RawQuery; got != "keep=1" {
t.Fatalf("default NAT capability hook retained dashboard credential in query %q", got)
}
if got := request.Header.Get("Cookie"); got != "sid=abc" {
t.Fatalf("default NAT capability hook retained dashboard credential in cookie %q", got)
}
})
}
@@ -101,3 +112,38 @@ func TestPrepareNATCapabilityDefaultKeepsForeignAndMissingAuthorization(t *testi
})
}
}
func TestPrepareNATCapabilityDefaultKeepsForeignAndMissingQueryAndCookie(t *testing.T) {
// Given
installNATCredentialTestGate(t)
for name, tc := range map[string]struct{ rawQuery, cookie string }{
"foreign values": {"keep=1&token=" + natTestForeignJWTValue + "&a=%2Fx", "sid=abc; nz-jwt=" + natTestForeignJWTValue},
"empty values": {"keep=1&token=", "nz-jwt="},
"missing channel": {"keep=1", "sid=abc"},
} {
t.Run(name, func(t *testing.T) {
request := &http.Request{
Header: make(http.Header),
URL: &url.URL{Path: "/nat", RawQuery: tc.rawQuery},
}
request.Header.Set("Cookie", tc.cookie)
// When
lease, err := prepareNATCapability(request, &model.NAT{Common: model.Common{ID: 91}, ServerID: 81})
// Then
if err != nil {
t.Fatalf("default NAT capability hook returned error: %v", err)
}
if lease.active {
t.Fatal("default NAT capability hook unexpectedly activated")
}
if got := request.URL.RawQuery; got != tc.rawQuery {
t.Fatalf("default NAT capability hook changed query to %q, want %q", got, tc.rawQuery)
}
if got := request.Header.Get("Cookie"); got != tc.cookie {
t.Fatalf("default NAT capability hook changed cookie to %q, want %q", got, tc.cookie)
}
})
}
}
+168 -21
View File
@@ -1,29 +1,69 @@
package rpc
import "net/http"
import (
"net/http"
"net/url"
"strings"
)
// natDashboardCredentialGate classifies an Authorization header value as a
// credential issued by this dashboard (panel-signed JWT or panel API token).
// main() injects controller.IsDashboardCredential; the setter keeps the rpc
// package free of a dependency on the controller package, the same pattern as
// SetReceiptGateListener and SetMCPKillSwitchObserver.
var natDashboardCredentialGate func(authz string) bool
// Channel names of the panel's own TokenLookup (controller/jwt.go
// "header: Authorization, query: token, cookie: nz-jwt"). A dashboard JWT
// can therefore reach the NAT ingress through the Authorization header, the
// ?token= query parameter or the nz-jwt cookie, and all three must be
// classified before the request is tunneled to the agent.
const (
natDashboardJWTCookieName = "nz-jwt"
natDashboardJWTQueryParameter = "token"
)
// SetNATDashboardCredentialGate wires the dashboard-credential classifier used
// by the NAT ingress. Passing nil restores the fail-closed default in
// stripDashboardCredential.
func SetNATDashboardCredentialGate(fn func(authz string) bool) {
natDashboardCredentialGate = fn
var (
// natDashboardAuthorizationGate classifies an Authorization header value
// (case-sensitive "Bearer " scheme included) as a credential issued by
// this dashboard.
natDashboardAuthorizationGate func(authz string) bool
// natDashboardCredentialValueGate classifies a raw credential value — the
// part after the "Bearer " scheme, exactly as the ?token= query parameter
// and the nz-jwt cookie carry it — as a credential issued by this
// dashboard.
natDashboardCredentialValueGate func(value string) bool
)
// SetNATDashboardCredentialGate wires the dashboard-credential classifiers
// used by the NAT ingress. main() injects
// controller.IsDashboardCredential and controller.IsDashboardCredentialValue;
// the setter keeps the rpc package free of a dependency on the controller
// package, the same pattern as SetReceiptGateListener and
// SetMCPKillSwitchObserver. A nil function fails closed: every value on that
// channel is then treated as dashboard-issued and stripped.
func SetNATDashboardCredentialGate(authorizationClass func(authz string) bool, valueClass func(value string) bool) {
natDashboardAuthorizationGate = authorizationClass
natDashboardCredentialValueGate = valueClass
}
// stripDashboardCredential removes the Authorization header when it carries a
// credential issued by this dashboard (panel-signed JWT or panel API token).
// Such credentials authenticate the dashboard itself and must never become
// origin credentials for the NAT backend. Foreign credentials are forwarded
// untouched: the NAT backend may authenticate with credentials of its own, and
// this dashboard must not make policy for them.
func stripDashboardCredential(request *http.Request) {
if natDashboardCredentialGate == nil {
// NATDashboardCredentialGateWired reports whether the dashboard-credential
// classifiers have been wired. It exists so the main() wiring contract can be
// pinned by a test.
func NATDashboardCredentialGateWired() bool {
return natDashboardAuthorizationGate != nil && natDashboardCredentialValueGate != nil
}
// stripDashboardCredentials removes credentials issued by this dashboard from
// every channel the panel's own TokenLookup accepts: the Authorization
// header, the ?token= query parameter and the nz-jwt cookie. Such credentials
// authenticate the dashboard itself and must never become origin credentials
// for the NAT backend. Foreign values are forwarded untouched: the NAT
// backend may authenticate with credentials of its own, and this dashboard
// must not make policy for them.
func stripDashboardCredentials(request *http.Request) {
stripDashboardAuthorizationHeader(request)
stripDashboardCredentialQuery(request)
stripDashboardCredentialCookie(request)
}
// stripDashboardAuthorizationHeader removes the Authorization header when it
// carries a dashboard-issued credential.
func stripDashboardAuthorizationHeader(request *http.Request) {
if natDashboardAuthorizationGate == nil {
// Gate not wired (early startup or tests): fail closed and keep the
// historical behavior of stripping every Authorization header.
request.Header.Del("Authorization")
@@ -33,9 +73,116 @@ func stripDashboardCredential(request *http.Request) {
// dashboard-issued value in it must still not survive into the tunnel, so
// the whole header is dropped when one is recognized.
for _, value := range request.Header.Values("Authorization") {
if natDashboardCredentialGate(value) {
if natDashboardAuthorizationGate(value) {
request.Header.Del("Authorization")
return
}
}
}
// stripDashboardCredentialQuery removes ?token= query parameters whose value
// is a dashboard-issued credential. Every other element of the query string —
// including foreign token values — is preserved byte-for-byte, so the
// forwarded request keeps the exact query the client sent.
func stripDashboardCredentialQuery(request *http.Request) {
if request.URL == nil || request.URL.RawQuery == "" {
return
}
elements := strings.Split(request.URL.RawQuery, "&")
kept := elements[:0]
changed := false
for _, element := range elements {
if isDashboardCredentialQueryElement(element) {
changed = true
continue
}
kept = append(kept, element)
}
if changed {
request.URL.RawQuery = strings.Join(kept, "&")
}
}
// isDashboardCredentialQueryElement reports whether a raw query element
// carries a dashboard-issued credential in the panel's token parameter.
func isDashboardCredentialQueryElement(element string) bool {
rawName, rawValue, _ := strings.Cut(element, "=")
// The panel reads the parameter by its decoded name (gin-jwt c.Query),
// so an escaped key must be decoded before comparing.
name, err := url.QueryUnescape(rawName)
if err != nil || name != natDashboardJWTQueryParameter {
return false
}
if natDashboardCredentialValueGate == nil {
return true // fail closed
}
// A value that fails to decode is one the panel's own query parsing drops
// as well, so it can never be a panel credential.
decoded, err := url.QueryUnescape(rawValue)
if err != nil {
return false
}
return natDashboardCredentialValueGate(decoded)
}
// stripDashboardCredentialCookie removes nz-jwt cookies whose value is a
// dashboard-issued credential. All other cookie pairs — including foreign
// nz-jwt values — are preserved verbatim.
func stripDashboardCredentialCookie(request *http.Request) {
lines := request.Header.Values("Cookie")
if len(lines) == 0 {
return
}
changed := false
kept := make([]string, 0, len(lines))
for _, line := range lines {
nextLine, lineChanged := stripDashboardCredentialCookieLine(line)
changed = changed || lineChanged
if nextLine != "" {
kept = append(kept, nextLine)
}
}
if !changed {
return
}
request.Header.Del("Cookie")
for _, line := range kept {
request.Header.Add("Cookie", line)
}
}
// stripDashboardCredentialCookieLine rewrites a single Cookie header line,
// dropping the dashboard-issued nz-jwt pairs. Kept pairs keep their raw form,
// whitespace included — except that a pair following a dropped one no longer
// carries the removed pair's share of the "; " separator.
func stripDashboardCredentialCookieLine(line string) (string, bool) {
pairs := strings.Split(line, ";")
kept := pairs[:0]
droppedBeforeFirstKept := true
changed := false
for _, pair := range pairs {
name, value, _ := strings.Cut(pair, "=")
if strings.TrimSpace(name) == natDashboardJWTCookieName &&
(natDashboardCredentialValueGate == nil || isDashboardCredentialCookieValue(value)) {
changed = true
continue
}
if droppedBeforeFirstKept {
pair = strings.TrimLeft(pair, " ")
}
droppedBeforeFirstKept = false
kept = append(kept, pair)
}
if !changed {
return line, false
}
return strings.Join(kept, ";"), true
}
// isDashboardCredentialCookieValue classifies a raw cookie value the same way
// the panel's cookie parsing would hand it to the JWT verifier: surrounding
// whitespace and quoting are presentation, not part of the credential.
func isDashboardCredentialCookieValue(rawValue string) bool {
value := strings.Trim(strings.TrimSpace(rawValue), `"`)
return natDashboardCredentialValueGate(value)
}
+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)
}
}