mirror of
https://github.com/Buriburizaem0n/nezha_domains.git
synced 2026-11-04 16:40:21 +00:00
309 lines
11 KiB
Go
309 lines
11 KiB
Go
package rpc
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
// 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"
|
|
// 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 (
|
|
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 classifier
|
|
// used by the NAT ingress. main() injects
|
|
// 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(valueClass func(ctx context.Context, values []string) []bool) {
|
|
natDashboardCredentialGateMu.Lock()
|
|
natDashboardCredentialValueGate = valueClass
|
|
natDashboardCredentialGateMu.Unlock()
|
|
}
|
|
|
|
// NATDashboardCredentialGateWired reports whether the dashboard-credential
|
|
// classifier has been wired. It exists so the main() wiring contract can be
|
|
// pinned by a test.
|
|
func NATDashboardCredentialGateWired() bool {
|
|
natDashboardCredentialGateMu.RLock()
|
|
wired := natDashboardCredentialValueGate != nil
|
|
natDashboardCredentialGateMu.RUnlock()
|
|
return wired
|
|
}
|
|
|
|
// 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) {
|
|
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, 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")
|
|
return
|
|
}
|
|
// A multi-valued Authorization header is malformed traffic, but any
|
|
// 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") {
|
|
raw := strings.TrimSpace(value)
|
|
if strings.HasPrefix(raw, "Bearer ") && classification.isDashboardCredential(strings.TrimPrefix(raw, "Bearer ")) {
|
|
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, classification natDashboardCredentialClassification) {
|
|
if request.URL == nil || request.URL.RawQuery == "" {
|
|
return
|
|
}
|
|
elements := strings.Split(request.URL.RawQuery, "&")
|
|
kept := elements[:0]
|
|
changed := false
|
|
for _, element := range elements {
|
|
if !classification.wired && dashboardCredentialQueryElementName(element) {
|
|
changed = true
|
|
continue
|
|
}
|
|
value, candidate := dashboardCredentialQueryElementValue(element)
|
|
if candidate && classification.isDashboardCredential(value) {
|
|
changed = true
|
|
continue
|
|
}
|
|
kept = append(kept, element)
|
|
}
|
|
if changed {
|
|
request.URL.RawQuery = strings.Join(kept, "&")
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
// 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 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, classification natDashboardCredentialClassification) {
|
|
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, classification)
|
|
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, classification natDashboardCredentialClassification) (string, bool) {
|
|
pairs := strings.Split(line, ";")
|
|
kept := pairs[:0]
|
|
droppedBeforeFirstKept := true
|
|
changed := false
|
|
for _, pair := range pairs {
|
|
if !classification.wired && dashboardCredentialCookiePairName(pair) {
|
|
changed = true
|
|
continue
|
|
}
|
|
value, candidate := dashboardCredentialCookiePairValue(pair)
|
|
if candidate && classification.isDashboardCredential(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
|
|
}
|
|
|
|
// 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), `"`)
|
|
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
|
|
}
|