Files
nezha_domains/service/rpc/auth_test.go
T

627 lines
26 KiB
Go

package rpc
import (
"context"
"errors"
"testing"
"google.golang.org/grpc/metadata"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/pkg/utils"
"github.com/nezhahq/nezha/service/singleton"
)
// authCheckWithSecret drives (*authHandler).check end-to-end via the same
// gRPC metadata path the real RPC handler uses. Tests rely on it to assert
// what a real reconnect — secret + UUID supplied on the wire — would do.
func authCheckWithSecret(secret, uuid string) (uint64, error) {
ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs(
"client_secret", secret,
"client_uuid", uuid,
))
return (&authHandler{}).Check(ctx)
}
func authCheckWithHyphenatedSecret(secret, uuid string) (uint64, error) {
ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs(
"client-secret", secret,
"client-uuid", uuid,
))
return (&authHandler{}).Check(ctx)
}
// authHandshakeUUID is RFC4122-shaped so it survives the uuid.ParseUUID gate
// at the top of check(); setupAuthAgentFixture's "uuid-alice" / "uuid-bob"
// only work for callers that bypass check() and exercise the inner helpers.
const authHandshakeUUID = "11111111-1111-1111-1111-111111111111"
// setupAuthHandshakeFixture seeds a single server (id=11, owner=user 100,
// real UUID) plus the user-secret tables so the global-secret fall-through
// in check() has something to match. Mirrors setupAuthAgentFixture's reset
// discipline but additionally restores AgentSecretToUserId / UserInfoMap.
func setupAuthHandshakeFixture(t *testing.T) func() {
t.Helper()
originalDB := singleton.DB
originalServerShared := singleton.ServerShared
originalServerTransferShared := singleton.ServerTransferShared
originalUserInfoMap := singleton.UserInfoMap
originalAgentSecretToUserId := singleton.AgentSecretToUserId
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(&model.Server{}, &model.ServerTransfer{}, &model.WAF{}); err != nil {
t.Fatalf("migrate: %v", err)
}
if err := db.Create(&model.Server{
Common: model.Common{ID: 11, UserID: 100},
UUID: authHandshakeUUID,
Name: "handshake-srv",
}).Error; err != nil {
t.Fatalf("create handshake server: %v", err)
}
singleton.DB = db
singleton.ServerShared = singleton.NewServerClass()
srv := &model.Server{Common: model.Common{ID: 11, UserID: 100}, UUID: authHandshakeUUID, Name: "handshake-srv"}
model.InitServer(srv)
singleton.ServerShared.Update(srv, authHandshakeUUID)
singleton.ServerTransferShared = singleton.NewServerTransferClass()
singleton.UserLock.Lock()
singleton.UserInfoMap = map[uint64]model.UserInfo{
100: {Role: model.RoleMember, AgentSecret: "alice-global"},
200: {Role: model.RoleMember, AgentSecret: "bob-global"},
}
singleton.AgentSecretToUserId = map[string]uint64{
"alice-global": 100,
"bob-global": 200,
}
singleton.UserLock.Unlock()
return func() {
if singleton.ServerTransferShared != nil {
singleton.ServerTransferShared.Stop()
}
singleton.DB = originalDB
singleton.ServerShared = originalServerShared
singleton.ServerTransferShared = originalServerTransferShared
singleton.UserLock.Lock()
singleton.UserInfoMap = originalUserInfoMap
singleton.AgentSecretToUserId = originalAgentSecretToUserId
singleton.UserLock.Unlock()
}
}
func TestAuthCheckAcceptsHyphenatedMetadata(t *testing.T) {
defer setupAuthHandshakeFixture(t)()
cid, err := authCheckWithHyphenatedSecret("alice-global", authHandshakeUUID)
if err != nil {
t.Fatalf("hyphenated metadata must authenticate: %v", err)
}
if cid != 11 {
t.Fatalf("expected server ID 11, got %d", cid)
}
}
// setupAuthAgentFixture seeds an in-memory DB and ServerShared with two
// servers belonging to different users so we can assert that a secret bound
// to user A cannot resolve a server UUID owned by user B.
func setupAuthAgentFixture(t *testing.T) func() {
t.Helper()
originalDB := singleton.DB
originalServerShared := singleton.ServerShared
originalServerTransferShared := singleton.ServerTransferShared
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(&model.Server{}, &model.ServerTransfer{}); err != nil {
t.Fatalf("migrate: %v", err)
}
if err := db.Create(&model.Server{
Common: model.Common{ID: 1, UserID: 100},
UUID: "uuid-alice",
Name: "alice-srv",
}).Error; err != nil {
t.Fatalf("create alice: %v", err)
}
if err := db.Create(&model.Server{
Common: model.Common{ID: 2, UserID: 200},
UUID: "uuid-bob",
Name: "bob-srv",
}).Error; err != nil {
t.Fatalf("create bob: %v", err)
}
singleton.DB = db
singleton.ServerShared = singleton.NewServerClass()
singleton.ServerTransferShared = singleton.NewServerTransferClass()
return func() {
if singleton.ServerTransferShared != nil {
singleton.ServerTransferShared.Stop()
}
singleton.DB = originalDB
singleton.ServerShared = originalServerShared
singleton.ServerTransferShared = originalServerTransferShared
}
}
func TestAuthorizeAgentForUUIDAcceptsOwnedServer(t *testing.T) {
defer setupAuthAgentFixture(t)()
cid, hasID, err := authorizeAgentForUUID(100, "uuid-alice")
if err != nil {
t.Fatalf("alice with her own server UUID must not error, got %v", err)
}
if !hasID || cid != 1 {
t.Fatalf("expected (cid=1, hasID=true), got (cid=%d, hasID=%v)", cid, hasID)
}
}
// Core regression: an agent presenting user A's secret but user B's server
// UUID must be rejected. Previously the code returned the resolved server ID
// without verifying the UserID matched the secret owner, allowing same-tenant
// (and worse — cross-tenant if UUID leaks) server impersonation.
func TestAuthorizeAgentForUUIDRejectsForeignServerUUID(t *testing.T) {
defer setupAuthAgentFixture(t)()
_, _, err := authorizeAgentForUUID(100, "uuid-bob") // alice's secret + bob's UUID
if err == nil {
t.Fatalf("UUID owned by another user must be rejected")
}
}
func TestAuthorizeAgentForUUIDAllowsGlobalDefaultSecret(t *testing.T) {
defer setupAuthAgentFixture(t)()
cid, hasID, err := authorizeAgentForUUID(0, "uuid-bob")
if err != nil {
t.Fatalf("global default secret must be allowed to use existing UUIDs, got %v", err)
}
if !hasID || cid != 2 {
t.Fatalf("expected (cid=2, hasID=true), got (cid=%d, hasID=%v)", cid, hasID)
}
}
// An unknown UUID must NOT be treated as an impersonation attempt — it is
// the normal first-time registration path and the caller (Check) creates a
// new server bound to the secret owner.
func TestAuthorizeAgentForUUIDPermitsUnknownUUIDForRegistration(t *testing.T) {
defer setupAuthAgentFixture(t)()
cid, hasID, err := authorizeAgentForUUID(100, "uuid-never-seen-before")
if err != nil {
t.Fatalf("unknown UUID must be permitted for new registration, got %v", err)
}
if hasID {
t.Fatalf("hasID must be false for unknown UUID, got cid=%d", cid)
}
}
// initiatePendingTransfer mirrors the controller flow used by the batch-move
// endpoint to drive ownership through ServerTransferShared. Tests use it to
// set up the auth-tolerance window with the Server row already flipped to
// ToUserID. Returns nothing; callers use ServerTransferShared.LookupPending
// to fetch the row if they need it.
func initiatePendingTransfer(t *testing.T, serverID, fromUserID, toUserID uint64) {
t.Helper()
var created *model.ServerTransfer
err := singleton.DB.Transaction(func(tx *gorm.DB) error {
var err error
created, err = singleton.ServerTransferShared.Initiate(tx, serverID, fromUserID, toUserID, fromUserID)
return err
})
if err != nil {
t.Fatalf("initiate pending transfer: %v", err)
}
singleton.ServerTransferShared.Register(created)
}
// The auth-tolerance window: while a Pending transfer exists for this server,
// the old owner's AgentSecret must still authenticate this UUID — the agent
// hasn't received the new secret yet via ApplyConfig. Without this, every
// in-flight transfer would knock the affected agent offline immediately.
func TestAuthorizeAgentForUUIDAcceptsFromUserDuringPendingTransfer(t *testing.T) {
defer setupAuthAgentFixture(t)()
// Alice initiates: server 1 moves from alice (100) to bob (200).
// Server.UserID is now 200; alice's agent still presents secret==100.
initiatePendingTransfer(t, 1, 100, 200)
cid, hasID, err := authorizeAgentForUUID(100, "uuid-alice")
if err != nil {
t.Fatalf("FromUserID secret must be accepted during pending window, got %v", err)
}
if !hasID || cid != 1 {
t.Fatalf("expected (cid=1, hasID=true), got (cid=%d, hasID=%v)", cid, hasID)
}
}
// Tolerance is narrowly scoped: an unrelated user's secret must NOT be
// accepted just because *some* transfer is in flight. Specifically, only
// secrets matching FromUserID or ToUserID get through.
func TestAuthorizeAgentForUUIDRejectsThirdPartyDuringPendingTransfer(t *testing.T) {
defer setupAuthAgentFixture(t)()
initiatePendingTransfer(t, 1, 100, 200)
// userId=999 has nothing to do with this transfer.
_, _, err := authorizeAgentForUUID(999, "uuid-alice")
if err == nil {
t.Fatalf("third-party secret must be rejected even while a transfer is pending")
}
}
// SECURITY: during a Pending transfer the destination user's user-global
// AgentSecret must NOT close the pending window. PushIfOnline only delivers
// the per-transfer HandshakeSecret on the wire, so a reconnect under the
// destination user's global AgentSecret is not proof of agent rotation —
// it could just be the destination user authenticating with their own
// secret + the now-visible Server.UUID. Reject it; only the per-transfer
// HandshakeSecret path may promote to Verified.
func TestAuthorizeAgentForUUIDRejectsToUserGlobalSecretDuringPendingTransfer(t *testing.T) {
defer setupAuthAgentFixture(t)()
initiatePendingTransfer(t, 1, 100, 200)
if _, _, err := authorizeAgentForUUID(200, "uuid-alice"); err == nil {
t.Fatal("destination user's global AgentSecret must NOT authenticate during pending transfer; only per-transfer HandshakeSecret may close the window")
}
if !singleton.ServerTransferShared.HasPending(1) {
t.Fatal("pending transfer must survive a destination-user global AgentSecret reconnect")
}
if _, _, err := authorizeAgentForUUID(100, "uuid-alice"); err != nil {
t.Fatalf("FromUser tolerance window must remain open while transfer is still Pending, got %v", err)
}
}
// During the revert recovery window the destination user's global
// AgentSecret must NOT be accepted by authorizeAgentForUUID. PushIfOnline
// only delivers per-transfer HandshakeSecret / RevertHandshakeSecret on
// the wire, so a reconnect under the ToUserID global secret cannot come
// from the real agent — it can only come from the destination user
// themselves, who can see Server.UUID and would otherwise impersonate the
// agent during rollback, trigger pushRevertIfOnline to leak
// RevertHandshakeSecret, and get promoted via MarkRevertDelivered.
// Legitimate recovery goes through FromUserID's global secret, the
// forward HandshakeSecret, or the RevertHandshakeSecret.
func TestAuthorizeAgentForUUIDRejectsToUserGlobalSecretDuringRevertRecovery(t *testing.T) {
defer setupAuthAgentFixture(t)()
initiatePendingTransfer(t, 1, 100, 200)
pending, ok := singleton.ServerTransferShared.LookupPending(1)
if !ok {
t.Fatal("expected pending transfer")
}
if _, err := singleton.ServerTransferShared.Cancel(pending.ID); err != nil {
t.Fatalf("cancel transfer: %v", err)
}
if _, _, err := authorizeAgentForUUID(200, "uuid-alice"); err == nil {
t.Fatal("destination user's global AgentSecret must NOT authenticate during revert recovery; only per-transfer HandshakeSecret / RevertHandshakeSecret may close the window")
}
if _, ok := singleton.ServerTransferShared.LookupRevertDelivery(1); !ok {
t.Fatal("rejected ToUserID auth must not consume the revert delivery — the real agent still needs it for the eventual per-transfer recovery")
}
}
// Regression for finding A: after MarkVerified deletes the pending entry,
// the agent's persisted ClientSecret is still the per-transfer
// HandshakeSecret (PushIfOnline only ever delivered that value). The very
// next reconnect — gRPC stream drop, agent restart, network blip — must
// keep authenticating, otherwise the agent silently locks itself out on
// the now-orphaned handshake token. There is no follow-up ApplyConfig
// path that swaps the agent over to the destination user's stable
// AgentSecret, so auth itself has to keep treating the post-Verified
// HandshakeSecret as a valid credential for that server.
func TestAuthHandshakeSecretStillAuthenticatesAfterMarkVerified(t *testing.T) {
defer setupAuthHandshakeFixture(t)()
initiatePendingTransfer(t, 11, 100, 200)
pending, ok := singleton.ServerTransferShared.LookupPending(11)
if !ok {
t.Fatal("expected pending transfer")
}
handshakeSecret := pending.HandshakeSecret
if handshakeSecret == "" {
t.Fatal("precondition: pending transfer must carry a HandshakeSecret")
}
cid, err := authCheckWithSecret(handshakeSecret, authHandshakeUUID)
if err != nil {
t.Fatalf("first reconnect with HandshakeSecret must promote the transfer, got %v", err)
}
if cid != 11 {
t.Fatalf("first reconnect must resolve to server 11, got %d", cid)
}
if singleton.ServerTransferShared.HasPending(11) {
t.Fatal("MarkVerified must have cleared the pending index after the handshake reconnect")
}
if _, err := authCheckWithSecret(handshakeSecret, authHandshakeUUID); err != nil {
t.Fatalf("second reconnect with the same HandshakeSecret must still authenticate (the agent has no other credential to present until a final hand-off completes); got %v", err)
}
}
// First successful auth with RevertHandshakeSecret proves the agent has
// applied the rollback (10s reload + applyPendingReload have committed
// the secret to disk). At that point the auth path must promote the
// secret into the long-term verifiedHandshakes map and consume the
// temporary revertDeliveries entry — otherwise the only acceptance path
// is LookupByRevertHandshakeSecret, which prunes after
// defaultRevertDeliveryRecoveryWindow and leaves the agent locked out
// ~24h later. See ServerTransferClass.MarkRevertDelivered.
func TestAuthRevertHandshakeSecretPromotesToVerifiedAndKeepsAuthenticating(t *testing.T) {
defer setupAuthHandshakeFixture(t)()
initiatePendingTransfer(t, 11, 100, 200)
pending, ok := singleton.ServerTransferShared.LookupPending(11)
if !ok {
t.Fatal("expected pending transfer")
}
if _, err := singleton.ServerTransferShared.Cancel(pending.ID); err != nil {
t.Fatalf("cancel transfer to register a revert delivery: %v", err)
}
revert, ok := singleton.ServerTransferShared.LookupRevertDelivery(11)
if !ok {
t.Fatal("precondition: cancel must have registered a revert delivery")
}
revertHandshake := revert.RevertHandshakeSecret
if revertHandshake == "" {
t.Fatal("precondition: revert delivery must carry a RevertHandshakeSecret")
}
if _, err := authCheckWithSecret(revertHandshake, authHandshakeUUID); err != nil {
t.Fatalf("first auth with RevertHandshakeSecret must succeed, got %v", err)
}
if _, ok := singleton.ServerTransferShared.LookupRevertDelivery(11); ok {
t.Fatal("first successful auth must consume the temporary revertDelivery — the credential is now promoted to the long-term map")
}
sid, ok := singleton.ServerTransferShared.LookupServerByVerifiedHandshakeSecret(revertHandshake)
if !ok || sid != 11 {
t.Fatalf("RevertHandshakeSecret must be promoted into verifiedHandshakes; lookup got (sid=%d, ok=%v)", sid, ok)
}
if _, err := authCheckWithSecret(revertHandshake, authHandshakeUUID); err != nil {
t.Fatalf("second auth via the promoted verifiedHandshakes path must still succeed, got %v", err)
}
}
// HIGH security regression: if a transfer has already been Cancelled/Failed/
// Timed out, its HandshakeSecret must NEVER authenticate. Today auth.check
// calls MarkVerified on the lookup result and treats RowsAffected==0 as
// success, so an attacker who learned the per-transfer HandshakeSecret
// (e.g. previous owner whose stream was hijacked during Pending) can
// authenticate inside the narrow race window where revertTransition has
// changed DB status but not yet deleted the in-memory pending entry, or
// after that window simply because the swallowed return is `return
// t.ServerID, nil`.
//
// Expected: when the transfer row is no longer Pending, auth must reject
// the HandshakeSecret entirely.
func TestAuthHandshakeSecretRejectedAfterTransferTerminated(t *testing.T) {
defer setupAuthHandshakeFixture(t)()
initiatePendingTransfer(t, 11, 100, 200)
pending, ok := singleton.ServerTransferShared.LookupPending(11)
if !ok {
t.Fatal("expected pending transfer")
}
handshakeSecret := pending.HandshakeSecret
// Settle the DB row to Cancelled WITHOUT touching the in-memory
// pending entry. This reproduces the race window in revertTransition
// between the DB CAS and the c.mu.Lock that deletes the pending
// entry; LookupByHandshakeSecret still hits.
if err := singleton.DB.Model(&model.ServerTransfer{}).
Where("id = ?", pending.ID).
Update("status", model.ServerTransferStatusCancelled).Error; err != nil {
t.Fatalf("simulate concurrent cancel: %v", err)
}
_, err := authCheckWithSecret(handshakeSecret, authHandshakeUUID)
if err == nil {
t.Fatal("HandshakeSecret on a terminated transfer must be rejected — auth swallowed MarkVerified RowsAffected==0 and returned success, enabling auth bypass with a stale per-transfer secret")
}
}
// HIGH security regression: the auth tolerance window for the old owner's
// global AgentSecret must close in lockstep with MarkVerified. Holding c.mu
// across the DB CAS, the c.pending delete and the verifiedHandshakes write
// inside MarkVerified makes those three steps a single observable event for
// any auth-path lookup taking c.mu.RLock; once MarkVerified returns
// verified=true, no later authorizeAgentForUUID can still see the pending
// entry that previously admitted FromUserID.
func TestAuthOldOwnerSecretRejectedOnceTransferIsVerifiedInDB(t *testing.T) {
defer setupAuthHandshakeFixture(t)()
initiatePendingTransfer(t, 11, 100, 200)
pending, ok := singleton.ServerTransferShared.LookupPending(11)
if !ok {
t.Fatal("expected pending transfer")
}
verified, _, err := singleton.ServerTransferShared.MarkVerified(11, pending.ID)
if err != nil {
t.Fatalf("MarkVerified must succeed for a fresh pending: %v", err)
}
if !verified {
t.Fatal("MarkVerified must report verified=true for a fresh pending")
}
if _, _, err := authorizeAgentForUUID(100, authHandshakeUUID); err == nil {
t.Fatal("old owner's global AgentSecret must be rejected once MarkVerified has returned — the auth tolerance window must not outlive the verified transition")
}
}
// FORWARD-RECOVERY (HIGH): symmetric to TestAuthHandshakeSecretRejectedAfter
// TransferTerminated. That test pokes the DB directly to simulate an
// attacker who learned the per-transfer forward HandshakeSecret outside
// of any dashboard-driven cancellation; auth must reject. This test
// exercises the OTHER scenario: a legitimate agent that already wrote the
// forward HandshakeSecret to disk via the 10s reload timer, and the
// dashboard cancels the transfer via the normal Cancel API (which goes
// through revertTransition). The agent's next reconnect presents the
// forward HandshakeSecret. Auth must authenticate it so RequestTask can
// run OnAgentReconnect and push the RevertHandshakeSecret rollback —
// otherwise the agent is permanently locked out and the operator has to
// SSH in and edit the config by hand.
//
// The distinguishing signal is whether revertTransition was the one that
// settled the row: it populates terminalForwardRecovery; a direct DB
// poke does not.
func TestAuthForwardHandshakeSecretAcceptedAfterDashboardCancel(t *testing.T) {
defer setupAuthHandshakeFixture(t)()
initiatePendingTransfer(t, 11, 100, 200)
pending, ok := singleton.ServerTransferShared.LookupPending(11)
if !ok {
t.Fatal("expected pending transfer")
}
forward := pending.HandshakeSecret
if _, err := singleton.ServerTransferShared.Cancel(pending.ID); err != nil {
t.Fatalf("dashboard Cancel must succeed: %v", err)
}
cid, err := authCheckWithSecret(forward, authHandshakeUUID)
if err != nil {
t.Fatalf("forward HandshakeSecret must authenticate after dashboard Cancel so RequestTask can deliver the rollback; got %v", err)
}
if cid != 11 {
t.Fatalf("forward HandshakeSecret must resolve to its bound server, got cid=%d", cid)
}
if _, ok := singleton.ServerTransferShared.LookupServerByVerifiedHandshakeSecret(forward); ok {
t.Fatal("forward HandshakeSecret on a terminated transfer must NOT be promoted into verifiedHandshakes — promotion would outlive the bounded recovery window and turn a cancelled credential into a permanent one")
}
}
// wafAgentAuthFailCount returns the recorded WAF count for the given IP +
// gRPC block identifier. Used by the bad-credential WAF tests to assert
// FirstOrCreate / UPDATE actually fired.
func wafAgentAuthFailCount(t *testing.T, ip string) uint64 {
t.Helper()
bin, err := utils.IPStringToBinary(ip)
if err != nil {
t.Fatalf("ip parse: %v", err)
}
var w model.WAF
res := singleton.DB.Where("ip = ? AND block_identifier = ?", bin, model.BlockIDgRPC).First(&w)
if res.Error != nil {
if errors.Is(res.Error, gorm.ErrRecordNotFound) {
return 0
}
t.Fatalf("query waf: %v", res.Error)
}
return w.Count
}
// authCheckFromIP feeds an attacker IP through the real Check entry point
// so the WAF BlockIP path observes a non-empty CtxKeyRealIP. authCheckWithSecret
// uses a bare context.Background which keeps the IP empty and short-circuits
// BlockIP(ip == ""), masking the very regression these tests want to pin.
func authCheckFromIP(secret, uuid, ip string) (uint64, error) {
ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs(
"client_secret", secret,
"client_uuid", uuid,
))
ctx = context.WithValue(ctx, model.CtxKeyRealIP{}, ip)
return (&authHandler{}).Check(ctx)
}
// REGRESSION: the new per-transfer handshake path moved the client_uuid
// validation in front of the global AgentSecretToUserId lookup. A bad
// secret paired with a malformed/missing UUID now short-circuits to
// "客户端 UUID 不合法" and skips the BlockIP(WAFBlockReasonTypeAgentAuthFail)
// counter the previous implementation incremented. That counter is the
// only thing throttling brute-force on agent secrets — losing it lets an
// attacker enumerate secrets indefinitely just by also corrupting the
// UUID metadata. Both the missing-secret and bad-secret cases must still
// count toward AgentAuthFail when the UUID is unusable.
func TestAuthBadSecretInvalidUUIDStillIncrementsAgentAuthFailWAF(t *testing.T) {
defer setupAuthHandshakeFixture(t)()
const attackerIP = "203.0.113.7"
if _, err := authCheckFromIP("definitely-not-a-real-secret", "not-a-uuid", attackerIP); err == nil {
t.Fatal("Check must reject bogus credentials")
}
if got := wafAgentAuthFailCount(t, attackerIP); got == 0 {
t.Fatalf("bad client_secret + invalid client_uuid must still count toward WAFBlockReasonTypeAgentAuthFail; got count=%d", got)
}
}
// Mirror of the above for the empty-UUID metadata path. uuid.ParseUUID("")
// also errors out, so the same auth-fail counting must apply — otherwise an
// attacker can just omit the metadata key entirely.
func TestAuthBadSecretEmptyUUIDStillIncrementsAgentAuthFailWAF(t *testing.T) {
defer setupAuthHandshakeFixture(t)()
const attackerIP = "203.0.113.8"
if _, err := authCheckFromIP("another-bad-secret", "", attackerIP); err == nil {
t.Fatal("Check must reject bogus credentials")
}
if got := wafAgentAuthFailCount(t, attackerIP); got == 0 {
t.Fatalf("bad client_secret + empty client_uuid must still count toward WAFBlockReasonTypeAgentAuthFail; got count=%d", got)
}
}
// FORWARD-RECOVERY: forward secret bound to server A must not authenticate
// when presented with server B's UUID. Defence against an attacker who
// learns one server's forward secret and tries to attach it to a different
// agent during the recovery window.
func TestAuthForwardHandshakeSecretRejectedForDifferentUUID(t *testing.T) {
defer setupAuthHandshakeFixture(t)()
initiatePendingTransfer(t, 11, 100, 200)
pending, ok := singleton.ServerTransferShared.LookupPending(11)
if !ok {
t.Fatal("expected pending transfer")
}
forward := pending.HandshakeSecret
if _, err := singleton.ServerTransferShared.Cancel(pending.ID); err != nil {
t.Fatalf("dashboard Cancel must succeed: %v", err)
}
const otherUUID = "22222222-2222-2222-2222-222222222222"
if _, err := authCheckWithSecret(forward, otherUUID); err == nil {
t.Fatal("forward HandshakeSecret must be rejected when paired with a different server UUID even during recovery — token is per-(server, transfer)")
}
}
// SECURITY (P1): PushIfOnline only ever delivers the per-transfer
// HandshakeSecret to the real agent; the destination user's global
// AgentSecret is never sent on the wire and is therefore not proof of
// agent rotation. Server.UUID is visible to the destination user once
// Register flips Server.UserID, so admitting (ToUser global secret, real
// UUID) and calling MarkVerified would let the destination user clear
// the auth tolerance window for FromUser's secret (locking the real
// agent out) and flip transfer state to Verified without the agent ever
// applying the new credential. Only LookupByHandshakeSecret may promote.
func TestAuthDestinationUserGlobalSecretDoesNotVerifyPendingTransfer(t *testing.T) {
defer setupAuthHandshakeFixture(t)()
initiatePendingTransfer(t, 11, 100, 200)
if !singleton.ServerTransferShared.HasPending(11) {
t.Fatal("precondition: pending transfer must be registered")
}
cid, err := authCheckWithSecret("bob-global", authHandshakeUUID)
if err == nil {
t.Fatalf("destination user's global AgentSecret must not close the transfer's pending window; got cid=%d", cid)
}
if !singleton.ServerTransferShared.HasPending(11) {
t.Fatal("pending transfer must survive a destination-user global AgentSecret reconnect; only the per-transfer HandshakeSecret may promote to Verified")
}
}