Skip to content
This repository was archived by the owner on Jun 2, 2026. It is now read-only.

Commit 0d95edf

Browse files
committed
fix: address 8 security vulnerabilities from codebase review
- S0 [CRITICAL]: Gate X-User-DID header behind TRUST_PROXY_HEADERS config (default: false) to prevent admin auth bypass via header spoofing Fixes #6 - S1: Sanitize JSONExtract/JSONExtractPath with regex validation to prevent SQL injection in both SQLite and PostgreSQL executors Fixes #2 - S2: Make WebSocket CheckOrigin configurable via ALLOWED_ORIGINS; defaults to same-origin policy instead of allowing all origins Fixes #3 - S3: Replace backfillActive bool with atomic.Bool and use CompareAndSwap to prevent race conditions in concurrent backfill triggers Fixes #4 - S4: Treat JTI Insert failures as replay attempts (ErrDPoPReplay) instead of generic server errors for proper TOCTOU handling Fixes #5 - S5: Add requireAdmin() to all admin queries (statistics, settings, lexicons, activity, etc.) — only currentSession remains public (also part of #6) - S6: Add upload size limits (10MB ZIP, 500 files, 1MB/file) with io.LimitReader to prevent memory exhaustion via ZIP bombs Fixes #14 - S7: Disable global WriteTimeout (set to 0) to support long-lived WebSocket subscriptions; per-handler deadlines remain in place Fixes #16
1 parent 9269938 commit 0d95edf

10 files changed

Lines changed: 196 additions & 45 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,6 @@ logs/
5656
tmp/
5757
temp/
5858
.beads/
59+
60+
# AI tools
61+
.opencode/

cmd/hypergoat/main.go

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -323,7 +323,7 @@ func run() error {
323323
domainDID = "did:web:" + cfg.Host // Derive from host
324324
}
325325

