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
+9 -20
View File
@@ -1,7 +1,6 @@
package panel
import (
"crypto/subtle"
"errors"
"strings"
"time"
@@ -256,36 +255,26 @@ func requireExpectedScope(expectedScope string) (string, error) {
return scope, nil
}
// MatchToken returns the enabled, non-expired api_token row whose stored
// SHA-256 hash matches the presented bearer value, or (nil,false). The loop
// scans every enabled row with constant-time compares, then applies expiry and
// scope checks to avoid treating corrupt values as admin.
// MatchToken seeks the enabled row by the presented value's SHA-256 (comparing hashes leaks nothing
// usable), then checks scope and expiry so a corrupt value is never treated as admin.
func (s *ApiTokenService) MatchToken(presented string) (*model.ApiToken, bool) {
if presented == "" {
return nil, false
}
db := database.GetDB()
var rows []*model.ApiToken
if err := db.Model(model.ApiToken{}).Where("enabled = ?", true).Find(&rows).Error; err != nil {
var row model.ApiToken
if err := db.Model(model.ApiToken{}).
Where("token = ? AND enabled = ?", crypto.HashTokenSHA256(presented), true).
First(&row).Error; err != nil {
return nil, false
}
presentedHash := []byte(crypto.HashTokenSHA256(presented))
var matched *model.ApiToken
for _, r := range rows {
if subtle.ConstantTimeCompare([]byte(r.Token), presentedHash) == 1 {
matched = r
}
}
if matched == nil {
if !model.IsKnownApiScope(row.Scope) {
return nil, false
}
if !model.IsKnownApiScope(matched.Scope) {
if row.ExpiresAt != 0 && nowMilli() >= row.ExpiresAt {
return nil, false
}
if matched.ExpiresAt != 0 && nowMilli() >= matched.ExpiresAt {
return nil, false
}
return matched, true
return &row, true
}
// Match is the legacy boolean form for callers that do not need scope.