perf(auth): look up api tokens by their hash index (#6915)

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.
This commit is contained in:
25f2002654
2026-10-24 08:36:49 +05:30
committed by GitHub
parent 9f0f2b3802
commit f6a233220a
4 changed files with 66 additions and 22 deletions
@@ -2,6 +2,7 @@ package panel
import (
"errors"
"fmt"
"strings"
"testing"
@@ -276,3 +277,56 @@ func TestRecreateByNameKeepsOneToken(t *testing.T) {
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)
}
}