Skip to content

Commit 6f00c29

Browse files
committed
Security audit — fix 17 vulnerabilities across 11 files
Critical: - Timing attack on API key comparison → constant-time compare - Timing attack on Torznab API key → constant-time compare High: - Permissive CORS wildcard with cookie auth → origin validation - Missing security headers → X-Content-Type-Options, X-Frame-Options, XSS, Referrer - Unbounded request body → 1MB limit middleware - OPDS file serving path traversal → directory whitelist + symlink resolution - SSRF via connection test endpoints → block metadata/link-local addresses Medium: - Password length uncapped at bcrypt 72-byte limit → enforce 6-72 chars - Error messages leak internal details → generic responses - Unbounded HTTP response reads → 2MB limit on external responses - Settings/test endpoints not admin-restricted → require admin role - CSV import goroutines use expired request context → background context - Rate limiter XFF spoofing → use last hop, not first - Rate limiter memory leak → periodic bucket cleanup - Session store memory leak → periodic expired session cleanup - OIDC state nonce memory leak → periodic cleanup Low: - Upload response leaks internal file paths → removed - Health checks use http.DefaultClient without timeout → 10s timeout
1 parent 16ef35d commit 6f00c29

11 files changed

Lines changed: 247 additions & 58 deletions

File tree

internal/api/admin.go

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -236,16 +236,18 @@ func (s *Server) handleAdminBulkCancel(w http.ResponseWriter, r *http.Request) {
236236
func (s *Server) handleAdminHealth(w http.ResponseWriter, _ *http.Request) {
237237
checks := make([]map[string]interface{}, 0)
238238

239+
healthClient := &http.Client{Timeout: 10 * time.Second}
240+
239241
// Prowlarr.
240242
if s.cfg.HasProwlarr() {
241243
status := "ok"
242244
detail := ""
243245
req, _ := http.NewRequest("GET", s.cfg.ProwlarrURL+"/api/v1/health", nil)
244246
req.Header.Set("X-Api-Key", s.cfg.ProwlarrAPIKey)
245-
resp, err := http.DefaultClient.Do(req)
247+
resp, err := healthClient.Do(req)
246248
if err != nil {
247249
status = "error"
248-
detail = err.Error()
250+
detail = "Connection failed"
249251
} else {
250252
resp.Body.Close()
251253
if resp.StatusCode != 200 {
@@ -302,10 +304,10 @@ func (s *Server) handleAdminHealth(w http.ResponseWriter, _ *http.Request) {
302304
detail := ""
303305
req, _ := http.NewRequest("GET", s.cfg.ABSURL+"/api/libraries", nil)
304306
req.Header.Set("Authorization", "Bearer "+s.cfg.ABSToken)
305-
resp, err := http.DefaultClient.Do(req)
307+
resp, err := healthClient.Do(req)
306308
if err != nil {
307309
status = "error"
308-
detail = err.Error()
310+
detail = "Connection failed"
309311
} else {
310312
resp.Body.Close()
311313
if resp.StatusCode != 200 {
@@ -324,10 +326,10 @@ func (s *Server) handleAdminHealth(w http.ResponseWriter, _ *http.Request) {
324326
if s.cfg.HasKavita() {
325327
status := "ok"
326328
detail := ""
327-
resp, err := http.Get(s.cfg.KavitaURL + "/api/health")
329+
resp, err := healthClient.Get(s.cfg.KavitaURL + "/api/health")
328330
if err != nil {
329331
status = "error"
330-
detail = err.Error()
332+
detail = "Connection failed"
331333
} else {
332334
resp.Body.Close()
333335
if resp.StatusCode != 200 {
@@ -346,10 +348,10 @@ func (s *Server) handleAdminHealth(w http.ResponseWriter, _ *http.Request) {
346348
if s.cfg.HasCalibre() && s.cfg.CalibreURL != "" {
347349
status := "ok"
348350
detail := ""
349-
resp, err := http.Get(s.cfg.CalibreURL)
351+
resp, err := healthClient.Get(s.cfg.CalibreURL)
350352
if err != nil {
351353
status = "error"
352-
detail = err.Error()
354+
detail = "Connection failed"
353355
} else {
354356
resp.Body.Close()
355357
if resp.StatusCode >= 400 {

internal/api/auth.go

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ import (
44
"context"
55
"crypto/rand"
66
"crypto/sha256"
7+
"crypto/subtle"
78
"encoding/hex"
89
"encoding/json"
9-
"fmt"
1010
"log/slog"
1111
"net/http"
1212
"strconv"
@@ -51,10 +51,32 @@ type SessionStore struct {
5151

5252
// NewSessionStore creates a new session store.
5353
func NewSessionStore() *SessionStore {
54-
return &SessionStore{
54+
s := &SessionStore{
5555
sessions: make(map[string]*SessionData),
5656
pendingTOTP: make(map[string]*PendingTOTP),
5757
}
58+
59+
// Periodically clean up expired sessions and pending TOTP tokens.
60+
go func() {
61+
ticker := time.NewTicker(10 * time.Minute)
62+
for range ticker.C {
63+
now := time.Now()
64+
s.mu.Lock()
65+
for token, data := range s.sessions {
66+
if now.After(data.Expiry) {
67+
delete(s.sessions, token)
68+
}
69+
}
70+
for token, pending := range s.pendingTOTP {
71+
if now.After(pending.Expiry) {
72+
delete(s.pendingTOTP, token)
73+
}
74+
}
75+
s.mu.Unlock()
76+
}
77+
}()
78+
79+
return s
5880
}
5981

6082
// Create generates a new session token for a user, valid for 24 hours.
@@ -204,7 +226,7 @@ func authMiddleware(cfg *config.Config, database *db.DB, sessions *SessionStore,
204226
if apiKey == "" {
205227
apiKey = r.URL.Query().Get("apikey")
206228
}
207-
if apiKey == cfg.APIKey {
229+
if subtle.ConstantTimeCompare([]byte(apiKey), []byte(cfg.APIKey)) == 1 {
208230
// API key users get admin-level access.
209231
ctx := context.WithValue(r.Context(), ctxUserRole, "admin")
210232
ctx = context.WithValue(ctx, ctxUsername, "api")
@@ -490,10 +512,17 @@ func handleRegister(database *db.DB, sessions *SessionStore) http.HandlerFunc {
490512
return
491513
}
492514

493-
if len(req.Username) < 3 || len(req.Password) < 6 {
515+
if len(req.Username) < 3 || len(req.Username) > 64 {
516+
writeJSON(w, http.StatusBadRequest, map[string]interface{}{
517+
"success": false,
518+
"error": "Username must be 3-64 characters",
519+
})
520+
return
521+
}
522+
if len(req.Password) < 6 || len(req.Password) > 72 {
494523
writeJSON(w, http.StatusBadRequest, map[string]interface{}{
495524
"success": false,
496-
"error": "Username must be at least 3 characters, password at least 6",
525+
"error": "Password must be 6-72 characters",
497526
})
498527
return
499528
}
@@ -737,9 +766,10 @@ func handleDeleteUser(database *db.DB) http.HandlerFunc {
737766
}
738767

739768
if err := database.DeleteUser(id); err != nil {
769+
slog.Error("failed to delete user", "id", id, "error", err)
740770
writeJSON(w, http.StatusNotFound, map[string]interface{}{
741771
"success": false,
742-
"error": fmt.Sprintf("Failed to delete user: %s", err.Error()),
772+
"error": "Failed to delete user",
743773
})
744774
return
745775
}

internal/api/csv.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
package api
22

33
import (
4+
"context"
45
"encoding/csv"
56
"io"
67
"log/slog"
78
"net/http"
89
"strings"
10+
"time"
911
)
1012

1113
func (s *Server) handleCSVImport(w http.ResponseWriter, r *http.Request) {
@@ -93,6 +95,7 @@ func (s *Server) handleCSVImport(w http.ResponseWriter, r *http.Request) {
9395
}
9496

9597
// Search all sources and queue best result.
98+
// Use background context since the goroutine outlives the HTTP request.
9699
go func(title, author, mediaType string) {
97100
tab := "main"
98101
if mediaType == "audiobook" {
@@ -106,7 +109,9 @@ func (s *Server) handleCSVImport(w http.ResponseWriter, r *http.Request) {
106109
query = title + " " + author
107110
}
108111

109-
results, _ := s.searchMgr.Search(r.Context(), tab, query)
112+
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
113+
defer cancel()
114+
results, _ := s.searchMgr.Search(ctx, tab, query)
110115
if len(results) == 0 {
111116
slog.Warn("CSV import: no results", "title", title)
112117
return

internal/api/oidc.go

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ func NewOIDCHandler(cfg *config.Config, database *db.DB, sessions *SessionStore)
6363

6464
slog.Info("OIDC provider initialized", "issuer", cfg.OIDCIssuer, "provider_name", cfg.OIDCProviderName)
6565

66-
return &OIDCHandler{
66+
h := &OIDCHandler{
6767
cfg: cfg,
6868
db: database,
6969
sessions: sessions,
@@ -72,6 +72,23 @@ func NewOIDCHandler(cfg *config.Config, database *db.DB, sessions *SessionStore)
7272
oauth2: oauth2Cfg,
7373
states: make(map[string]time.Time),
7474
}
75+
76+
// Periodically clean up expired OIDC state nonces.
77+
go func() {
78+
ticker := time.NewTicker(5 * time.Minute)
79+
for range ticker.C {
80+
h.mu.Lock()
81+
now := time.Now()
82+
for state, expiry := range h.states {
83+
if now.After(expiry) {
84+
delete(h.states, state)
85+
}
86+
}
87+
h.mu.Unlock()
88+
}
89+
}()
90+
91+
return h
7592
}
7693

7794
// generateState creates a random state string for CSRF protection.

internal/api/opds.go

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -238,21 +238,49 @@ func (s *Server) handleOPDSDownload(w http.ResponseWriter, r *http.Request) {
238238
http.Error(w, "File not found on disk", http.StatusNotFound)
239239
return
240240
}
241-
if _, err := os.Stat(item.FilePath); os.IsNotExist(err) {
241+
242+
// Resolve the real path to prevent path traversal via symlinks or DB tampering.
243+
realPath, err := filepath.EvalSymlinks(item.FilePath)
244+
if err != nil {
245+
http.Error(w, "File not found on disk", http.StatusNotFound)
246+
return
247+
}
248+
249+
// Validate the file is within an allowed directory.
250+
allowed := false
251+
for _, dir := range []string{s.cfg.EbookDir, s.cfg.AudiobookDir, s.cfg.MangaDir, s.cfg.IncomingDir, s.cfg.MangaIncomingDir} {
252+
if dir == "" {
253+
continue
254+
}
255+
absDir, err := filepath.Abs(dir)
256+
if err != nil {
257+
continue
258+
}
259+
if strings.HasPrefix(realPath, absDir+string(filepath.Separator)) || realPath == absDir {
260+
allowed = true
261+
break
262+
}
263+
}
264+
if !allowed {
265+
http.Error(w, "Access denied", http.StatusForbidden)
266+
return
267+
}
268+
269+
if _, err := os.Stat(realPath); os.IsNotExist(err) {
242270
http.Error(w, "File not found on disk", http.StatusNotFound)
243271
return
244272
}
245273

246-
fmtStr := strings.ToLower(strings.TrimPrefix(filepath.Ext(item.FilePath), "."))
274+
fmtStr := strings.ToLower(strings.TrimPrefix(filepath.Ext(realPath), "."))
247275
mime := formatMIMEs[fmtStr]
248276
if mime == "" {
249277
mime = "application/octet-stream"
250278
}
251279

252280
w.Header().Set("Content-Type", mime)
253281
w.Header().Set("Content-Disposition",
254-
fmt.Sprintf("attachment; filename=\"%s\"", filepath.Base(item.FilePath)))
255-
http.ServeFile(w, r, item.FilePath)
282+
fmt.Sprintf("attachment; filename=%q", filepath.Base(realPath)))
283+
http.ServeFile(w, r, realPath)
256284
return
257285
}
258286
}

internal/api/ratelimit.go

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,34 @@ type rateLimitBucket struct {
2727

2828
// NewRateLimiter creates a rate limiter with the given window and rules.
2929
func NewRateLimiter(windowSec int, rules map[string]int) *RateLimiter {
30-
return &RateLimiter{
30+
rl := &RateLimiter{
3131
windowSec: windowSec,
3232
rules: rules,
3333
buckets: make(map[rateLimitKey]*rateLimitBucket),
3434
}
35+
// Periodically clean up stale buckets to prevent unbounded memory growth.
36+
go func() {
37+
ticker := time.NewTicker(5 * time.Minute)
38+
for range ticker.C {
39+
rl.cleanup()
40+
}
41+
}()
42+
return rl
43+
}
44+
45+
// cleanup removes expired buckets.
46+
func (rl *RateLimiter) cleanup() {
47+
now := time.Now()
48+
cutoff := now.Add(-time.Duration(rl.windowSec) * time.Second)
49+
50+
rl.mu.Lock()
51+
defer rl.mu.Unlock()
52+
53+
for key, bucket := range rl.buckets {
54+
if len(bucket.timestamps) == 0 || bucket.timestamps[len(bucket.timestamps)-1].Before(cutoff) {
55+
delete(rl.buckets, key)
56+
}
57+
}
3558
}
3659

3760
func (rl *RateLimiter) ruleForPath(path string) string {
@@ -116,9 +139,14 @@ func RateLimitMiddleware(rl *RateLimiter, next http.Handler) http.Handler {
116139
return
117140
}
118141

119-
identity := r.Header.Get("X-Forwarded-For")
120-
if identity == "" {
121-
identity = r.RemoteAddr
142+
// Use RemoteAddr as primary identity. X-Forwarded-For is trivially
143+
// spoofable unless behind a trusted reverse proxy. If behind a proxy,
144+
// take only the rightmost (last hop before our server) IP from XFF.
145+
identity := r.RemoteAddr
146+
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
147+
parts := strings.Split(xff, ",")
148+
// Use the last entry (closest proxy hop) -- less spoofable than first.
149+
identity = strings.TrimSpace(parts[len(parts)-1])
122150
}
123151

124152
allowed, retryAfter, rule, limit := rl.Check(identity, r.URL.Path)

0 commit comments

Comments
 (0)