diff --git a/cmd/dashboard/controller/dashboard_credential.go b/cmd/dashboard/controller/dashboard_credential.go index 00af53ea..f16e3e34 100644 --- a/cmd/dashboard/controller/dashboard_credential.go +++ b/cmd/dashboard/controller/dashboard_credential.go @@ -1,12 +1,13 @@ package controller import ( - "errors" + "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" @@ -15,8 +16,8 @@ import ( // dashboardCredentialJWTParser verifies that a JWT was signed by this // dashboard. It is derived from the auth middleware created in routers() and -// exists only for IsDashboardCredential; it must never authenticate dashboard -// requests on its own. +// 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 @@ -69,32 +70,75 @@ func IsDashboardCredential(authz string) bool { // issued by this dashboard (unknown PAT hash, failed signature verification) // returns false. func IsDashboardCredentialValue(value string) bool { - plaintext := strings.TrimSpace(value) - if strings.HasPrefix(plaintext, model.APITokenPrefix) { - return isDashboardAPIToken(plaintext) + classified := ClassifyDashboardCredentialValues(context.Background(), []string{value}) + if len(classified) != 1 { + return true } - return isDashboardSignedJWT(plaintext) + return classified[0] } -// isDashboardAPIToken resolves a PAT-shaped value against the api_tokens -// table. The plaintext token is only ever hashed here; it is never logged. -func isDashboardAPIToken(plaintext string) bool { +// 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") - return true + for _, indexes := range patIndexes { + for _, i := range indexes { + results[i] = true + } + } + return results } - var tok model.APIToken - err := singleton.DB.Where("token_hash = ?", model.HashAPIToken(plaintext)).First(&tok).Error - if err == nil { - return true + + hashes := make([]string, 0, len(patIndexes)) + for hash := range patIndexes { + hashes = append(hashes, hash) } - if errors.Is(err, gorm.ErrRecordNotFound) { - // Deterministic proof the token was not issued by this dashboard: - // forwarding it leaks nothing the dashboard ever validated. - return false + type tokenHashRow struct { + TokenHash string } - logDashboardCredentialUnclassifiable("api token lookup failed") - return true + 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 @@ -112,7 +156,7 @@ func isDashboardSignedJWT(token string) bool { } // logDashboardCredentialUnclassifiable records a classification failure. The -// Authorization value itself is never included in the message. +// credential value itself is never included in the message. func logDashboardCredentialUnclassifiable(reason string) { - log.Printf("NEZHA>> NAT ingress: Authorization value could not be classified (%s); stripping it as a precaution", reason) + log.Printf("NEZHA>> NAT ingress: credential value could not be classified (%s); stripping it as a precaution", reason) } diff --git a/cmd/dashboard/controller/dashboard_credential_nat_flow_test.go b/cmd/dashboard/controller/dashboard_credential_nat_flow_test.go index 26a8f6ff..770e6f5e 100644 --- a/cmd/dashboard/controller/dashboard_credential_nat_flow_test.go +++ b/cmd/dashboard/controller/dashboard_credential_nat_flow_test.go @@ -49,8 +49,8 @@ func setupNATFlowTest(t *testing.T) (*rpcService.NezhaHandler, *model.Server, *n // Wire the gate exactly as cmd/dashboard/main.go does, with the real // classifiers built by setupDashboardCredentialTest. - rpc.SetNATDashboardCredentialGate(IsDashboardCredential, IsDashboardCredentialValue) - t.Cleanup(func() { rpc.SetNATDashboardCredentialGate(nil, nil) }) + rpc.SetNATDashboardCredentialGate(ClassifyDashboardCredentialValues) + t.Cleanup(func() { rpc.SetNATDashboardCredentialGate(nil) }) return handler, server, taskStream } diff --git a/cmd/dashboard/controller/dashboard_credential_test.go b/cmd/dashboard/controller/dashboard_credential_test.go index fbfe47ec..9c1cc81c 100644 --- a/cmd/dashboard/controller/dashboard_credential_test.go +++ b/cmd/dashboard/controller/dashboard_credential_test.go @@ -2,6 +2,7 @@ package controller import ( "bytes" + "context" "log" "testing" "time" @@ -163,6 +164,35 @@ func TestIsDashboardCredentialValueClassification(t *testing.T) { }) } +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 diff --git a/cmd/dashboard/dashboard_credential_wiring_test.go b/cmd/dashboard/dashboard_credential_wiring_test.go index 95be89ff..d53e7c8f 100644 --- a/cmd/dashboard/dashboard_credential_wiring_test.go +++ b/cmd/dashboard/dashboard_credential_wiring_test.go @@ -7,8 +7,8 @@ import ( ) // TestWireNATDashboardCredentialGate pins the NAT ingress wiring contract: -// main() must connect the real dashboard-credential classifiers -// (controller.IsDashboardCredential / controller.IsDashboardCredentialValue) +// 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. diff --git a/cmd/dashboard/main.go b/cmd/dashboard/main.go index 4b64c441..4c41ae0a 100644 --- a/cmd/dashboard/main.go +++ b/cmd/dashboard/main.go @@ -235,10 +235,10 @@ func main() { // TokenLookup accepts — the Authorization header, the ?token= query parameter // and the nz-jwt cookie — before tunneling a request to the agent, and // forwards foreign values untouched. ServeWeb built the JWT parser the -// classifiers reuse, so the gate is wired right after it and before any +// classifier reuses, so the gate is wired right after it and before any // listener starts serving. func wireNATDashboardCredentialGate() { - rpc.SetNATDashboardCredentialGate(controller.IsDashboardCredential, controller.IsDashboardCredentialValue) + rpc.SetNATDashboardCredentialGate(controller.ClassifyDashboardCredentialValues) } func newHTTPandGRPCMux(httpHandler http.Handler, grpcHandler http.Handler) http.Handler { diff --git a/cmd/dashboard/rpc/nat_credential.go b/cmd/dashboard/rpc/nat_credential.go index a9e82f4a..153f556e 100644 --- a/cmd/dashboard/rpc/nat_credential.go +++ b/cmd/dashboard/rpc/nat_credential.go @@ -1,9 +1,11 @@ package rpc import ( + "context" "net/http" "net/url" "strings" + "sync" ) // Channel names of the panel's own TokenLookup (controller/jwt.go @@ -14,37 +16,42 @@ import ( 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 ( - // 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 + 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 classifiers +// SetNATDashboardCredentialGate wires the dashboard-credential classifier // used by the NAT ingress. main() injects -// controller.IsDashboardCredential and controller.IsDashboardCredentialValue; +// 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(authorizationClass func(authz string) bool, valueClass func(value string) bool) { - natDashboardAuthorizationGate = authorizationClass +func SetNATDashboardCredentialGate(valueClass func(ctx context.Context, values []string) []bool) { + natDashboardCredentialGateMu.Lock() natDashboardCredentialValueGate = valueClass + natDashboardCredentialGateMu.Unlock() } // NATDashboardCredentialGateWired reports whether the dashboard-credential -// classifiers have been wired. It exists so the main() wiring contract can be +// classifier has been wired. It exists so the main() wiring contract can be // pinned by a test. func NATDashboardCredentialGateWired() bool { - return natDashboardAuthorizationGate != nil && natDashboardCredentialValueGate != nil + natDashboardCredentialGateMu.RLock() + wired := natDashboardCredentialValueGate != nil + natDashboardCredentialGateMu.RUnlock() + return wired } // stripDashboardCredentials removes credentials issued by this dashboard from @@ -55,15 +62,103 @@ func NATDashboardCredentialGateWired() bool { // 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) + 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) { - if natDashboardAuthorizationGate == nil { +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") @@ -73,7 +168,8 @@ func stripDashboardAuthorizationHeader(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 natDashboardAuthorizationGate(value) { + raw := strings.TrimSpace(value) + if strings.HasPrefix(raw, "Bearer ") && classification.isDashboardCredential(strings.TrimPrefix(raw, "Bearer ")) { request.Header.Del("Authorization") return } @@ -84,7 +180,7 @@ func stripDashboardAuthorizationHeader(request *http.Request) { // 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) { +func stripDashboardCredentialQuery(request *http.Request, classification natDashboardCredentialClassification) { if request.URL == nil || request.URL.RawQuery == "" { return } @@ -92,7 +188,12 @@ func stripDashboardCredentialQuery(request *http.Request) { kept := elements[:0] changed := false for _, element := range elements { - if isDashboardCredentialQueryElement(element) { + if !classification.wired && dashboardCredentialQueryElementName(element) { + changed = true + continue + } + value, candidate := dashboardCredentialQueryElementValue(element) + if candidate && classification.isDashboardCredential(value) { changed = true continue } @@ -103,32 +204,35 @@ func stripDashboardCredentialQuery(request *http.Request) { } } -// isDashboardCredentialQueryElement reports whether a raw query element -// carries a dashboard-issued credential in the panel's token parameter. -func isDashboardCredentialQueryElement(element string) bool { +// 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 - } - if natDashboardCredentialValueGate == nil { - return true // fail closed + 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 "", false } - return natDashboardCredentialValueGate(decoded) + 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) { +func stripDashboardCredentialCookie(request *http.Request, classification natDashboardCredentialClassification) { lines := request.Header.Values("Cookie") if len(lines) == 0 { return @@ -136,7 +240,7 @@ func stripDashboardCredentialCookie(request *http.Request) { changed := false kept := make([]string, 0, len(lines)) for _, line := range lines { - nextLine, lineChanged := stripDashboardCredentialCookieLine(line) + nextLine, lineChanged := stripDashboardCredentialCookieLine(line, classification) changed = changed || lineChanged if nextLine != "" { kept = append(kept, nextLine) @@ -155,15 +259,18 @@ func stripDashboardCredentialCookie(request *http.Request) { // 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) { +func stripDashboardCredentialCookieLine(line string, classification natDashboardCredentialClassification) (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)) { + if !classification.wired && dashboardCredentialCookiePairName(pair) { + changed = true + continue + } + value, candidate := dashboardCredentialCookiePairValue(pair) + if candidate && classification.isDashboardCredential(value) { changed = true continue } @@ -179,10 +286,23 @@ func stripDashboardCredentialCookieLine(line string) (string, bool) { 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 { +// 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), `"`) - return natDashboardCredentialValueGate(value) + 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 } diff --git a/cmd/dashboard/rpc/nat_credential_test.go b/cmd/dashboard/rpc/nat_credential_test.go index 2f7a2be9..234e78f2 100644 --- a/cmd/dashboard/rpc/nat_credential_test.go +++ b/cmd/dashboard/rpc/nat_credential_test.go @@ -1,8 +1,11 @@ package rpc import ( + "context" + "fmt" "net/http" "net/url" + "strings" "testing" "github.com/nezhahq/nezha/model" @@ -35,27 +38,21 @@ const ( // is foreign. func installNATCredentialTestGate(t *testing.T) { t.Helper() - originalAuthorization := natDashboardAuthorizationGate - originalValue := natDashboardCredentialValueGate - natDashboardAuthorizationGate = func(authz string) bool { - switch authz { - case natTestDashboardJWT, natTestDashboardExpiredJWT, natTestDashboardAPIToken: - return true - default: - return false + 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 + } } - } - natDashboardCredentialValueGate = func(value string) bool { - switch value { - case natTestDashboardJWTValue, natTestDashboardExpiredJWTValue, natTestDashboardPATValue: - return true - default: - return false - } - } + return results + }) t.Cleanup(func() { - natDashboardAuthorizationGate = originalAuthorization - natDashboardCredentialValueGate = originalValue + SetNATDashboardCredentialGate(original) }) } @@ -119,17 +116,69 @@ func TestStripDashboardCredentialsCookieChannelQuotedValue(t *testing.T) { } } +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, nil) - t.Cleanup(func() { SetNATDashboardCredentialGate(nil, nil) }) + SetNATDashboardCredentialGate(nil) + t.Cleanup(func() { SetNATDashboardCredentialGate(nil) }) request := &http.Request{ Header: make(http.Header), - URL: &url.URL{Path: "/nat", RawQuery: "keep=1&token=" + natTestForeignJWTValue}, + 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) + 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})