mirror of
https://github.com/Buriburizaem0n/nezha_domains.git
synced 2026-11-05 00:50:21 +00:00
fix(nat): bound dashboard credential classification
This commit is contained in:
1 parent
34ac50394d
commit
1420dd450c
7 files changed
+334
-91
No files matched your search
@@ -1,12 +1,13 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"context"
|
||||||
"log"
|
"log"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
jwt "github.com/golang-jwt/jwt/v4"
|
jwt "github.com/golang-jwt/jwt/v4"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
|
||||||
ginjwt "github.com/appleboy/gin-jwt/v2"
|
ginjwt "github.com/appleboy/gin-jwt/v2"
|
||||||
"github.com/nezhahq/nezha/model"
|
"github.com/nezhahq/nezha/model"
|
||||||
@@ -15,8 +16,8 @@ import (
|
|||||||
|
|
||||||
// dashboardCredentialJWTParser verifies that a JWT was signed by this
|
// dashboardCredentialJWTParser verifies that a JWT was signed by this
|
||||||
// dashboard. It is derived from the auth middleware created in routers() and
|
// dashboard. It is derived from the auth middleware created in routers() and
|
||||||
// exists only for IsDashboardCredential; it must never authenticate dashboard
|
// exists only for dashboard-credential classification; it must never
|
||||||
// requests on its own.
|
// authenticate dashboard requests on its own.
|
||||||
var dashboardCredentialJWTParser *ginjwt.GinJWTMiddleware
|
var dashboardCredentialJWTParser *ginjwt.GinJWTMiddleware
|
||||||
|
|
||||||
// newDashboardCredentialJWTParser derives a signature-only parser from the
|
// 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)
|
// issued by this dashboard (unknown PAT hash, failed signature verification)
|
||||||
// returns false.
|
// returns false.
|
||||||
func IsDashboardCredentialValue(value string) bool {
|
func IsDashboardCredentialValue(value string) bool {
|
||||||
plaintext := strings.TrimSpace(value)
|
classified := ClassifyDashboardCredentialValues(context.Background(), []string{value})
|
||||||
if strings.HasPrefix(plaintext, model.APITokenPrefix) {
|
if len(classified) != 1 {
|
||||||
return isDashboardAPIToken(plaintext)
|
return true
|
||||||
}
|
}
|
||||||
return isDashboardSignedJWT(plaintext)
|
return classified[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
// isDashboardAPIToken resolves a PAT-shaped value against the api_tokens
|
// ClassifyDashboardCredentialValues classifies a bounded request batch. JWTs
|
||||||
// table. The plaintext token is only ever hashed here; it is never logged.
|
// are checked independently, while all PAT-shaped values are hashed and
|
||||||
func isDashboardAPIToken(plaintext string) bool {
|
// 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 {
|
if singleton.DB == nil {
|
||||||
logDashboardCredentialUnclassifiable("api token lookup unavailable")
|
logDashboardCredentialUnclassifiable("api token lookup unavailable")
|
||||||
return true
|
for _, indexes := range patIndexes {
|
||||||
|
for _, i := range indexes {
|
||||||
|
results[i] = true
|
||||||
}
|
}
|
||||||
var tok model.APIToken
|
|
||||||
err := singleton.DB.Where("token_hash = ?", model.HashAPIToken(plaintext)).First(&tok).Error
|
|
||||||
if err == nil {
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
return results
|
||||||
// Deterministic proof the token was not issued by this dashboard:
|
|
||||||
// forwarding it leaks nothing the dashboard ever validated.
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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")
|
logDashboardCredentialUnclassifiable("api token lookup failed")
|
||||||
return true
|
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
|
// isDashboardSignedJWT verifies the token signature with the dashboard's
|
||||||
@@ -112,7 +156,7 @@ func isDashboardSignedJWT(token string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// logDashboardCredentialUnclassifiable records a classification failure. The
|
// 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) {
|
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)
|
||||||
}
|
}
|
||||||
@@ -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
|
// Wire the gate exactly as cmd/dashboard/main.go does, with the real
|
||||||
// classifiers built by setupDashboardCredentialTest.
|
// classifiers built by setupDashboardCredentialTest.
|
||||||
rpc.SetNATDashboardCredentialGate(IsDashboardCredential, IsDashboardCredentialValue)
|
rpc.SetNATDashboardCredentialGate(ClassifyDashboardCredentialValues)
|
||||||
t.Cleanup(func() { rpc.SetNATDashboardCredentialGate(nil, nil) })
|
t.Cleanup(func() { rpc.SetNATDashboardCredentialGate(nil) })
|
||||||
|
|
||||||
return handler, server, taskStream
|
return handler, server, taskStream
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package controller
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"log"
|
"log"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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) {
|
func TestIsDashboardCredentialFailsClosedWhenParserUnavailable(t *testing.T) {
|
||||||
setupDashboardCredentialTest(t)
|
setupDashboardCredentialTest(t)
|
||||||
dashboardCredentialJWTParser = nil
|
dashboardCredentialJWTParser = nil
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// TestWireNATDashboardCredentialGate pins the NAT ingress wiring contract:
|
// TestWireNATDashboardCredentialGate pins the NAT ingress wiring contract:
|
||||||
// main() must connect the real dashboard-credential classifiers
|
// main() must connect the real batched dashboard-credential classifier
|
||||||
// (controller.IsDashboardCredential / controller.IsDashboardCredentialValue)
|
// (controller.ClassifyDashboardCredentialValues)
|
||||||
// through wireNATDashboardCredentialGate before any listener serves traffic.
|
// through wireNATDashboardCredentialGate before any listener serves traffic.
|
||||||
// That the wired functions really classify per channel is pinned end to end
|
// That the wired functions really classify per channel is pinned end to end
|
||||||
// by the controller package's real-classifier NAT flow test.
|
// by the controller package's real-classifier NAT flow test.
|
||||||
|
|||||||
@@ -235,10 +235,10 @@ func main() {
|
|||||||
// TokenLookup accepts — the Authorization header, the ?token= query parameter
|
// TokenLookup accepts — the Authorization header, the ?token= query parameter
|
||||||
// and the nz-jwt cookie — before tunneling a request to the agent, and
|
// and the nz-jwt cookie — before tunneling a request to the agent, and
|
||||||
// forwards foreign values untouched. ServeWeb built the JWT parser the
|
// 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.
|
// listener starts serving.
|
||||||
func wireNATDashboardCredentialGate() {
|
func wireNATDashboardCredentialGate() {
|
||||||
rpc.SetNATDashboardCredentialGate(controller.IsDashboardCredential, controller.IsDashboardCredentialValue)
|
rpc.SetNATDashboardCredentialGate(controller.ClassifyDashboardCredentialValues)
|
||||||
}
|
}
|
||||||
|
|
||||||
func newHTTPandGRPCMux(httpHandler http.Handler, grpcHandler http.Handler) http.Handler {
|
func newHTTPandGRPCMux(httpHandler http.Handler, grpcHandler http.Handler) http.Handler {
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
package rpc
|
package rpc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Channel names of the panel's own TokenLookup (controller/jwt.go
|
// Channel names of the panel's own TokenLookup (controller/jwt.go
|
||||||
@@ -14,37 +16,42 @@ import (
|
|||||||
const (
|
const (
|
||||||
natDashboardJWTCookieName = "nz-jwt"
|
natDashboardJWTCookieName = "nz-jwt"
|
||||||
natDashboardJWTQueryParameter = "token"
|
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 (
|
var (
|
||||||
// natDashboardAuthorizationGate classifies an Authorization header value
|
natDashboardCredentialGateMu sync.RWMutex
|
||||||
// (case-sensitive "Bearer " scheme included) as a credential issued by
|
// natDashboardCredentialValueGate classifies a bounded batch of raw
|
||||||
// this dashboard.
|
// credential values. Batching lets the controller resolve all PAT hashes in
|
||||||
natDashboardAuthorizationGate func(authz string) bool
|
// one query instead of turning attacker-controlled query/cookie fan-out into
|
||||||
// natDashboardCredentialValueGate classifies a raw credential value — the
|
// one database query per value.
|
||||||
// part after the "Bearer " scheme, exactly as the ?token= query parameter
|
natDashboardCredentialValueGate func(ctx context.Context, values []string) []bool
|
||||||
// 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
|
// SetNATDashboardCredentialGate wires the dashboard-credential classifier
|
||||||
// used by the NAT ingress. main() injects
|
// 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
|
// the setter keeps the rpc package free of a dependency on the controller
|
||||||
// package, the same pattern as SetReceiptGateListener and
|
// package, the same pattern as SetReceiptGateListener and
|
||||||
// SetMCPKillSwitchObserver. A nil function fails closed: every value on that
|
// SetMCPKillSwitchObserver. A nil function fails closed: every value on that
|
||||||
// channel is then treated as dashboard-issued and stripped.
|
// channel is then treated as dashboard-issued and stripped.
|
||||||
func SetNATDashboardCredentialGate(authorizationClass func(authz string) bool, valueClass func(value string) bool) {
|
func SetNATDashboardCredentialGate(valueClass func(ctx context.Context, values []string) []bool) {
|
||||||
natDashboardAuthorizationGate = authorizationClass
|
natDashboardCredentialGateMu.Lock()
|
||||||
natDashboardCredentialValueGate = valueClass
|
natDashboardCredentialValueGate = valueClass
|
||||||
|
natDashboardCredentialGateMu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
// NATDashboardCredentialGateWired reports whether the dashboard-credential
|
// 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.
|
// pinned by a test.
|
||||||
func NATDashboardCredentialGateWired() bool {
|
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
|
// 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
|
// backend may authenticate with credentials of its own, and this dashboard
|
||||||
// must not make policy for them.
|
// must not make policy for them.
|
||||||
func stripDashboardCredentials(request *http.Request) {
|
func stripDashboardCredentials(request *http.Request) {
|
||||||
stripDashboardAuthorizationHeader(request)
|
classification := classifyNATDashboardCredentialValues(request)
|
||||||
stripDashboardCredentialQuery(request)
|
stripDashboardAuthorizationHeader(request, classification)
|
||||||
stripDashboardCredentialCookie(request)
|
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
|
// stripDashboardAuthorizationHeader removes the Authorization header when it
|
||||||
// carries a dashboard-issued credential.
|
// carries a dashboard-issued credential.
|
||||||
func stripDashboardAuthorizationHeader(request *http.Request) {
|
func stripDashboardAuthorizationHeader(request *http.Request, classification natDashboardCredentialClassification) {
|
||||||
if natDashboardAuthorizationGate == nil {
|
if !classification.wired {
|
||||||
// Gate not wired (early startup or tests): fail closed and keep the
|
// Gate not wired (early startup or tests): fail closed and keep the
|
||||||
// historical behavior of stripping every Authorization header.
|
// historical behavior of stripping every Authorization header.
|
||||||
request.Header.Del("Authorization")
|
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
|
// dashboard-issued value in it must still not survive into the tunnel, so
|
||||||
// the whole header is dropped when one is recognized.
|
// the whole header is dropped when one is recognized.
|
||||||
for _, value := range request.Header.Values("Authorization") {
|
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")
|
request.Header.Del("Authorization")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -84,7 +180,7 @@ func stripDashboardAuthorizationHeader(request *http.Request) {
|
|||||||
// is a dashboard-issued credential. Every other element of the query string —
|
// is a dashboard-issued credential. Every other element of the query string —
|
||||||
// including foreign token values — is preserved byte-for-byte, so the
|
// including foreign token values — is preserved byte-for-byte, so the
|
||||||
// forwarded request keeps the exact query the client sent.
|
// 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 == "" {
|
if request.URL == nil || request.URL.RawQuery == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -92,7 +188,12 @@ func stripDashboardCredentialQuery(request *http.Request) {
|
|||||||
kept := elements[:0]
|
kept := elements[:0]
|
||||||
changed := false
|
changed := false
|
||||||
for _, element := range elements {
|
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
|
changed = true
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -103,32 +204,35 @@ func stripDashboardCredentialQuery(request *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// isDashboardCredentialQueryElement reports whether a raw query element
|
// dashboardCredentialQueryElementValue extracts the decoded value from a raw
|
||||||
// carries a dashboard-issued credential in the panel's token parameter.
|
// query element carrying the panel's token parameter.
|
||||||
func isDashboardCredentialQueryElement(element string) bool {
|
func dashboardCredentialQueryElementValue(element string) (string, bool) {
|
||||||
rawName, rawValue, _ := strings.Cut(element, "=")
|
rawName, rawValue, _ := strings.Cut(element, "=")
|
||||||
// The panel reads the parameter by its decoded name (gin-jwt c.Query),
|
// The panel reads the parameter by its decoded name (gin-jwt c.Query),
|
||||||
// so an escaped key must be decoded before comparing.
|
// so an escaped key must be decoded before comparing.
|
||||||
name, err := url.QueryUnescape(rawName)
|
name, err := url.QueryUnescape(rawName)
|
||||||
if err != nil || name != natDashboardJWTQueryParameter {
|
if err != nil || name != natDashboardJWTQueryParameter {
|
||||||
return false
|
return "", false
|
||||||
}
|
|
||||||
if natDashboardCredentialValueGate == nil {
|
|
||||||
return true // fail closed
|
|
||||||
}
|
}
|
||||||
// A value that fails to decode is one the panel's own query parsing drops
|
// 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.
|
// as well, so it can never be a panel credential.
|
||||||
decoded, err := url.QueryUnescape(rawValue)
|
decoded, err := url.QueryUnescape(rawValue)
|
||||||
if err != nil {
|
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
|
// stripDashboardCredentialCookie removes nz-jwt cookies whose value is a
|
||||||
// dashboard-issued credential. All other cookie pairs — including foreign
|
// dashboard-issued credential. All other cookie pairs — including foreign
|
||||||
// nz-jwt values — are preserved verbatim.
|
// nz-jwt values — are preserved verbatim.
|
||||||
func stripDashboardCredentialCookie(request *http.Request) {
|
func stripDashboardCredentialCookie(request *http.Request, classification natDashboardCredentialClassification) {
|
||||||
lines := request.Header.Values("Cookie")
|
lines := request.Header.Values("Cookie")
|
||||||
if len(lines) == 0 {
|
if len(lines) == 0 {
|
||||||
return
|
return
|
||||||
@@ -136,7 +240,7 @@ func stripDashboardCredentialCookie(request *http.Request) {
|
|||||||
changed := false
|
changed := false
|
||||||
kept := make([]string, 0, len(lines))
|
kept := make([]string, 0, len(lines))
|
||||||
for _, line := range lines {
|
for _, line := range lines {
|
||||||
nextLine, lineChanged := stripDashboardCredentialCookieLine(line)
|
nextLine, lineChanged := stripDashboardCredentialCookieLine(line, classification)
|
||||||
changed = changed || lineChanged
|
changed = changed || lineChanged
|
||||||
if nextLine != "" {
|
if nextLine != "" {
|
||||||
kept = append(kept, 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,
|
// 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
|
// whitespace included — except that a pair following a dropped one no longer
|
||||||
// carries the removed pair's share of the "; " separator.
|
// 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, ";")
|
pairs := strings.Split(line, ";")
|
||||||
kept := pairs[:0]
|
kept := pairs[:0]
|
||||||
droppedBeforeFirstKept := true
|
droppedBeforeFirstKept := true
|
||||||
changed := false
|
changed := false
|
||||||
for _, pair := range pairs {
|
for _, pair := range pairs {
|
||||||
name, value, _ := strings.Cut(pair, "=")
|
if !classification.wired && dashboardCredentialCookiePairName(pair) {
|
||||||
if strings.TrimSpace(name) == natDashboardJWTCookieName &&
|
changed = true
|
||||||
(natDashboardCredentialValueGate == nil || isDashboardCredentialCookieValue(value)) {
|
continue
|
||||||
|
}
|
||||||
|
value, candidate := dashboardCredentialCookiePairValue(pair)
|
||||||
|
if candidate && classification.isDashboardCredential(value) {
|
||||||
changed = true
|
changed = true
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -179,10 +286,23 @@ func stripDashboardCredentialCookieLine(line string) (string, bool) {
|
|||||||
return strings.Join(kept, ";"), true
|
return strings.Join(kept, ";"), true
|
||||||
}
|
}
|
||||||
|
|
||||||
// isDashboardCredentialCookieValue classifies a raw cookie value the same way
|
// dashboardCredentialCookiePairValue extracts a cookie value the same way the
|
||||||
// the panel's cookie parsing would hand it to the JWT verifier: surrounding
|
// panel hands it to the JWT verifier: surrounding whitespace/quotes are
|
||||||
// whitespace and quoting are presentation, not part of the credential.
|
// presentation, then Gin applies QueryUnescape.
|
||||||
func isDashboardCredentialCookieValue(rawValue string) bool {
|
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), `"`)
|
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
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
package rpc
|
package rpc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/nezhahq/nezha/model"
|
"github.com/nezhahq/nezha/model"
|
||||||
@@ -35,27 +38,21 @@ const (
|
|||||||
// is foreign.
|
// is foreign.
|
||||||
func installNATCredentialTestGate(t *testing.T) {
|
func installNATCredentialTestGate(t *testing.T) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
originalAuthorization := natDashboardAuthorizationGate
|
natDashboardCredentialGateMu.RLock()
|
||||||
originalValue := natDashboardCredentialValueGate
|
original := natDashboardCredentialValueGate
|
||||||
natDashboardAuthorizationGate = func(authz string) bool {
|
natDashboardCredentialGateMu.RUnlock()
|
||||||
switch authz {
|
SetNATDashboardCredentialGate(func(_ context.Context, values []string) []bool {
|
||||||
case natTestDashboardJWT, natTestDashboardExpiredJWT, natTestDashboardAPIToken:
|
results := make([]bool, len(values))
|
||||||
return true
|
for i, value := range values {
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
natDashboardCredentialValueGate = func(value string) bool {
|
|
||||||
switch value {
|
switch value {
|
||||||
case natTestDashboardJWTValue, natTestDashboardExpiredJWTValue, natTestDashboardPATValue:
|
case natTestDashboardJWTValue, natTestDashboardExpiredJWTValue, natTestDashboardPATValue:
|
||||||
return true
|
results[i] = true
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return results
|
||||||
|
})
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
natDashboardAuthorizationGate = originalAuthorization
|
SetNATDashboardCredentialGate(original)
|
||||||
natDashboardCredentialValueGate = originalValue
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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) {
|
func TestPrepareNATCapabilityFailsClosedWithoutGate(t *testing.T) {
|
||||||
// Given — unwired gates: every value on every channel is treated as
|
// Given — unwired gates: every value on every channel is treated as
|
||||||
// dashboard-issued and stripped, foreign ones included.
|
// dashboard-issued and stripped, foreign ones included.
|
||||||
SetNATDashboardCredentialGate(nil, nil)
|
SetNATDashboardCredentialGate(nil)
|
||||||
t.Cleanup(func() { SetNATDashboardCredentialGate(nil, nil) })
|
t.Cleanup(func() { SetNATDashboardCredentialGate(nil) })
|
||||||
request := &http.Request{
|
request := &http.Request{
|
||||||
Header: make(http.Header),
|
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("Authorization", natTestForeignRandom)
|
||||||
request.Header.Set("Cookie", "sid=abc; nz-jwt="+natTestForeignJWTValue)
|
request.Header.Set("Cookie", "sid=abc; nz-jwt="+natTestForeignJWTValue+"; nz-jwt=%ZZ")
|
||||||
|
|
||||||
// When
|
// When
|
||||||
lease, err := prepareNATCapability(request, &model.NAT{Common: model.Common{ID: 91}, ServerID: 81})
|
lease, err := prepareNATCapability(request, &model.NAT{Common: model.Common{ID: 91}, ServerID: 81})
|
||||||
|
|||||||
Reference in new issue
Block a user