Skip to content

Commit c0d2aa9

Browse files
explodedclaude
andcommitted
Fix security vulnerabilities, error handling, and accessibility across codebase
Addresses IDOR in AcceptPartner/RemovePartner, OAuth state CSRF, XSS in profile messages, cookie Secure flag behind proxy, pervasive swallowed DB errors, bubble sort perf, slice corruption, missing indexes, HTMX stored locally, template buffering, rate limiter IP extraction, goroutine shutdown, and accessibility (aria-labels, focus-visible, visually-hidden toggle). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 3c2dacf commit c0d2aa9

31 files changed

Lines changed: 549 additions & 202 deletions

cmd/wtw/main.go

Lines changed: 70 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -38,25 +38,29 @@ type visitor struct {
3838
}
3939

4040
func newRateLimiter(rate int, window time.Duration) *rateLimiter {
41-
rl := &rateLimiter{
41+
return &rateLimiter{
4242
visitors: make(map[string]*visitor),
4343
rate: rate,
4444
window: window,
4545
}
46-
go rl.cleanup()
47-
return rl
4846
}
4947

50-
func (rl *rateLimiter) cleanup() {
48+
func (rl *rateLimiter) cleanup(ctx context.Context) {
49+
ticker := time.NewTicker(time.Minute)
50+
defer ticker.Stop()
5151
for {
52-
time.Sleep(time.Minute)
53-
rl.mu.Lock()
54-
for ip, v := range rl.visitors {
55-
if time.Since(v.windowStart) > rl.window {
56-
delete(rl.visitors, ip)
52+
select {
53+
case <-ticker.C:
54+
rl.mu.Lock()
55+
for ip, v := range rl.visitors {
56+
if time.Since(v.windowStart) > rl.window {
57+
delete(rl.visitors, ip)
58+
}
5759
}
60+
rl.mu.Unlock()
61+
case <-ctx.Done():
62+
return
5863
}
59-
rl.mu.Unlock()
6064
}
6165
}
6266

@@ -76,12 +80,27 @@ func (rl *rateLimiter) allow(ip string) bool {
7680
return v.count <= rl.rate
7781
}
7882

83+
// clientIP extracts the client IP, preferring X-Forwarded-For behind a proxy.
84+
func clientIP(r *http.Request) string {
85+
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
86+
// First entry is the original client
87+
if ip := strings.TrimSpace(strings.SplitN(xff, ",", 2)[0]); ip != "" {
88+
return ip
89+
}
90+
}
91+
if xri := r.Header.Get("X-Real-IP"); xri != "" {
92+
return strings.TrimSpace(xri)
93+
}
94+
ip, _, err := net.SplitHostPort(r.RemoteAddr)
95+
if err != nil {
96+
return r.RemoteAddr
97+
}
98+
return ip
99+
}
100+
79101
func rateLimitMiddleware(limiter *rateLimiter, next http.Handler) http.Handler {
80102
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
81-
ip, _, err := net.SplitHostPort(r.RemoteAddr)
82-
if err != nil {
83-
ip = r.RemoteAddr
84-
}
103+
ip := clientIP(r)
85104
if !limiter.allow(ip) {
86105
slog.Warn("rate limit exceeded", "ip", ip)
87106
http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
@@ -145,9 +164,13 @@ func main() {
145164

146165
queries := db.New(database)
147166

167+
// Cancellable context for background goroutines
168+
bgCtx, bgCancel := context.WithCancel(context.Background())
169+
defer bgCancel()
170+
148171
// Session store
149172
store := session.NewStore(queries)
150-
go store.CleanupLoop()
173+
go store.CleanupLoop(bgCtx)
151174

152175
// Auth handler
153176
authHandler := auth.NewHandler(
@@ -156,18 +179,23 @@ func main() {
156179
os.Getenv("GOOGLE_REDIRECT_URL"),
157180
queries,
158181
store,
182+
isProd,
159183
)
160184

161-
// TMDB client refresh poster paths in background
185+
// TMDB client -- refresh poster paths in background
162186
tmdbClient := tmdb.NewClient(os.Getenv("TMDB_API_KEY"), queries)
163-
tmdbClient.StartRefreshLoop(context.Background())
187+
tmdbClient.StartRefreshLoop(bgCtx)
164188

165189
// Recommendation engine
166190
engine := recommend.NewEngine(queries, os.Getenv("ANTHROPIC_API_KEY"), tmdbClient)
167191

168192
// Handlers
169193
h := handlers.New(queries, store, engine, tmdbClient)
170194

195+
// Rate limiter with cancellable cleanup
196+
limiter := newRateLimiter(60, time.Minute)
197+
go limiter.cleanup(bgCtx)
198+
171199
// Routes
172200
mux := http.NewServeMux()
173201

@@ -207,7 +235,6 @@ func main() {
207235
})
208236

209237
// Middleware chain
210-
limiter := newRateLimiter(60, time.Minute)
211238
handler := middleware.RequestLogger(
212239
rateLimitMiddleware(limiter,
213240
middleware.SecurityHeaders(isProd, mux),
@@ -222,18 +249,27 @@ func main() {
222249
IdleTimeout: 60 * time.Second,
223250
}
224251

252+
// Use a channel to propagate server errors to main goroutine
253+
serverErr := make(chan error, 1)
225254
go func() {
226255
slog.Info("HTTP server listening", "addr", srv.Addr)
227256
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
228-
slog.Error("server error", "error", err)
229-
os.Exit(1)
257+
serverErr <- err
230258
}
231259
}()
232260

233261
quit := make(chan os.Signal, 1)
234262
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
235-
<-quit
236-
slog.Info("shutting down server...")
263+
264+
select {
265+
case <-quit:
266+
slog.Info("shutting down server...")
267+
case err := <-serverErr:
268+
slog.Error("server error", "error", err)
269+
}
270+
271+
// Cancel background goroutines before shutting down the server
272+
bgCancel()
237273

238274
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
239275
defer cancel()
@@ -246,16 +282,21 @@ func main() {
246282
// migrateRatingsConstraint recreates the ratings table if the CHECK constraint
247283
// does not include 'favourite'. Needed because CREATE TABLE IF NOT EXISTS skips
248284
// existing tables, so the schema update alone cannot fix old databases.
249-
func migrateRatingsConstraint(db *sql.DB) error {
285+
func migrateRatingsConstraint(database *sql.DB) error {
250286
var tableSql string
251-
err := db.QueryRow("SELECT sql FROM sqlite_master WHERE type='table' AND name='ratings'").Scan(&tableSql)
287+
err := database.QueryRow("SELECT sql FROM sqlite_master WHERE type='table' AND name='ratings'").Scan(&tableSql)
252288
if err != nil {
253289
return nil // table doesn't exist yet, schema will create it
254290
}
255291
if strings.Contains(tableSql, "'favourite'") {
256292
return nil // already migrated
257293
}
258-
_, err = db.Exec(`
294+
tx, err := database.Begin()
295+
if err != nil {
296+
return err
297+
}
298+
defer tx.Rollback()
299+
_, err = tx.Exec(`
259300
CREATE TABLE ratings_new (
260301
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
261302
show_id TEXT NOT NULL REFERENCES shows(id),
@@ -268,7 +309,10 @@ func migrateRatingsConstraint(db *sql.DB) error {
268309
ALTER TABLE ratings_new RENAME TO ratings;
269310
CREATE INDEX IF NOT EXISTS idx_ratings_user ON ratings(user_id);
270311
`)
271-
return err
312+
if err != nil {
313+
return err
314+
}
315+
return tx.Commit()
272316
}
273317

274318
func cacheStaticAssets(next http.Handler) http.Handler {

internal/auth/google.go

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ package auth
22

33
import (
44
"context"
5+
"crypto/rand"
56
"database/sql"
7+
"encoding/hex"
68
"encoding/json"
79
"fmt"
810
"log/slog"
@@ -19,17 +21,18 @@ type Handler struct {
1921
config *oauth2.Config
2022
queries *db.Queries
2123
store *session.Store
24+
isProd bool
2225
}
2326

24-
func NewHandler(clientID, clientSecret, redirectURL string, queries *db.Queries, store *session.Store) *Handler {
27+
func NewHandler(clientID, clientSecret, redirectURL string, queries *db.Queries, store *session.Store, isProd bool) *Handler {
2528
cfg := &oauth2.Config{
2629
ClientID: clientID,
2730
ClientSecret: clientSecret,
2831
RedirectURL: redirectURL,
2932
Scopes: []string{"openid", "email", "profile"},
3033
Endpoint: google.Endpoint,
3134
}
32-
return &Handler{config: cfg, queries: queries, store: store}
35+
return &Handler{config: cfg, queries: queries, store: store, isProd: isProd}
3336
}
3437

3538
type googleUserInfo struct {
@@ -40,11 +43,42 @@ type googleUserInfo struct {
4043
}
4144

4245
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
43-
url := h.config.AuthCodeURL("state-token", oauth2.AccessTypeOffline)
46+
b := make([]byte, 16)
47+
if _, err := rand.Read(b); err != nil {
48+
http.Error(w, "internal error", http.StatusInternalServerError)
49+
return
50+
}
51+
state := hex.EncodeToString(b)
52+
53+
http.SetCookie(w, &http.Cookie{
54+
Name: "oauth_state",
55+
Value: state,
56+
Path: "/",
57+
MaxAge: 600,
58+
HttpOnly: true,
59+
SameSite: http.SameSiteLaxMode,
60+
Secure: h.isProd,
61+
})
62+
63+
url := h.config.AuthCodeURL(state, oauth2.AccessTypeOffline)
4464
http.Redirect(w, r, url, http.StatusTemporaryRedirect)
4565
}
4666

4767
func (h *Handler) Callback(w http.ResponseWriter, r *http.Request) {
68+
// Validate OAuth state
69+
stateCookie, err := r.Cookie("oauth_state")
70+
if err != nil || stateCookie.Value == "" || stateCookie.Value != r.URL.Query().Get("state") {
71+
http.Error(w, "invalid state", http.StatusBadRequest)
72+
return
73+
}
74+
// Clear the state cookie
75+
http.SetCookie(w, &http.Cookie{
76+
Name: "oauth_state",
77+
Value: "",
78+
Path: "/",
79+
MaxAge: -1,
80+
})
81+
4882
code := r.URL.Query().Get("code")
4983
if code == "" {
5084
http.Error(w, "missing code", http.StatusBadRequest)
@@ -99,15 +133,20 @@ func (h *Handler) Callback(w http.ResponseWriter, r *http.Request) {
99133
Name: "session",
100134
Value: sessionToken,
101135
Path: "/",
102-
MaxAge: 30 * 24 * 60 * 60,
136+
MaxAge: session.MaxAgeSec,
103137
HttpOnly: true,
104138
SameSite: http.SameSiteLaxMode,
105-
Secure: r.TLS != nil,
139+
Secure: h.isProd,
106140
})
107141

108142
// Check if user has ratings to decide redirect
109143
ratings, err := h.queries.GetUserRatings(r.Context(), user.ID)
110-
if err != nil || len(ratings) == 0 {
144+
if err != nil {
145+
slog.Error("failed to check user ratings", "error", err)
146+
http.Redirect(w, r, "/onboarding", http.StatusSeeOther)
147+
return
148+
}
149+
if len(ratings) == 0 {
111150
http.Redirect(w, r, "/onboarding", http.StatusSeeOther)
112151
return
113152
}

internal/db/helpers.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,5 @@ func NewNullString(s string) sql.NullString {
1010
}
1111

1212
func NewNullInt64(i int64) sql.NullInt64 {
13-
return sql.NullInt64{Int64: i, Valid: i != 0}
13+
return sql.NullInt64{Int64: i, Valid: true}
1414
}

internal/db/queries.sql

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ VALUES (?, ?, ?, ?, ?, ?, ?, ?);
156156
SELECT * FROM shows WHERE tmdb_id = ?;
157157

158158
-- name: GetShowByTitle :one
159-
SELECT * FROM shows WHERE LOWER(title) = LOWER(?) LIMIT 1;
159+
SELECT * FROM shows WHERE title = ? COLLATE NOCASE LIMIT 1;
160160

161161
-- name: CountShows :one
162162
SELECT COUNT(*) FROM shows;

internal/db/queries.sql.go

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/db/schema.sql

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ CREATE TABLE IF NOT EXISTS partner_verdicts (
6262
);
6363

6464
CREATE INDEX IF NOT EXISTS idx_ratings_user ON ratings(user_id);
65+
CREATE INDEX IF NOT EXISTS idx_ratings_show ON ratings(show_id);
6566
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
6667
CREATE INDEX IF NOT EXISTS idx_partnerships_user ON partnerships(user_id);
6768
CREATE INDEX IF NOT EXISTS idx_partnerships_email ON partnerships(partner_email);
69+
CREATE INDEX IF NOT EXISTS idx_partnerships_partner ON partnerships(partner_id);

internal/db/seed.go

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package db
22

33
import (
4+
"context"
45
"database/sql"
56
"fmt"
67
)
@@ -68,11 +69,17 @@ var SeedShows = []SeedShow{
6869
}
6970

7071
func SeedAllShows(database *sql.DB) error {
72+
queries := New(database)
73+
ctx := context.Background()
7174
for _, s := range SeedShows {
72-
_, err := database.Exec(
73-
`INSERT OR IGNORE INTO shows (id, title, year, poster_path, genre, popularity) VALUES (?, ?, ?, ?, ?, ?)`,
74-
s.ID, s.Title, s.Year, s.PosterPath, s.Genre, s.Popularity,
75-
)
75+
err := queries.InsertShow(ctx, InsertShowParams{
76+
ID: s.ID,
77+
Title: s.Title,
78+
Year: s.Year,
79+
PosterPath: NewNullString(s.PosterPath),
80+
Genre: NewNullString(s.Genre),
81+
Popularity: sql.NullFloat64{Float64: s.Popularity, Valid: true},
82+
})
7683
if err != nil {
7784
return fmt.Errorf("seeding show %s: %w", s.ID, err)
7885
}

0 commit comments

Comments
 (0)