326-
adminHandler, err := admin.NewHandler(adminRepos, authMiddleware, configRepo, domainDID)
326+
adminHandler, err := admin.NewHandler(adminRepos, authMiddleware, configRepo, domainDID, cfg.TrustProxyHeaders)
327327
if err != nil {
328328
slog.Error("Failed to create admin GraphQL handler", "error", err)
329329
} else {
@@ -500,7 +500,14 @@ func run() error {
500500
slog.Info("GraphQL endpoint enabled", "path", "/graphql")
501501

502502
// Add WebSocket subscription endpoint
503-
subscriptionHandler := subscription.NewHandler(graphqlHandler.Schema())
503+
var allowedOrigins []string
504+
if cfg.AllowedOrigins != "" {
505+
allowedOrigins = strings.Split(cfg.AllowedOrigins, ",")
506+
for i := range allowedOrigins {
507+
allowedOrigins[i] = strings.TrimSpace(allowedOrigins[i])
508+
}
509+
}
510+
subscriptionHandler := subscription.NewHandler(graphqlHandler.Schema(), allowedOrigins)
504511
r.Handle("/graphql/ws", subscriptionHandler)
505512
slog.Info("GraphQL subscriptions enabled", "path", "/graphql/ws")
506513
}
@@ -652,10 +659,14 @@ func run() error {
652659

653660
// Create HTTP server
654661
srv := &http.Server{
655-
Addr: cfg.Address(),
656-
Handler: r,
657-
ReadTimeout: 15 * time.Second,
658-
WriteTimeout: 15 * time.Second,
662+
Addr: cfg.Address(),
663+
Handler: r,
664+
ReadTimeout: 15 * time.Second,
665+
// WriteTimeout disabled (set to 0) to support long-lived WebSocket connections.
666+
// Individual handlers enforce their own write deadlines:
667+
// - WebSocket: per-message deadline in subscription/handler.go
668+
// - HTTP: standard response lifecycle
669+
WriteTimeout: 0,
659670
IdleTimeout: 60 * time.Second,
660671
}
661672

internal/config/config.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ type Config struct {
3838
BackfillMaxPDSWorkers int
3939
BackfillMaxHTTPConcurrent int
4040
BackfillRepoTimeoutMS int
41+
42+
// Security
43+
TrustProxyHeaders bool // Trust X-User-DID header from reverse proxy (default: false, DANGEROUS if true without proxy)
44+
AllowedOrigins string // Comma-separated allowed WebSocket/CORS origins (empty = same-origin only, "*" = allow all)
4145
}
4246

4347
// Load reads configuration from environment variables.
@@ -59,6 +63,8 @@ func Load() (*Config, error) {
5963
BackfillMaxPDSWorkers: getEnvInt("BACKFILL_MAX_PDS_WORKERS", 10),
6064
BackfillMaxHTTPConcurrent: getEnvInt("BACKFILL_MAX_HTTP_CONCURRENT", 50),
6165
BackfillRepoTimeoutMS: getEnvInt("BACKFILL_REPO_TIMEOUT", 60000),
66+
TrustProxyHeaders: getEnvBool("TRUST_PROXY_HEADERS", false),
67+
AllowedOrigins: getEnv("ALLOWED_ORIGINS", ""),
6268
}
6369

6470
// Generate SecretKeyBase if not provided
@@ -103,7 +109,14 @@ func (c *Config) LogConfig() {
103109
"oauth_loopback_mode", c.OAuthLoopbackMode,
104110
"oauth_signing_key_set", c.OAuthSigningKey != "",
105111
"jetstream_disable_cursor", c.JetstreamDisableCursor,
112+
"trust_proxy_headers", c.TrustProxyHeaders,
113+
"allowed_origins", c.AllowedOrigins,
106114
)
115+
116+
if c.TrustProxyHeaders {
117+
slog.Warn("TRUST_PROXY_HEADERS is enabled: X-User-DID header will be trusted for authentication. " +
118+
"Only enable this when running behind a trusted reverse proxy that sets this header.")
119+
}
107120
}
108121

109122
// Address returns the server address in host:port format.

internal/database/postgres/executor.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,17 @@ import (
66
"database/sql"
77
"errors"
88
"fmt"
9+
"regexp"
910
"strings"
1011

1112
_ "github.com/jackc/pgx/v5/stdlib" // PostgreSQL driver
1213

1314
"github.com/GainForest/hypergoat/internal/database"
1415
)
1516

17+
// validJSONFieldName matches safe JSON field names to prevent SQL injection.
18+
var validJSONFieldName = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
19+
1620
// Executor implements database.Executor for PostgreSQL.
1721
type Executor struct {
1822
db *sql.DB
@@ -103,15 +107,25 @@ func (e *Executor) Placeholders(count, startIndex int) string {
103107
}
104108

105109
// JSONExtract generates PostgreSQL JSON extraction SQL.
110+
// The field parameter is validated to prevent SQL injection.
106111
func (e *Executor) JSONExtract(column, field string) string {
112+
if !validJSONFieldName.MatchString(field) {
113+
panic(fmt.Sprintf("postgres: invalid JSON field name: %q (must match ^[a-zA-Z_][a-zA-Z0-9_]*$)", field))
114+
}
107115
return fmt.Sprintf("%s->>'%s'", column, field)
108116
}
109117

110118
// JSONExtractPath generates PostgreSQL JSON path extraction SQL.
119+
// All path segments are validated to prevent SQL injection.
111120
func (e *Executor) JSONExtractPath(column string, path []string) string {
112121
if len(path) == 0 {
113122
return column
114123
}
124+
for _, p := range path {
125+
if !validJSONFieldName.MatchString(p) {
126+
panic(fmt.Sprintf("postgres: invalid JSON path segment: %q (must match ^[a-zA-Z_][a-zA-Z0-9_]*$)", p))
127+
}
128+
}
115129
if len(path) == 1 {
116130
return fmt.Sprintf("%s->>'%s'", column, path[0])
117131
}

internal/database/sqlite/executor.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,17 @@ import (
55
"context"
66
"database/sql"
77
"fmt"
8+
"regexp"
89
"strings"
910

1011
_ "modernc.org/sqlite" // Pure Go SQLite driver
1112

1213
"github.com/GainForest/hypergoat/internal/database"
1314
)
1415

16+
// validJSONFieldName matches safe JSON field names to prevent SQL injection.
17+
var validJSONFieldName = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
18+
1519
// Executor implements database.Executor for SQLite.
1620
type Executor struct {
1721
db *sql.DB
@@ -122,12 +126,22 @@ func (e *Executor) Placeholders(count, startIndex int) string {
122126
}
123127

124128
// JSONExtract generates SQLite JSON extraction SQL.
129+
// The field parameter is validated to prevent SQL injection.
125130
func (e *Executor) JSONExtract(column, field string) string {
131+
if !validJSONFieldName.MatchString(field) {
132+
panic(fmt.Sprintf("sqlite: invalid JSON field name: %q (must match ^[a-zA-Z_][a-zA-Z0-9_]*$)", field))
133+
}
126134
return fmt.Sprintf("json_extract(%s, '$.%s')", column, field)
127135
}
128136

129137
// JSONExtractPath generates SQLite JSON path extraction SQL.
138+
// All path segments are validated to prevent SQL injection.
130139
func (e *Executor) JSONExtractPath(column string, path []string) string {
140+
for _, p := range path {
141+
if !validJSONFieldName.MatchString(p) {
142+
panic(fmt.Sprintf("sqlite: invalid JSON path segment: %q (must match ^[a-zA-Z_][a-zA-Z0-9_]*$)", p))
143+
}
144+
}
131145
jsonPath := "$." + strings.Join(path, ".")
132146
return fmt.Sprintf("json_extract(%s, '%s')", column, jsonPath)
133147
}

internal/graphql/admin/handler.go

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,17 @@ import (
1414

1515
// Handler handles admin GraphQL requests with authentication.
1616
type Handler struct {
17-
schema *graphql.Schema
18-
resolver *Resolver
19-
middleware *oauth.AuthMiddleware
20-
configRepo *repositories.ConfigRepository
17+
schema *graphql.Schema
18+
resolver *Resolver
19+
middleware *oauth.AuthMiddleware
20+
configRepo *repositories.ConfigRepository
21+
trustProxyHeaders bool
2122
}
2223

2324
// NewHandler creates a new admin GraphQL handler.
24-
func NewHandler(repos *Repositories, middleware *oauth.AuthMiddleware, configRepo *repositories.ConfigRepository, domainDID string) (*Handler, error) {
25+
// trustProxyHeaders controls whether the X-User-DID header is trusted for authentication.
26+
// This should only be true when running behind a trusted reverse proxy.
27+
func NewHandler(repos *Repositories, middleware *oauth.AuthMiddleware, configRepo *repositories.ConfigRepository, domainDID string, trustProxyHeaders bool) (*Handler, error) {
2528
resolver := NewResolver(repos, domainDID)
2629

2730
builder := NewSchemaBuilder(resolver)
@@ -31,10 +34,11 @@ func NewHandler(repos *Repositories, middleware *oauth.AuthMiddleware, configRep
3134
}
3235

3336
return &Handler{
34-
schema: schema,
35-
resolver: resolver,
36-
middleware: middleware,
37-
configRepo: configRepo,
37+
schema: schema,
38+
resolver: resolver,
39+
middleware: middleware,
40+
configRepo: configRepo,
41+
trustProxyHeaders: trustProxyHeaders,
3842
}, nil
3943
}
4044

@@ -43,7 +47,11 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
4347
// Handle CORS
4448
w.Header().Set("Access-Control-Allow-Origin", "*")
4549
w.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS")
46-
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, DPoP, X-User-DID")
50+
allowedHeaders := "Content-Type, Authorization, DPoP"
51+
if h.trustProxyHeaders {
52+
allowedHeaders += ", X-User-DID"
53+
}
54+
w.Header().Set("Access-Control-Allow-Headers", allowedHeaders)
4755

4856
if r.Method == "OPTIONS" {
4957
w.WriteHeader(http.StatusOK)
@@ -76,9 +84,16 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
7684
ctx := r.Context()
7785
userDID := oauth.UserIDFromContext(ctx)
7886

79-
// Fall back to X-User-DID header if no OAuth token (for Next.js frontend proxy)
80-
if userDID == "" {
87+
// Only trust X-User-DID header when explicitly configured (TRUST_PROXY_HEADERS=true).
88+
// This is intended for deployments behind a trusted reverse proxy (e.g., Next.js frontend).
89+
// WARNING: Without a trusted proxy, this header can be spoofed by any client.
90+
if userDID == "" && h.trustProxyHeaders {
8191
userDID = r.Header.Get("X-User-DID")
92+
if userDID != "" {
93+
slog.Warn("[admin] Auth via X-User-DID proxy header",
94+
"did", userDID,
95+
"remote_addr", r.RemoteAddr)
96+
}
8297
}
8398
handle := "" // Would need to resolve from DID
8499

internal/graphql/admin/resolvers.go

Lines changed: 45 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,10 @@ import (
1212
"errors"
1313
"fmt"
1414
"io"
15+
"log/slog"
1516
"strconv"
1617
"strings"
18+
"sync/atomic"
1719
"time"
1820

1921
"github.com/GainForest/hypergoat/internal/database/repositories"
@@ -47,7 +49,7 @@ type LexiconChangeCallback func(collections []string) error
4749
// Resolver provides methods for resolving admin GraphQL queries and mutations.
4850
type Resolver struct {
4951
repos *Repositories
50-
backfillActive bool
52+
backfillActive atomic.Bool
5153
domainDID string // The DID of this labeler instance
5254
backfillCallback BackfillCallback
5355
fullBackfillCallback FullBackfillCallback
@@ -95,7 +97,7 @@ func (r *Resolver) notifyLexiconChange(ctx context.Context) {
9597

9698
if err := r.lexiconChangeCallback(collections); err != nil {
9799
// Log but don't fail the operation
98-
fmt.Printf("Failed to notify lexicon change: %v\n", err)
100+
slog.Warn("Failed to notify lexicon change", "error", err)
99101
}
100102
}
101103

@@ -175,12 +177,12 @@ func (r *Resolver) Settings(ctx context.Context) (map[string]interface{}, error)
175177

176178
// IsBackfilling returns whether a backfill is currently active.
177179
func (r *Resolver) IsBackfilling() bool {
178-
return r.backfillActive
180+
return r.backfillActive.Load()
179181
}
180182

181183
// SetBackfillActive sets the backfill status.
182184
func (r *Resolver) SetBackfillActive(active bool) {
183-
r.backfillActive = active
185+
r.backfillActive.Store(active)
184186
}
185187

186188
// Lexicons returns all lexicon definitions.
@@ -225,8 +227,22 @@ func (r *Resolver) OAuthClients(ctx context.Context) ([]map[string]interface{},
225227
return result, nil
226228
}
227229

230+
// Upload size limits for lexicon ZIP files.
231+
const (
232+
maxLexiconUploadBytes = 10 * 1024 * 1024 // 10MB max ZIP size
233+
maxLexiconFileCount = 500 // Max files in ZIP
234+
maxLexiconFileSize = 1 * 1024 * 1024 // 1MB max per file
235+
)
236+
228237
// UploadLexicons extracts lexicons from a base64-encoded ZIP file.
229238
func (r *Resolver) UploadLexicons(ctx context.Context, zipBase64 string) (int, error) {
239+
// Validate base64 input size before decoding (base64 encodes 3 bytes as 4 chars)
240+
maxBase64Len := maxLexiconUploadBytes * 4 / 3
241+
if len(zipBase64) > maxBase64Len {
242+
return 0, fmt.Errorf("upload too large: estimated %d bytes exceeds %d byte limit",
243+
len(zipBase64)*3/4, maxLexiconUploadBytes)
244+
}
245+
230246
// Decode base64
231247
zipData, err := base64.StdEncoding.DecodeString(zipBase64)
232248
if err != nil {
@@ -239,6 +255,12 @@ func (r *Resolver) UploadLexicons(ctx context.Context, zipBase64 string) (int, e
239255
return 0, fmt.Errorf("invalid ZIP file: %w", err)
240256
}
241257

258+
// Check file count to prevent zip bombs
259+
if len(zipReader.File) > maxLexiconFileCount {
260+
return 0, fmt.Errorf("too many files in ZIP: %d exceeds limit of %d",
261+
len(zipReader.File), maxLexiconFileCount)
262+
}
263+
242264
// Process each file
243265
count := 0
244266
for _, file := range zipReader.File {
@@ -247,17 +269,27 @@ func (r *Resolver) UploadLexicons(ctx context.Context, zipBase64 string) (int, e
247269
continue
248270
}
249271

250-
// Open and read file
272+
// Check individual uncompressed file size
273+
if file.UncompressedSize64 > maxLexiconFileSize {
274+
return count, fmt.Errorf("file %s too large: %d bytes exceeds %d byte limit",
275+
file.Name, file.UncompressedSize64, maxLexiconFileSize)
276+
}
277+
278+
// Open and read file with size limit
251279
rc, err := file.Open()
252280
if err != nil {
253281
continue
254282
}
255283

256-
data, err := io.ReadAll(rc)
284+
data, err := io.ReadAll(io.LimitReader(rc, maxLexiconFileSize+1))
257285
rc.Close()
258286
if err != nil {
259287
continue
260288
}
289+
if len(data) > maxLexiconFileSize {
290+
return count, fmt.Errorf("file %s exceeds %d byte limit after decompression",
291+
file.Name, maxLexiconFileSize)
292+
}
261293

262294
// Parse JSON to extract lexicon ID
263295
var lexicon struct {
@@ -287,26 +319,24 @@ func (r *Resolver) UploadLexicons(ctx context.Context, zipBase64 string) (int, e
287319
}
288320

289321
// TriggerBackfill starts a full backfill process.
322+
// Uses atomic CompareAndSwap to prevent concurrent backfill launches (race-safe).
290323
func (r *Resolver) TriggerBackfill(ctx context.Context) (bool, error) {
291-
if r.backfillActive {
292-
return false, fmt.Errorf("backfill already in progress")
293-
}
294-
295324
if r.fullBackfillCallback == nil {
296325
return false, fmt.Errorf("full backfill not configured")
297326
}
298327

299-
r.backfillActive = true
328+
// Atomically check-and-set to prevent concurrent backfill launches
329+
if !r.backfillActive.CompareAndSwap(false, true) {
330+
return false, fmt.Errorf("backfill already in progress")
331+
}
300332

301333
// Run backfill in background goroutine
302334
go func() {
303-
defer func() {
304-
r.backfillActive = false
305-
}()
335+
defer r.backfillActive.Store(false)
306336

307337
// Use background context since HTTP request context will be cancelled
308338
if err := r.fullBackfillCallback(context.Background()); err != nil {
309-
// Error is logged by the callback
339+
slog.Error("[backfill] Full backfill failed in background", "error", err)
310340
return
311341
}
312342
}()

0 commit comments

Comments
 (0)