Files
nginx-ui/internal/nodeauth/shared_secret_test.go
2026-07-29 17:52:44 +08:00

180 lines
6.4 KiB
Go

package nodeauth
import (
"io"
"net/http"
"strings"
"testing"
"time"
"github.com/0xJacky/Nginx-UI/model"
"github.com/0xJacky/Nginx-UI/settings"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
const sharedNodeSecret = "shared-node-secret"
func setupSharedSecretTest(t *testing.T) *gorm.DB {
t.Helper()
originalInstanceID := settings.NodeSettings.InstanceID
originalCryptoSecret := settings.CryptoSettings.Secret
originalNodeSecret := settings.NodeSettings.Secret
t.Cleanup(func() {
settings.NodeSettings.InstanceID = originalInstanceID
settings.CryptoSettings.Secret = originalCryptoSecret
settings.NodeSettings.Secret = originalNodeSecret
model.Use(nil)
})
settings.NodeSettings.InstanceID = "11111111-1111-4111-8111-111111111111"
settings.CryptoSettings.Secret = "shared-secret-test-root"
settings.NodeSettings.Secret = sharedNodeSecret
database := openNodeAuthIntegrationDatabase(t, "shared-secret")
require.NoError(t, database.AutoMigrate(&model.Node{}, &model.NodeControllerCredential{}))
model.Use(database)
return database
}
func newSharedSecretRequest(t *testing.T, secret string, now time.Time) *http.Request {
t.Helper()
request, err := http.NewRequest(http.MethodPost, "https://node.example/api/configs?sort=name",
strings.NewReader(`{"name":"example"}`))
require.NoError(t, err)
require.NoError(t, SignRequestWithSharedSecret(request, []byte(secret), now))
return request
}
// TestSharedSecretRequestKeepsTheSecretOffTheWire is the property the whole
// change exists for: a node that authenticates with the configured secret no
// longer transmits it.
func TestSharedSecretRequestKeepsTheSecretOffTheWire(t *testing.T) {
database := setupSharedSecretTest(t)
now := time.Now()
request := newSharedSecretRequest(t, sharedNodeSecret, now)
assert.Empty(t, request.Header.Get("X-Node-Secret"))
for _, header := range []string{signatureInputHeader, signatureHeader, contentDigestHeader} {
assert.NotEmpty(t, request.Header.Get(header), header)
}
body, err := io.ReadAll(request.Body)
require.NoError(t, err)
assert.NotContains(t, string(body), sharedNodeSecret)
assert.NotContains(t, request.URL.String(), sharedNodeSecret)
for name, values := range request.Header {
for _, value := range values {
assert.NotContains(t, value, sharedNodeSecret, name)
}
}
principal, err := verifyRequest(newSharedSecretRequest(t, sharedNodeSecret, now),
database, now, NewReplayCache(16))
require.NoError(t, err)
assert.Equal(t, model.NodeAuthMethodLegacy, principal.AuthMethod)
}
func TestSharedSecretRequestRejectsForgeryAndReplay(t *testing.T) {
database := setupSharedSecretTest(t)
now := time.Now()
t.Run("other_secret", func(t *testing.T) {
_, err := verifyRequest(newSharedSecretRequest(t, "another-secret", now),
database, now, NewReplayCache(16))
assert.ErrorContains(t, err, "signature is invalid")
})
t.Run("tampered_path", func(t *testing.T) {
request := newSharedSecretRequest(t, sharedNodeSecret, now)
request.URL.Path = "/api/nginx/restart"
_, err := verifyRequest(request, database, now, NewReplayCache(16))
assert.ErrorContains(t, err, "signature is invalid")
})
t.Run("tampered_body", func(t *testing.T) {
request := newSharedSecretRequest(t, sharedNodeSecret, now)
CloseStagedBody(request)
request.Body = io.NopCloser(strings.NewReader(`{"name":"attacker"}`))
_, err := verifyRequest(request, database, now, NewReplayCache(16))
assert.ErrorContains(t, err, "content digest mismatch")
})
t.Run("replay", func(t *testing.T) {
cache := NewReplayCache(16)
request := newSharedSecretRequest(t, sharedNodeSecret, now)
replay := request.Clone(request.Context())
replay.Body = io.NopCloser(strings.NewReader(`{"name":"example"}`))
_, err := verifyRequest(request, database, now, cache)
require.NoError(t, err)
_, err = verifyRequest(replay, database, now, cache)
assert.ErrorContains(t, err, "nonce was already used")
})
t.Run("expired", func(t *testing.T) {
_, err := verifyRequest(newSharedSecretRequest(t, sharedNodeSecret, now),
database, now.Add(10*time.Minute), NewReplayCache(16))
assert.ErrorContains(t, err, "expired")
})
t.Run("no_configured_secret", func(t *testing.T) {
request := newSharedSecretRequest(t, sharedNodeSecret, now)
settings.NodeSettings.Secret = ""
defer func() { settings.NodeSettings.Secret = sharedNodeSecret }()
_, err := verifyRequest(request, database, now, NewReplayCache(16))
assert.ErrorContains(t, err, "not configured")
})
}
// TestLegacyNodeTransportUsesCompatibleHeader covers rolling upgrades: a
// controller must keep using the protocol understood by pre-v2.5.0 nodes until
// the relationship maintenance job can replace the secret with a key pair.
func TestLegacyNodeTransportUsesCompatibleHeader(t *testing.T) {
database := setupSharedSecretTest(t)
encryptedSecret, err := EncryptPrivateCredential(LegacyCredentialPurpose(1), []byte(sharedNodeSecret))
require.NoError(t, err)
node := &model.Node{
Name: "child",
URL: "https://child.example",
AuthMethod: model.NodeAuthMethodLegacy,
CredentialStatus: model.NodeCredentialStatusActive,
EncryptedLegacySecret: encryptedSecret,
Enabled: true,
}
require.NoError(t, database.Create(node).Error)
require.EqualValues(t, 1, node.ID, "the legacy credential purpose is bound to the node ID")
verified := 0
child := roundTripFunc(func(request *http.Request) (*http.Response, error) {
assert.Equal(t, sharedNodeSecret, request.Header.Get("X-Node-Secret"))
assert.Empty(t, request.Header.Get(signatureHeader))
verified++
return &http.Response{
StatusCode: http.StatusNoContent,
Header: make(http.Header),
Body: http.NoBody,
Request: request,
}, nil
})
client := &http.Client{Transport: NewTransport(node, child)}
for _, target := range []string{
"https://child.example/api/node",
"https://child.example/api/configs?sort=name",
} {
request, err := http.NewRequest(http.MethodGet, target, nil)
require.NoError(t, err)
response, err := client.Do(request)
require.NoError(t, err)
require.NoError(t, response.Body.Close())
assert.Equal(t, http.StatusNoContent, response.StatusCode)
}
assert.Equal(t, 2, verified)
headers := make(http.Header)
require.NoError(t, SignWebSocketHeaders(node, "wss://child.example/api/analytic/intro", headers))
assert.Equal(t, sharedNodeSecret, headers.Get("X-Node-Secret"))
assert.Empty(t, headers.Get(signatureHeader))
}