mirror of
https://github.com/Buriburizaem0n/nezha_domains.git
synced 2026-11-04 16:40:21 +00:00
Since 2640b86 (v2.2.11, 2026-07-20) the NAT ingress deleted the entire
Authorization header, breaking self-hosted apps behind the tunnel that
authenticate with their own Bearer tokens (e.g. the Qinglong panel login
loop).
Strip only credentials issued by this dashboard and forward foreign ones:
- controller.IsDashboardCredential classifies an Authorization value as
dashboard-issued via a signature-only JWT parser derived from the auth
middleware (same key, HS256 pinned, claims validation deliberately
skipped so an expired panel token is still recognized and kept out of
the tunnel) or an api_tokens lookup for PAT-shaped values. Unknown PATs,
foreign signatures, other schemes and missing values pass through. When
classification cannot complete (parser not ready, DB nil or error) the
verdict fails closed (strip) and the log carries a fixed reason only,
never credential content.
- Both NAT capability variants (default, and agent-compat legacy /
invalid-capability / post-consume paths) replace the unconditional
Del("Authorization") with stripDashboardCredential. The rpc package
receives the classifier through SetNATDashboardCredentialGate, the same
injection pattern as SetMCPKillSwitchObserver; an unwired gate keeps the
historical strip-everything behavior.
Tests: controller classification semantics (panel JWT, expired panel JWT
and panel PAT stripped; foreign-signed JWT, unknown nzp_ token, random
strings, non-Bearer schemes and missing header pass through; parser
unavailable, DB nil and DB error fail closed with credential-free logs)
plus rpc unit and flow coverage for both variants (dashboard credentials
never reach the forwarded bytes; foreign and missing Authorization are
forwarded untouched).
Compatibility: no API, database or frontend changes; NAT backends that do
not use Authorization behave exactly as before.
105 lines
4.3 KiB
Go
105 lines
4.3 KiB
Go
package controller
|
|
|
|
import (
|
|
"errors"
|
|
"log"
|
|
"strings"
|
|
|
|
jwt "github.com/golang-jwt/jwt/v4"
|
|
"gorm.io/gorm"
|
|
|
|
ginjwt "github.com/appleboy/gin-jwt/v2"
|
|
"github.com/nezhahq/nezha/model"
|
|
"github.com/nezhahq/nezha/service/singleton"
|
|
)
|
|
|
|
// 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.
|
|
var dashboardCredentialJWTParser *ginjwt.GinJWTMiddleware
|
|
|
|
// newDashboardCredentialJWTParser derives a signature-only parser from the
|
|
// dashboard auth middleware. It keeps the exact key value and the pinned HS256
|
|
// signing algorithm of initParams() — there is no second read of
|
|
// singleton.Conf.JWTSecretKey — and disables claims validation so expiry does
|
|
// not change the verdict: an expired token is still proof the dashboard issued
|
|
// it, and keeping expired dashboard tokens out of the NAT tunnel prevents a
|
|
// careless backend from treating "any JWT" as its own credential. The live
|
|
// auth middleware keeps full claims validation; dashboard authentication is
|
|
// not relaxed by this parser in any way.
|
|
func newDashboardCredentialJWTParser(authMiddleware *ginjwt.GinJWTMiddleware) *ginjwt.GinJWTMiddleware {
|
|
parser := *authMiddleware
|
|
parser.ParseOptions = []jwt.ParserOption{jwt.WithoutClaimsValidation()}
|
|
return &parser
|
|
}
|
|
|
|
// IsDashboardCredential reports whether an Authorization header value was
|
|
// issued by this dashboard: a panel-signed JWT or a panel API token (PAT).
|
|
//
|
|
// NAT ingress uses this to enforce the tunnel credential invariant: values
|
|
// classified as dashboard-issued are stripped before the request is tunneled
|
|
// to the agent, while foreign credentials are forwarded untouched because the
|
|
// NAT backend may authenticate with credentials of its own.
|
|
//
|
|
// Decision policy mirrors the dashboard's own auth middlewares: only the
|
|
// case-sensitive "Bearer " scheme is considered; PAT-shaped values are
|
|
// resolved against the api_tokens table; everything else is treated as a JWT.
|
|
// Whenever the classification cannot be completed (parser or config not ready,
|
|
// database failure) the value is conservatively reported as a dashboard
|
|
// credential so the caller strips it; only a deterministic proof that the
|
|
// value was not issued by this dashboard (scheme mismatch, unknown PAT hash,
|
|
// failed signature verification) returns false.
|
|
func IsDashboardCredential(authz string) bool {
|
|
raw := strings.TrimSpace(authz)
|
|
if !strings.HasPrefix(raw, "Bearer ") {
|
|
return false
|
|
}
|
|
plaintext := strings.TrimSpace(strings.TrimPrefix(raw, "Bearer "))
|
|
if strings.HasPrefix(plaintext, model.APITokenPrefix) {
|
|
return isDashboardAPIToken(plaintext)
|
|
}
|
|
return isDashboardSignedJWT(plaintext)
|
|
}
|
|
|
|
// 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 {
|
|
if singleton.DB == nil {
|
|
logDashboardCredentialUnclassifiable("api token lookup unavailable")
|
|
return 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) {
|
|
// Deterministic proof the token was not issued by this dashboard:
|
|
// forwarding it leaks nothing the dashboard ever validated.
|
|
return false
|
|
}
|
|
logDashboardCredentialUnclassifiable("api token lookup failed")
|
|
return true
|
|
}
|
|
|
|
// isDashboardSignedJWT verifies the token signature with the dashboard's
|
|
// pinned algorithm and key. Claims validation is disabled on purpose (see
|
|
// newDashboardCredentialJWTParser): expiry does not change who issued the
|
|
// token.
|
|
func isDashboardSignedJWT(token string) bool {
|
|
if dashboardCredentialJWTParser == nil {
|
|
// routers() has not run yet (startup incomplete): fail closed.
|
|
logDashboardCredentialUnclassifiable("dashboard credential parser unavailable")
|
|
return true
|
|
}
|
|
_, err := dashboardCredentialJWTParser.ParseTokenString(token)
|
|
return err == nil
|
|
}
|
|
|
|
// logDashboardCredentialUnclassifiable records a classification failure. The
|
|
// Authorization 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)
|
|
}
|