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.
205 lines
7.1 KiB
Go
205 lines
7.1 KiB
Go
//go:build agentcompat
|
|
|
|
package rpc
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"log"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/nezhahq/nezha/model"
|
|
"github.com/nezhahq/nezha/pkg/agentcompatcontract"
|
|
serviceRPC "github.com/nezhahq/nezha/service/rpc"
|
|
)
|
|
|
|
func TestPrepareNATCapabilityConsumesAndRemovesHeaderBeforeNATWork(t *testing.T) {
|
|
// Given
|
|
handler := serviceRPC.NewNezhaHandler()
|
|
original := serviceRPC.NezhaHandlerSingleton
|
|
serviceRPC.NezhaHandlerSingleton = handler
|
|
t.Cleanup(func() { serviceRPC.NezhaHandlerSingleton = original })
|
|
request := &http.Request{Header: make(http.Header)}
|
|
request.Header.Set(agentcompatcontract.IOStreamCapabilityHeader, "malformed")
|
|
|
|
// When
|
|
lease, err := prepareNATCapability(request, &model.NAT{Common: model.Common{ID: 91}, ServerID: 81})
|
|
|
|
// Then
|
|
if err == nil {
|
|
t.Fatal("malformed capability unexpectedly accepted")
|
|
}
|
|
if lease.active {
|
|
t.Fatal("malformed capability unexpectedly activated")
|
|
}
|
|
if _, present := request.Header[agentcompatcontract.IOStreamCapabilityHeader]; present {
|
|
t.Fatal("capability header remained after hook")
|
|
}
|
|
}
|
|
|
|
func TestPrepareNATCapabilityRejectsDuplicateHeaderAfterRemovingAllValues(t *testing.T) {
|
|
// Given
|
|
request := &http.Request{Header: make(http.Header)}
|
|
request.Header.Add(agentcompatcontract.IOStreamCapabilityHeader, "one")
|
|
request.Header.Add(agentcompatcontract.IOStreamCapabilityHeader, "two")
|
|
|
|
// When
|
|
lease, err := prepareNATCapability(request, &model.NAT{Common: model.Common{ID: 92}, ServerID: 82})
|
|
|
|
// Then
|
|
if err == nil {
|
|
t.Fatal("duplicate capability header unexpectedly accepted")
|
|
}
|
|
if lease.active {
|
|
t.Fatal("duplicate capability header unexpectedly activated")
|
|
}
|
|
if _, present := request.Header[agentcompatcontract.IOStreamCapabilityHeader]; present {
|
|
t.Fatal("duplicate capability header remained after hook")
|
|
}
|
|
}
|
|
|
|
func TestPrepareNATCapabilityAgentCompatStripsDashboardCredentials(t *testing.T) {
|
|
// Given — legacy path without a capability header, then the invalid
|
|
// capability path: both must drop dashboard-issued Authorization values.
|
|
installNATCredentialTestGate(t)
|
|
for name, credential := range map[string]string{
|
|
"dashboard jwt": natTestDashboardJWT,
|
|
"expired dashboard jwt": natTestDashboardExpiredJWT,
|
|
"dashboard api token": natTestDashboardAPIToken,
|
|
} {
|
|
t.Run(name+"/legacy path", func(t *testing.T) {
|
|
request := &http.Request{Header: make(http.Header)}
|
|
request.Header.Set("Authorization", credential)
|
|
|
|
lease, err := prepareNATCapability(request, &model.NAT{Common: model.Common{ID: 91}, ServerID: 81})
|
|
|
|
if err != nil {
|
|
t.Fatalf("legacy NAT path returned error: %v", err)
|
|
}
|
|
if lease.active {
|
|
t.Fatal("legacy NAT path unexpectedly activated")
|
|
}
|
|
if got := request.Header.Get("Authorization"); got != "" {
|
|
t.Fatalf("legacy NAT path retained dashboard credential %q", got)
|
|
}
|
|
})
|
|
t.Run(name+"/invalid capability path", func(t *testing.T) {
|
|
request := &http.Request{Header: make(http.Header)}
|
|
request.Header.Set(agentcompatcontract.IOStreamCapabilityHeader, "malformed")
|
|
request.Header.Set("Authorization", credential)
|
|
|
|
lease, err := prepareNATCapability(request, &model.NAT{Common: model.Common{ID: 91}, ServerID: 81})
|
|
|
|
if err == nil {
|
|
t.Fatal("malformed capability unexpectedly accepted")
|
|
}
|
|
if lease.active {
|
|
t.Fatal("malformed capability unexpectedly activated")
|
|
}
|
|
if got := request.Header.Get("Authorization"); got != "" {
|
|
t.Fatalf("invalid capability path retained dashboard credential %q", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPrepareNATCapabilityAgentCompatStripsDashboardCredentialAfterConsumingCapability(t *testing.T) {
|
|
// Given — a well-formed capability that consumes successfully: the request
|
|
// is about to be tunneled, so the dashboard credential must be gone.
|
|
installNATCredentialTestGate(t)
|
|
handler := serviceRPC.NewNezhaHandler()
|
|
original := serviceRPC.NezhaHandlerSingleton
|
|
serviceRPC.NezhaHandlerSingleton = handler
|
|
t.Cleanup(func() { serviceRPC.NezhaHandlerSingleton = original })
|
|
|
|
capability, err := handler.RegisterAgentCompatIOStreamCapability(context.Background(), serviceRPC.AgentCompatCapabilityRegistration{
|
|
Owner: serviceRPC.AgentCompatCapabilityOwner{PATID: 1, UserID: 2},
|
|
Purpose: serviceRPC.AgentCompatCapabilityNAT,
|
|
TargetServerID: 81,
|
|
ResourceID: 91,
|
|
ServerAccessAllowed: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("register capability: %v", err)
|
|
}
|
|
request := &http.Request{Header: make(http.Header)}
|
|
request.Header.Set(agentcompatcontract.IOStreamCapabilityHeader, capability.String())
|
|
request.Header.Set("Authorization", natTestDashboardJWT)
|
|
|
|
// When
|
|
lease, err := prepareNATCapability(request, &model.NAT{Common: model.Common{ID: 91}, ServerID: 81})
|
|
|
|
// Then
|
|
if err != nil {
|
|
t.Fatalf("valid NAT capability rejected: %v", err)
|
|
}
|
|
if !lease.active {
|
|
t.Fatal("valid NAT capability did not activate")
|
|
}
|
|
if got := request.Header.Get("Authorization"); got != "" {
|
|
t.Fatalf("dashboard credential retained after capability consumption: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestPrepareNATCapabilityAgentCompatKeepsForeignAndMissingAuthorization(t *testing.T) {
|
|
// Given — legacy path: foreign credentials are ordinary request data and
|
|
// an absent header must stay untouched.
|
|
installNATCredentialTestGate(t)
|
|
for name, values := range map[string][]string{
|
|
"foreign jwt": {natTestForeignJWT},
|
|
"foreign random": {natTestForeignRandom},
|
|
"empty value": {""},
|
|
"missing header": nil,
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
request := &http.Request{Header: make(http.Header)}
|
|
for _, value := range values {
|
|
request.Header.Set("Authorization", value)
|
|
}
|
|
|
|
lease, err := prepareNATCapability(request, &model.NAT{Common: model.Common{ID: 91}, ServerID: 81})
|
|
|
|
if err != nil {
|
|
t.Fatalf("legacy NAT path returned error: %v", err)
|
|
}
|
|
if lease.active {
|
|
t.Fatal("legacy NAT path unexpectedly activated")
|
|
}
|
|
got := request.Header.Values("Authorization")
|
|
if len(got) != len(values) {
|
|
t.Fatalf("legacy NAT path changed Authorization to %q, want %q", got, values)
|
|
}
|
|
for i := range values {
|
|
if got[i] != values[i] {
|
|
t.Fatalf("legacy NAT path changed Authorization to %q, want %q", got, values)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestServeNATAgentCompatSensitiveHeadersStayOutOfErrorsAndLogs(t *testing.T) {
|
|
installNATCredentialTestGate(t)
|
|
request := &http.Request{Header: make(http.Header)}
|
|
request.Header.Set(agentcompatcontract.IOStreamCapabilityHeader, "capability-secret")
|
|
request.Header.Set("Authorization", "Bearer deterministic-secret")
|
|
writer := &serveNATResponseWriter{}
|
|
var logs bytes.Buffer
|
|
originalOutput := log.Writer()
|
|
log.SetOutput(&logs)
|
|
t.Cleanup(func() { log.SetOutput(originalOutput) })
|
|
|
|
ServeNAT(writer, request, &model.NAT{Common: model.Common{ID: 91}, ServerID: 81})
|
|
|
|
if writer.status != http.StatusServiceUnavailable {
|
|
t.Fatalf("status = %d, want %d", writer.status, http.StatusServiceUnavailable)
|
|
}
|
|
for _, output := range []string{writer.body, logs.String()} {
|
|
if strings.Contains(output, "capability-secret") || strings.Contains(output, "deterministic-secret") {
|
|
t.Fatalf("sensitive value leaked in %q", output)
|
|
}
|
|
}
|
|
}
|