mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-24 14:55:02 +03:00
f6a233220a
Every bearer-authenticated request, including each node-sync call, loaded all enabled api_tokens rows and compared each stored SHA-256 hash in constant time. Tokens are stored hashed, so the database can compare hashes directly: a timing leak there reveals only a stored hash, which cannot authenticate. The lookup is now one indexed seek on idx_api_tokens_token, which AutoMigrate creates from the model tag on new and existing databases; scope and expiry are still checked afterwards.
333 lines
11 KiB
Go
333 lines
11 KiB
Go
package panel
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gorm.io/gorm"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
)
|
|
|
|
var errInjectedTokenCreate = errors.New("injected token create failure")
|
|
|
|
func TestApiTokenCreatedAtSeconds(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
in int64
|
|
want int64
|
|
}{
|
|
{name: "seconds", in: 1_782_485_394, want: 1_782_485_394},
|
|
{name: "legacy milliseconds", in: 1_782_485_394_270, want: 1_782_485_394},
|
|
{name: "unset", in: 0, want: 0},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := apiTokenCreatedAtSeconds(tt.in); got != tt.want {
|
|
t.Fatalf("apiTokenCreatedAtSeconds(%d) = %d, want %d", tt.in, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRecreateByNamePreservesTokenWhenReplacementFails(t *testing.T) {
|
|
t.Setenv("XUI_DB_FOLDER", t.TempDir())
|
|
dbtest.InitDB(t, config.GetDBPath())
|
|
|
|
svc := ApiTokenService{}
|
|
first, err := svc.RecreateByName("cli-fallback", "")
|
|
if err != nil {
|
|
t.Fatalf("first recreate: %v", err)
|
|
}
|
|
db := database.GetDB()
|
|
const callback = "test:fail-token-replacement"
|
|
if err := db.Callback().Create().Before("gorm:create").Register(callback, func(tx *gorm.DB) {
|
|
if token, ok := tx.Statement.Dest.(*model.ApiToken); ok && token.Name == "cli-fallback" {
|
|
tx.AddError(errInjectedTokenCreate)
|
|
}
|
|
}); err != nil {
|
|
t.Fatalf("register callback: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = db.Callback().Create().Remove(callback) })
|
|
|
|
if _, err := svc.RecreateByName("cli-fallback", ""); !errors.Is(err, errInjectedTokenCreate) {
|
|
t.Fatalf("recreate error = %v, want %v", err, errInjectedTokenCreate)
|
|
}
|
|
var row model.ApiToken
|
|
if err := db.Where("name = ?", "cli-fallback").First(&row).Error; err != nil {
|
|
t.Fatalf("load preserved token: %v", err)
|
|
}
|
|
if !svc.Match(first.Token) {
|
|
t.Fatal("original token was revoked after replacement failure")
|
|
}
|
|
}
|
|
|
|
// Create caps the name at 64 characters; RecreateByName writes the same column
|
|
// and now takes operator input from -tokenName, so it must cap it too.
|
|
func TestRecreateByNameRejectsOverlongName(t *testing.T) {
|
|
t.Setenv("XUI_DB_FOLDER", t.TempDir())
|
|
dbtest.InitDB(t, config.GetDBPath())
|
|
|
|
const wantErr = "token name must be 64 characters or fewer"
|
|
|
|
svc := ApiTokenService{}
|
|
_, err := svc.RecreateByName(strings.Repeat("n", 65), "")
|
|
if err == nil {
|
|
t.Fatal("expected a 65-character token name to be rejected")
|
|
}
|
|
if got := strings.TrimSpace(err.Error()); got != wantErr {
|
|
t.Fatalf("error = %q, want %q — any other error would pass a bare nil check", got, wantErr)
|
|
}
|
|
if _, err := svc.RecreateByName(strings.Repeat("n", 64), ""); err != nil {
|
|
t.Fatalf("64 characters is the documented limit, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// Rotating a monitor token through the CLI silently reissued it as admin,
|
|
// because the replacement row took the column default instead of the old scope.
|
|
func TestRecreateByNameKeepsReplacedTokenScope(t *testing.T) {
|
|
t.Setenv("XUI_DB_FOLDER", t.TempDir())
|
|
dbtest.InitDB(t, config.GetDBPath())
|
|
|
|
svc := ApiTokenService{}
|
|
if _, err := svc.Create("grafana", model.ApiScopeMonitor, 0); err != nil {
|
|
t.Fatalf("seed grafana: %v", err)
|
|
}
|
|
rotated, err := svc.RecreateByName("grafana", "")
|
|
if err != nil {
|
|
t.Fatalf("recreate: %v", err)
|
|
}
|
|
|
|
var row model.ApiToken
|
|
if err := database.GetDB().Where("name = ?", "grafana").First(&row).Error; err != nil {
|
|
t.Fatalf("load grafana: %v", err)
|
|
}
|
|
if row.Scope != model.ApiScopeMonitor {
|
|
t.Fatalf("stored scope = %q, want %q", row.Scope, model.ApiScopeMonitor)
|
|
}
|
|
if rotated.Scope != model.ApiScopeMonitor {
|
|
t.Fatalf("returned scope = %q, want %q", rotated.Scope, model.ApiScopeMonitor)
|
|
}
|
|
}
|
|
|
|
// An explicit scope wins over the replaced token's, and a bad one is refused
|
|
// before the old token is touched.
|
|
func TestRecreateByNameAppliesGivenScope(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
seedScope string
|
|
scope string
|
|
want string
|
|
wantErr string
|
|
}{
|
|
{name: "replaces a monitor token as node-sync", seedScope: model.ApiScopeMonitor, scope: model.ApiScopeNodeSync, want: model.ApiScopeNodeSync},
|
|
{name: "replaces a monitor token as node-admin", seedScope: model.ApiScopeMonitor, scope: model.ApiScopeNodeAdmin, want: model.ApiScopeNodeAdmin},
|
|
{name: "creates a new token as monitor", scope: model.ApiScopeMonitor, want: model.ApiScopeMonitor},
|
|
{name: "refuses an unknown scope", seedScope: model.ApiScopeMonitor, scope: "root", want: model.ApiScopeMonitor, wantErr: "scope must be 'admin', 'monitor', 'node-sync', or 'node-admin'"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Setenv("XUI_DB_FOLDER", t.TempDir())
|
|
dbtest.InitDB(t, config.GetDBPath())
|
|
|
|
svc := ApiTokenService{}
|
|
var seeded *ApiTokenView
|
|
if tt.seedScope != "" {
|
|
var err error
|
|
if seeded, err = svc.Create("bot", tt.seedScope, 0); err != nil {
|
|
t.Fatalf("seed bot: %v", err)
|
|
}
|
|
}
|
|
_, err := svc.RecreateByName("bot", tt.scope)
|
|
if tt.wantErr != "" {
|
|
if err == nil || strings.TrimSpace(err.Error()) != tt.wantErr {
|
|
t.Fatalf("error = %v, want %q", err, tt.wantErr)
|
|
}
|
|
if !svc.Match(seeded.Token) {
|
|
t.Fatal("the old token was revoked by a refused rotation")
|
|
}
|
|
} else if err != nil {
|
|
t.Fatalf("recreate: %v", err)
|
|
}
|
|
|
|
var row model.ApiToken
|
|
if err := database.GetDB().Where("name = ?", "bot").First(&row).Error; err != nil {
|
|
t.Fatalf("load bot: %v", err)
|
|
}
|
|
if row.Scope != tt.want {
|
|
t.Fatalf("stored scope = %q, want %q", row.Scope, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// A scope this build does not know, as after a downgrade, must not be guessed
|
|
// as admin; the rotation fails and the stored row stays untouched.
|
|
func TestRecreateByNameRefusesUnknownStoredScope(t *testing.T) {
|
|
t.Setenv("XUI_DB_FOLDER", t.TempDir())
|
|
dbtest.InitDB(t, config.GetDBPath())
|
|
|
|
db := database.GetDB()
|
|
stored := model.ApiToken{Name: "remote", Token: "stored-hash", Enabled: true, Scope: "future-node-scope"}
|
|
if err := db.Create(&stored).Error; err != nil {
|
|
t.Fatalf("seed remote: %v", err)
|
|
}
|
|
|
|
const wantErr = `token "remote" has unknown scope "future-node-scope"`
|
|
_, err := (&ApiTokenService{}).RecreateByName("remote", "")
|
|
if err == nil || strings.TrimSpace(err.Error()) != wantErr {
|
|
t.Fatalf("error = %v, want %q", err, wantErr)
|
|
}
|
|
var row model.ApiToken
|
|
if err := db.Where("name = ?", "remote").First(&row).Error; err != nil {
|
|
t.Fatalf("load remote: %v", err)
|
|
}
|
|
if row.Id != stored.Id || row.Token != stored.Token || row.Scope != stored.Scope {
|
|
t.Fatalf("row = %+v, want the stored row %+v unchanged", row, stored)
|
|
}
|
|
}
|
|
|
|
// Rotating a token issued with an expiry through the API handed back one that
|
|
// never expires, since the replacement row took ExpiresAt 0.
|
|
func TestRecreateByNameKeepsReplacedTokenExpiry(t *testing.T) {
|
|
for _, scope := range []string{"", model.ApiScopeNodeSync} {
|
|
t.Run("scope="+scope, func(t *testing.T) {
|
|
t.Setenv("XUI_DB_FOLDER", t.TempDir())
|
|
dbtest.InitDB(t, config.GetDBPath())
|
|
|
|
svc := ApiTokenService{}
|
|
expiresAt := nowMilli() + 30*24*60*60*1000
|
|
if _, err := svc.Create("grafana", model.ApiScopeMonitor, expiresAt); err != nil {
|
|
t.Fatalf("seed grafana: %v", err)
|
|
}
|
|
rotated, err := svc.RecreateByName("grafana", scope)
|
|
if err != nil {
|
|
t.Fatalf("recreate: %v", err)
|
|
}
|
|
|
|
var row model.ApiToken
|
|
if err := database.GetDB().Where("name = ?", "grafana").First(&row).Error; err != nil {
|
|
t.Fatalf("load grafana: %v", err)
|
|
}
|
|
if row.ExpiresAt != expiresAt || rotated.ExpiresAt != expiresAt {
|
|
t.Fatalf("stored expiresAt = %d, returned %d, want %d", row.ExpiresAt, rotated.ExpiresAt, expiresAt)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// An expired token must not come back to life without an expiry; the rotation
|
|
// is refused and the expired row is left as it was.
|
|
func TestRecreateByNameRefusesExpiredToken(t *testing.T) {
|
|
for _, scope := range []string{"", model.ApiScopeAdmin} {
|
|
t.Run("scope="+scope, func(t *testing.T) {
|
|
t.Setenv("XUI_DB_FOLDER", t.TempDir())
|
|
dbtest.InitDB(t, config.GetDBPath())
|
|
|
|
db := database.GetDB()
|
|
stored := model.ApiToken{Name: "grafana", Token: "stored-hash", Enabled: true, Scope: model.ApiScopeMonitor, ExpiresAt: nowMilli() - 1000}
|
|
if err := db.Create(&stored).Error; err != nil {
|
|
t.Fatalf("seed grafana: %v", err)
|
|
}
|
|
|
|
const wantErr = `token "grafana" has expired; create a new token from the panel or the API instead`
|
|
_, err := (&ApiTokenService{}).RecreateByName("grafana", scope)
|
|
if err == nil || strings.TrimSpace(err.Error()) != wantErr {
|
|
t.Fatalf("error = %v, want %q", err, wantErr)
|
|
}
|
|
var row model.ApiToken
|
|
if err := db.Where("name = ?", "grafana").First(&row).Error; err != nil {
|
|
t.Fatalf("load grafana: %v", err)
|
|
}
|
|
if row.Id != stored.Id || row.Token != stored.Token || row.ExpiresAt != stored.ExpiresAt {
|
|
t.Fatalf("row = %+v, want the stored row %+v unchanged", row, stored)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRecreateByNameKeepsOneToken(t *testing.T) {
|
|
t.Setenv("XUI_DB_FOLDER", t.TempDir())
|
|
dbtest.InitDB(t, config.GetDBPath())
|
|
|
|
svc := ApiTokenService{}
|
|
first, err := svc.RecreateByName("cli-fallback", "")
|
|
if err != nil {
|
|
t.Fatalf("first recreate: %v", err)
|
|
}
|
|
second, err := svc.RecreateByName("cli-fallback", "")
|
|
if err != nil {
|
|
t.Fatalf("second recreate: %v", err)
|
|
}
|
|
if first.Token == second.Token {
|
|
t.Fatal("second call returned the same plaintext, want a rotated token")
|
|
}
|
|
|
|
var count int64
|
|
if err := database.GetDB().Model(model.ApiToken{}).Where("name = ?", "cli-fallback").Count(&count).Error; err != nil {
|
|
t.Fatalf("count: %v", err)
|
|
}
|
|
if count != 1 {
|
|
t.Fatalf("token rows = %d, want 1", count)
|
|
}
|
|
}
|
|
|
|
// maxRowsPerQuery reports the largest number of rows any single SELECT inside
|
|
// fn returned, which is how a table scan shows up.
|
|
func maxRowsPerQuery(t *testing.T, fn func()) int64 {
|
|
t.Helper()
|
|
db := database.GetDB()
|
|
var max int64
|
|
cb := "test:count_api_token_rows"
|
|
if err := db.Callback().Query().After("gorm:query").Register(cb, func(tx *gorm.DB) {
|
|
if tx.RowsAffected > max {
|
|
max = tx.RowsAffected
|
|
}
|
|
}); err != nil {
|
|
t.Fatalf("register query callback: %v", err)
|
|
}
|
|
defer func() {
|
|
if err := db.Callback().Query().Remove(cb); err != nil {
|
|
t.Errorf("remove query callback: %v", err)
|
|
}
|
|
}()
|
|
fn()
|
|
return max
|
|
}
|
|
|
|
// Every authenticated request looks its bearer token up. The lookup must read
|
|
// the matching row, not every enabled token.
|
|
func TestMatchTokenReadsOnlyTheMatchingTokenRow(t *testing.T) {
|
|
setupAPITokenTestDB(t)
|
|
svc := &ApiTokenService{}
|
|
|
|
tokens := make([]string, 0, 25)
|
|
for i := range 25 {
|
|
created, err := svc.Create(fmt.Sprintf("token-%d", i), model.ApiScopeAdmin, 0)
|
|
if err != nil {
|
|
t.Fatalf("create token %d: %v", i, err)
|
|
}
|
|
tokens = append(tokens, created.Token)
|
|
}
|
|
|
|
rows := maxRowsPerQuery(t, func() {
|
|
matched, ok := svc.MatchToken(tokens[7])
|
|
if !ok {
|
|
t.Fatal("the created token must match")
|
|
}
|
|
if matched.Name != "token-7" {
|
|
t.Fatalf("matched %q, want token-7", matched.Name)
|
|
}
|
|
})
|
|
|
|
if rows > 1 {
|
|
t.Fatalf("the lookup read %d token rows; it must read only the matching one", rows)
|
|
}
|
|
}
|