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,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)
|
||||
}
|
||||
Reference in new issue
Block a user