diff --git a/Server/admin/admin.go b/Server/admin/admin.go index a97cc115..d459409e 100644 --- a/Server/admin/admin.go +++ b/Server/admin/admin.go @@ -48,7 +48,7 @@ func NewHandler(database *db.DB, version string, hub HubBroadcaster, u *updater. r.Get("/", func(w http.ResponseWriter, req *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Security-Policy", - "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'") + "default-src 'self'; style-src 'self'; script-src 'self'") _, _ = w.Write(indexHTML) }) r.Handle("/*", http.FileServer(http.FS(staticFS))) diff --git a/Server/admin/api.go b/Server/admin/api.go index 45be6303..95761ffb 100644 --- a/Server/admin/api.go +++ b/Server/admin/api.go @@ -4,6 +4,7 @@ import ( "net/http" "github.com/go-chi/chi/v5" + "github.com/owncord/server/auth" "github.com/owncord/server/db" "github.com/owncord/server/updater" ) @@ -17,8 +18,9 @@ func NewAdminAPI(database *db.DB, version string, hub HubBroadcaster, u *updater r := chi.NewRouter() // Setup endpoints — unauthenticated, only functional when no users exist. + setupLimiter := auth.NewRateLimiter() r.Get("/setup/status", handleSetupStatus(database)) - r.Post("/setup", handleSetup(database)) + r.Post("/setup", handleSetup(database, setupLimiter)) // SSE log stream — auth is via a single-use ticket from POST /logs/ticket. // EventSource cannot send Authorization headers, so the client first diff --git a/Server/admin/helpers.go b/Server/admin/helpers.go index 8088a531..81d61cb6 100644 --- a/Server/admin/helpers.go +++ b/Server/admin/helpers.go @@ -2,6 +2,7 @@ package admin import ( "encoding/json" + "log/slog" "net/http" "strconv" @@ -19,7 +20,9 @@ type errorResponse struct { func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(status) - _ = json.NewEncoder(w).Encode(v) + if err := json.NewEncoder(w).Encode(v); err != nil { + slog.Error("writeJSON: failed to encode response", "error", err) + } } func writeErr(w http.ResponseWriter, status int, code, msg string) { diff --git a/Server/admin/setup_handler.go b/Server/admin/setup_handler.go index 8809888f..ebc37d8c 100644 --- a/Server/admin/setup_handler.go +++ b/Server/admin/setup_handler.go @@ -13,15 +13,6 @@ import ( "github.com/owncord/server/db" ) -// setupLimiter restricts setup attempts to prevent brute-force attacks -// against the initial owner account creation endpoint. -var setupLimiter = auth.NewRateLimiter() - -// ResetSetupLimiter resets the setup rate limiter. Exported for tests only. -func ResetSetupLimiter() { - setupLimiter = auth.NewRateLimiter() -} - // setupSanitizer strips all HTML from user input during setup. var setupSanitizer = bluemonday.StrictPolicy() @@ -61,7 +52,7 @@ func handleSetupStatus(database *db.DB) http.HandlerFunc { // handleSetup creates the first owner account. It only works when no users // exist in the database, preventing abuse after initial setup. -func handleSetup(database *db.DB) http.HandlerFunc { +func handleSetup(database *db.DB, limiter *auth.RateLimiter) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { // Rate limit: 5 attempts per minute per IP. // Strip the port so that different source ports from the same IP @@ -71,7 +62,7 @@ func handleSetup(database *db.DB) http.HandlerFunc { host = r.RemoteAddr } setupKey := "setup:" + host - if !setupLimiter.Allow(setupKey, 5, time.Minute) { + if !limiter.Allow(setupKey, 5, time.Minute) { writeErr(w, http.StatusTooManyRequests, "RATE_LIMITED", "too many setup attempts, try again later") return } @@ -132,6 +123,10 @@ func handleSetup(database *db.DB) http.HandlerFunc { } device := r.Header.Get("User-Agent") + const maxDeviceLen = 512 + if len(device) > maxDeviceLen { + device = device[:maxDeviceLen] + } if _, err := database.CreateSession(uid, auth.HashToken(token), device, host); err != nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to create session") return diff --git a/Server/admin/setup_handler_test.go b/Server/admin/setup_handler_test.go index 490a8a78..e51cf709 100644 --- a/Server/admin/setup_handler_test.go +++ b/Server/admin/setup_handler_test.go @@ -50,7 +50,6 @@ func TestSetupStatus_NoSetupNeeded(t *testing.T) { } func TestSetup_CreatesOwner(t *testing.T) { - admin.ResetSetupLimiter() database := openAdminTestDB(t) handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil) @@ -96,7 +95,6 @@ func TestSetup_CreatesOwner(t *testing.T) { } func TestSetup_BlockedAfterFirstUser(t *testing.T) { - admin.ResetSetupLimiter() database := openAdminTestDB(t) handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil) @@ -120,7 +118,6 @@ func TestSetup_BlockedAfterFirstUser(t *testing.T) { } func TestSetup_WeakPassword(t *testing.T) { - admin.ResetSetupLimiter() database := openAdminTestDB(t) handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil) @@ -134,7 +131,6 @@ func TestSetup_WeakPassword(t *testing.T) { } func TestSetup_MissingFields(t *testing.T) { - admin.ResetSetupLimiter() database := openAdminTestDB(t) handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil) diff --git a/Server/api/auth_handler.go b/Server/api/auth_handler.go index a900936a..956519ff 100644 --- a/Server/api/auth_handler.go +++ b/Server/api/auth_handler.go @@ -127,7 +127,7 @@ func handleRegister(database *db.DB) http.HandlerFunc { registrationOpen, err := isRegistrationOpen(database) if err != nil { writeJSON(w, http.StatusInternalServerError, errorResponse{ - Error: "SERVER_ERROR", + Error: "INTERNAL_ERROR", Message: "failed to load registration policy", }) return @@ -143,7 +143,7 @@ func handleRegister(database *db.DB) http.HandlerFunc { require2FA, err := isRequire2FAEnabled(database) if err != nil { writeJSON(w, http.StatusInternalServerError, errorResponse{ - Error: "SERVER_ERROR", + Error: "INTERNAL_ERROR", Message: "failed to load registration policy", }) return @@ -199,7 +199,7 @@ func handleRegister(database *db.DB) http.HandlerFunc { hash, err := auth.HashPassword(req.Password) if err != nil { writeJSON(w, http.StatusInternalServerError, errorResponse{ - Error: "SERVER_ERROR", + Error: "INTERNAL_ERROR", Message: "failed to process registration", }) return @@ -218,7 +218,7 @@ func handleRegister(database *db.DB) http.HandlerFunc { } else { slog.Error("CreateUserWithInvite failed", "err", err, "username", req.Username) writeJSON(w, http.StatusInternalServerError, errorResponse{ - Error: "SERVER_ERROR", + Error: "INTERNAL_ERROR", Message: "registration failed — please try again", }) } @@ -234,16 +234,16 @@ func handleRegister(database *db.DB) http.HandlerFunc { token, err := auth.GenerateToken() if err != nil { writeJSON(w, http.StatusInternalServerError, errorResponse{ - Error: "SERVER_ERROR", + Error: "INTERNAL_ERROR", Message: "failed to create session", }) return } - device := r.Header.Get("User-Agent") + device := truncateDevice(r.Header.Get("User-Agent")) if _, err := database.CreateSession(uid, auth.HashToken(token), device, ip); err != nil { writeJSON(w, http.StatusInternalServerError, errorResponse{ - Error: "SERVER_ERROR", + Error: "INTERNAL_ERROR", Message: "failed to create session", }) return @@ -253,7 +253,7 @@ func handleRegister(database *db.DB) http.HandlerFunc { if err != nil || user == nil { slog.Error("failed to fetch user after registration", "user_id", uid, "error", err) writeJSON(w, http.StatusInternalServerError, errorResponse{ - Error: "SERVER_ERROR", + Error: "INTERNAL_ERROR", Message: "registration succeeded but user fetch failed", }) return @@ -315,7 +315,7 @@ func handleLogin(database *db.DB, limiter *auth.RateLimiter, partialStore *auth. // non-nil error here is a genuine DB failure. slog.Error("login: GetUserByUsername failed", "err", err, "ip", ip) writeJSON(w, http.StatusInternalServerError, errorResponse{ - Error: "SERVER_ERROR", + Error: "INTERNAL_ERROR", Message: "login temporarily unavailable", }) return @@ -352,16 +352,16 @@ func handleLogin(database *db.DB, limiter *auth.RateLimiter, partialStore *auth. require2FA, err := isRequire2FAEnabled(database) if err != nil { writeJSON(w, http.StatusInternalServerError, errorResponse{ - Error: "SERVER_ERROR", + Error: "INTERNAL_ERROR", Message: "failed to load authentication policy", }) return } if user.TOTPSecret != nil { - partialToken, err := partialStore.Issue(user.ID, r.Header.Get("User-Agent"), ip) + partialToken, err := partialStore.Issue(user.ID, truncateDevice(r.Header.Get("User-Agent")), ip) if err != nil { writeJSON(w, http.StatusInternalServerError, errorResponse{ - Error: "SERVER_ERROR", + Error: "INTERNAL_ERROR", Message: "failed to start two-factor challenge", }) return @@ -381,10 +381,10 @@ func handleLogin(database *db.DB, limiter *auth.RateLimiter, partialStore *auth. } // Issue session. - token, err := issueSession(database, user.ID, r.Header.Get("User-Agent"), ip) + token, err := issueSession(database, user.ID, truncateDevice(r.Header.Get("User-Agent")), ip) if err != nil { writeJSON(w, http.StatusInternalServerError, errorResponse{ - Error: "SERVER_ERROR", + Error: "INTERNAL_ERROR", Message: "failed to create session", }) return @@ -463,7 +463,7 @@ func handleVerifyTOTP(database *db.DB, partialStore *auth.PartialAuthStore) http token, err := issueSession(database, user.ID, challenge.Device, challenge.IP) if err != nil { writeJSON(w, http.StatusInternalServerError, errorResponse{ - Error: "SERVER_ERROR", + Error: "INTERNAL_ERROR", Message: "failed to create session", }) return @@ -507,7 +507,7 @@ func handleEnableTOTP(pendingStore *auth.PendingTOTPStore) http.HandlerFunc { secret, err := auth.GenerateTOTPSecret() if err != nil { writeJSON(w, http.StatusInternalServerError, errorResponse{ - Error: "SERVER_ERROR", + Error: "INTERNAL_ERROR", Message: "failed to generate two-factor secret", }) return @@ -567,7 +567,7 @@ func handleConfirmTOTP(database *db.DB, pendingStore *auth.PendingTOTPStore) htt if err := database.UpdateUserTOTPSecret(user.ID, &secret); err != nil { writeJSON(w, http.StatusInternalServerError, errorResponse{ - Error: "SERVER_ERROR", + Error: "INTERNAL_ERROR", Message: "failed to enable two-factor authentication", }) return @@ -607,7 +607,7 @@ func handleDisableTOTP(database *db.DB, pendingStore *auth.PendingTOTPStore) htt require2FA, err := isRequire2FAEnabled(database) if err != nil { writeJSON(w, http.StatusInternalServerError, errorResponse{ - Error: "SERVER_ERROR", + Error: "INTERNAL_ERROR", Message: "failed to load authentication policy", }) return @@ -623,7 +623,7 @@ func handleDisableTOTP(database *db.DB, pendingStore *auth.PendingTOTPStore) htt pendingStore.Delete(user.ID) if err := database.UpdateUserTOTPSecret(user.ID, nil); err != nil { writeJSON(w, http.StatusInternalServerError, errorResponse{ - Error: "SERVER_ERROR", + Error: "INTERNAL_ERROR", Message: "failed to disable two-factor authentication", }) return @@ -646,7 +646,7 @@ func handleLogout(database *db.DB) http.HandlerFunc { if err := database.DeleteSession(sess.TokenHash); err != nil { writeJSON(w, http.StatusInternalServerError, errorResponse{ - Error: "SERVER_ERROR", + Error: "INTERNAL_ERROR", Message: "failed to logout", }) return @@ -744,7 +744,7 @@ func handleDeleteAccount(database *db.DB, limiter *auth.RateLimiter) http.Handle } slog.Error("DeleteAccount failed", "err", err, "user_id", user.ID) writeJSON(w, http.StatusInternalServerError, errorResponse{ - Error: "SERVER_ERROR", + Error: "INTERNAL_ERROR", Message: "failed to delete account", }) return @@ -777,6 +777,16 @@ func toUserResponse(u *db.User) *userResponse { return resp } +// truncateDevice truncates the User-Agent to prevent oversized session records. +const maxDeviceLen = 512 + +func truncateDevice(ua string) string { + if len(ua) > maxDeviceLen { + return ua[:maxDeviceLen] + } + return ua +} + func issueSession(database *db.DB, userID int64, device, ip string) (string, error) { token, err := auth.GenerateToken() if err != nil { diff --git a/Server/api/channel_handler.go b/Server/api/channel_handler.go index fbe004e9..62e9c10a 100644 --- a/Server/api/channel_handler.go +++ b/Server/api/channel_handler.go @@ -105,7 +105,7 @@ func handleListChannels(database *db.DB) http.HandlerFunc { if err != nil { slog.Error("handleListChannels ListChannels", "err", err) writeJSON(w, http.StatusInternalServerError, errorResponse{ - Error: "INTERNAL", + Error: "INTERNAL_ERROR", Message: "failed to list channels", }) return @@ -119,7 +119,7 @@ func handleListChannels(database *db.DB) http.HandlerFunc { if oErr != nil { slog.Error("handleListChannels GetAllChannelPermissionsForRole", "err", oErr) writeJSON(w, http.StatusInternalServerError, errorResponse{ - Error: "INTERNAL", + Error: "INTERNAL_ERROR", Message: "failed to fetch channel permissions", }) return @@ -153,7 +153,7 @@ func handleGetMessages(database *db.DB) http.HandlerFunc { if err != nil { slog.Error("handleGetMessages GetChannel", "err", err, "channel_id", channelID) writeJSON(w, http.StatusInternalServerError, errorResponse{ - Error: "INTERNAL", + Error: "INTERNAL_ERROR", Message: "failed to look up channel", }) return @@ -236,7 +236,7 @@ func handleGetMessages(database *db.DB) http.HandlerFunc { if err != nil { slog.Error("handleGetMessages GetMessagesForAPI", "err", err, "channel_id", channelID) writeJSON(w, http.StatusInternalServerError, errorResponse{ - Error: "INTERNAL", + Error: "INTERNAL_ERROR", Message: "failed to fetch messages", }) return @@ -309,7 +309,7 @@ func handleSearch(database *db.DB) http.HandlerFunc { } slog.Error("handleSearch SearchMessages", "err", err, "query", q) writeJSON(w, http.StatusInternalServerError, errorResponse{ - Error: "INTERNAL", + Error: "INTERNAL_ERROR", Message: "search failed", }) return @@ -325,7 +325,7 @@ func handleSearch(database *db.DB) http.HandlerFunc { if oErr != nil { slog.Error("handleSearch GetAllChannelPermissionsForRole", "err", oErr) writeJSON(w, http.StatusInternalServerError, errorResponse{ - Error: "INTERNAL", + Error: "INTERNAL_ERROR", Message: "search failed", }) return @@ -345,7 +345,7 @@ func handleSearch(database *db.DB) http.HandlerFunc { if ctErr != nil { slog.Error("handleSearch GetChannelTypes", "err", ctErr) writeJSON(w, http.StatusInternalServerError, errorResponse{ - Error: "INTERNAL", + Error: "INTERNAL_ERROR", Message: "search failed", }) return @@ -397,7 +397,7 @@ func handleGetPins(database *db.DB) http.HandlerFunc { if err != nil { slog.Error("handleGetPins GetChannel", "err", err, "channel_id", channelID) writeJSON(w, http.StatusInternalServerError, errorResponse{ - Error: "INTERNAL", + Error: "INTERNAL_ERROR", Message: "failed to look up channel", }) return @@ -450,7 +450,7 @@ func handleGetPins(database *db.DB) http.HandlerFunc { if err != nil { slog.Error("handleGetPins GetPinnedMessages", "err", err, "channel_id", channelID) writeJSON(w, http.StatusInternalServerError, errorResponse{ - Error: "INTERNAL", + Error: "INTERNAL_ERROR", Message: "failed to fetch pinned messages", }) return @@ -486,7 +486,7 @@ func handleSetPinned(database *db.DB, pinned bool) http.HandlerFunc { if chErr != nil { slog.Error("handleSetPinned GetChannel", "err", chErr, "channel_id", channelID) writeJSON(w, http.StatusInternalServerError, errorResponse{ - Error: "INTERNAL", + Error: "INTERNAL_ERROR", Message: "failed to look up channel", }) return @@ -534,7 +534,7 @@ func handleSetPinned(database *db.DB, pinned bool) http.HandlerFunc { if err != nil { slog.Error("handleSetPinned GetMessage", "err", err, "action", action, "message_id", messageID) writeJSON(w, http.StatusInternalServerError, errorResponse{ - Error: "INTERNAL", + Error: "INTERNAL_ERROR", Message: "failed to look up message", }) return @@ -550,7 +550,7 @@ func handleSetPinned(database *db.DB, pinned bool) http.HandlerFunc { if err := database.SetMessagePinned(messageID, pinned); err != nil { slog.Error("handleSetPinned SetMessagePinned", "err", err, "action", action, "message_id", messageID) writeJSON(w, http.StatusInternalServerError, errorResponse{ - Error: "INTERNAL", + Error: "INTERNAL_ERROR", Message: "failed to " + action + " message", }) return diff --git a/Server/api/dm_handler.go b/Server/api/dm_handler.go index 324857a6..61fabc49 100644 --- a/Server/api/dm_handler.go +++ b/Server/api/dm_handler.go @@ -88,7 +88,7 @@ func handleCreateDM(database *db.DB) http.HandlerFunc { if err != nil { slog.Error("handleCreateDM GetUserByID", "err", err, "recipient_id", req.RecipientID) writeJSON(w, http.StatusInternalServerError, errorResponse{ - Error: "INTERNAL", + Error: "INTERNAL_ERROR", Message: "failed to look up recipient", }) return @@ -107,7 +107,7 @@ func handleCreateDM(database *db.DB) http.HandlerFunc { slog.Error("handleCreateDM GetOrCreateDMChannel", "err", err, "user_id", user.ID, "recipient_id", req.RecipientID) writeJSON(w, http.StatusInternalServerError, errorResponse{ - Error: "INTERNAL", + Error: "INTERNAL_ERROR", Message: "failed to create DM channel", }) return @@ -154,7 +154,7 @@ func handleListDMs(database *db.DB) http.HandlerFunc { if err != nil { slog.Error("handleListDMs GetUserDMChannels", "err", err, "user_id", user.ID) writeJSON(w, http.StatusInternalServerError, errorResponse{ - Error: "INTERNAL", + Error: "INTERNAL_ERROR", Message: "failed to list DM channels", }) return @@ -187,7 +187,7 @@ func handleCloseDM(database *db.DB, broadcaster DMBroadcaster) http.HandlerFunc slog.Error("handleCloseDM IsDMParticipant", "err", err, "user_id", user.ID, "channel_id", channelID) writeJSON(w, http.StatusInternalServerError, errorResponse{ - Error: "INTERNAL", + Error: "INTERNAL_ERROR", Message: "failed to verify DM participation", }) return @@ -204,7 +204,7 @@ func handleCloseDM(database *db.DB, broadcaster DMBroadcaster) http.HandlerFunc slog.Error("handleCloseDM CloseDM", "err", err, "user_id", user.ID, "channel_id", channelID) writeJSON(w, http.StatusInternalServerError, errorResponse{ - Error: "INTERNAL", + Error: "INTERNAL_ERROR", Message: "failed to close DM", }) return diff --git a/Server/api/invite_handler.go b/Server/api/invite_handler.go index 2dc679dc..a02b5fca 100644 --- a/Server/api/invite_handler.go +++ b/Server/api/invite_handler.go @@ -78,7 +78,7 @@ func handleCreateInvite(database *db.DB) http.HandlerFunc { if err != nil { slog.Error("handleCreateInvite CreateInvite", "err", err, "user_id", user.ID) writeJSON(w, http.StatusInternalServerError, errorResponse{ - Error: "SERVER_ERROR", + Error: "INTERNAL_ERROR", Message: "failed to create invite", }) return @@ -88,7 +88,7 @@ func handleCreateInvite(database *db.DB) http.HandlerFunc { if err != nil || inv == nil { slog.Error("handleCreateInvite GetInvite", "err", err, "code", code) writeJSON(w, http.StatusInternalServerError, errorResponse{ - Error: "SERVER_ERROR", + Error: "INTERNAL_ERROR", Message: "failed to retrieve invite", }) return @@ -105,7 +105,7 @@ func handleListInvites(database *db.DB) http.HandlerFunc { if err != nil { slog.Error("handleListInvites ListInvites", "err", err) writeJSON(w, http.StatusInternalServerError, errorResponse{ - Error: "SERVER_ERROR", + Error: "INTERNAL_ERROR", Message: "failed to list invites", }) return @@ -128,7 +128,7 @@ func handleRevokeInvite(database *db.DB) http.HandlerFunc { if err != nil { slog.Error("handleRevokeInvite GetInvite", "err", err, "code", code) writeJSON(w, http.StatusInternalServerError, errorResponse{ - Error: "SERVER_ERROR", + Error: "INTERNAL_ERROR", Message: "failed to look up invite", }) return @@ -144,7 +144,7 @@ func handleRevokeInvite(database *db.DB) http.HandlerFunc { if err := database.RevokeInvite(code); err != nil { slog.Error("handleRevokeInvite RevokeInvite", "err", err, "code", code) writeJSON(w, http.StatusInternalServerError, errorResponse{ - Error: "SERVER_ERROR", + Error: "INTERNAL_ERROR", Message: "failed to revoke invite", }) return diff --git a/Server/api/middleware.go b/Server/api/middleware.go index 0a39d363..25aa2f95 100644 --- a/Server/api/middleware.go +++ b/Server/api/middleware.go @@ -324,16 +324,20 @@ func MaxBodySize(maxBytes int64) func(http.Handler) http.Handler { } } -// MaxBodySizeUnless is like MaxBodySize but skips the limit for specific paths. -// Exempted paths apply their own limit via route-scoped middleware. -func MaxBodySizeUnless(maxBytes int64, exemptPaths ...string) func(http.Handler) http.Handler { - exempt := make(map[string]bool, len(exemptPaths)) - for _, p := range exemptPaths { - exempt[p] = true - } +// MaxBodySizeUnless is like MaxBodySize but skips the limit for paths that +// match any of the given prefixes. Exempted paths apply their own limit via +// route-scoped middleware. +func MaxBodySizeUnless(maxBytes int64, exemptPrefixes ...string) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if !exempt[r.URL.Path] { + exempt := false + for _, prefix := range exemptPrefixes { + if strings.HasPrefix(r.URL.Path, prefix) { + exempt = true + break + } + } + if !exempt { r.Body = http.MaxBytesReader(w, r.Body, maxBytes) } next.ServeHTTP(w, r) diff --git a/Server/api/router.go b/Server/api/router.go index c6412021..ba1fe6a8 100644 --- a/Server/api/router.go +++ b/Server/api/router.go @@ -308,5 +308,7 @@ func requestLogger(next http.Handler) http.Handler { func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(status) - _ = json.NewEncoder(w).Encode(v) + if err := json.NewEncoder(w).Encode(v); err != nil { + slog.Error("writeJSON: failed to encode response", "error", err) + } } diff --git a/Server/api/upload_handler.go b/Server/api/upload_handler.go index 2b51da46..95ababf1 100644 --- a/Server/api/upload_handler.go +++ b/Server/api/upload_handler.go @@ -1,14 +1,17 @@ package api import ( + "errors" "fmt" "image" _ "image/gif" _ "image/jpeg" _ "image/png" + "io" "log/slog" "mime" "net/http" + "path/filepath" "strings" "time" @@ -29,6 +32,29 @@ type uploadResponse struct { Height *int `json:"height,omitempty"` } +// sanitizeUploadFilename cleans an upload filename: strips control characters, +// removes path separators, and truncates to a safe length. +func sanitizeUploadFilename(name string) string { + // Strip path components — use only the base name. + name = filepath.Base(name) + // Remove control characters. + var sb strings.Builder + for _, r := range name { + if r >= 32 && r != 127 { // exclude control chars and DEL + sb.WriteRune(r) + } + } + name = strings.TrimSpace(sb.String()) + // Truncate to 255 characters (filesystem limit). + if len(name) > 255 { + name = name[:255] + } + if name == "" || name == "." || name == ".." { + name = "unnamed" + } + return name +} + // MountUploadRoutes registers upload and file-serving endpoints. // allowedOrigins controls the Access-Control-Allow-Origin header on served files. func MountUploadRoutes(r chi.Router, database *db.DB, store *storage.Storage, allowedOrigins []string) { @@ -68,7 +94,7 @@ func handleUpload(database *db.DB, store *storage.Storage) http.HandlerFunc { // Detect MIME type from actual file bytes (never trust client header). var sniffBuf [512]byte n, readErr := file.Read(sniffBuf[:]) - if readErr != nil && readErr.Error() != "EOF" && readErr.Error() != "unexpected EOF" { + if readErr != nil && !errors.Is(readErr, io.EOF) && !errors.Is(readErr, io.ErrUnexpectedEOF) { writeJSON(w, http.StatusBadRequest, map[string]string{ "error": "BAD_REQUEST", "message": "failed to read uploaded file", @@ -114,7 +140,8 @@ func handleUpload(database *db.DB, store *storage.Storage) http.HandlerFunc { } // Insert attachment record in DB (unlinked — message_id is NULL). - if err := database.CreateAttachment(fileID, header.Filename, fileID, mime, header.Size, width, height); err != nil { + safeFilename := sanitizeUploadFilename(header.Filename) + if err := database.CreateAttachment(fileID, safeFilename, fileID, mime, header.Size, width, height); err != nil { // Clean up stored file on DB failure. _ = store.Delete(fileID) slog.Error("failed to create attachment record", "error", err) @@ -125,11 +152,11 @@ func handleUpload(database *db.DB, store *storage.Storage) http.HandlerFunc { return } - slog.Info("file uploaded", "id", fileID, "filename", header.Filename, "size", header.Size, "mime", mime) + slog.Info("file uploaded", "id", fileID, "filename", safeFilename, "size", header.Size, "mime", mime) writeJSON(w, http.StatusCreated, uploadResponse{ ID: fileID, - Filename: header.Filename, + Filename: safeFilename, Size: header.Size, Mime: mime, URL: "/api/v1/files/" + fileID, diff --git a/Server/auth/password.go b/Server/auth/password.go index 9e31ce61..29752700 100644 --- a/Server/auth/password.go +++ b/Server/auth/password.go @@ -2,6 +2,7 @@ package auth import ( "errors" + "sync" "golang.org/x/crypto/bcrypt" ) @@ -27,18 +28,28 @@ func HashPassword(password string) (string, error) { return string(hash), nil } -// dummyHash is a pre-computed bcrypt hash used to prevent timing side-channels -// when the user does not exist. Comparing against this dummy ensures that -// CheckPassword takes roughly constant time regardless of whether a valid hash -// was supplied. -var dummyHash []byte +// dummyHash is a lazily-computed bcrypt hash used to prevent timing +// side-channels when the user does not exist. Comparing against this dummy +// ensures that CheckPassword takes roughly constant time regardless of +// whether a valid hash was supplied. +var ( + dummyHash []byte + dummyHashOnce sync.Once +) -func init() { - h, err := bcrypt.GenerateFromPassword([]byte("dummy-timing-pad"), bcryptCost) - if err != nil { - panic("auth: failed to generate dummy bcrypt hash: " + err.Error()) - } - dummyHash = h +// getDummyHash returns the pre-computed dummy bcrypt hash, initialising it +// on first call via sync.Once. +func getDummyHash() []byte { + dummyHashOnce.Do(func() { + h, err := bcrypt.GenerateFromPassword([]byte("dummy-timing-pad"), bcryptCost) + if err != nil { + // crypto/rand is required for the server to function; panic is + // appropriate here as there is no recovery path. + panic("auth: failed to generate dummy bcrypt hash: " + err.Error()) + } + dummyHash = h + }) + return dummyHash } // CheckPassword reports whether password matches hash. Returns false on any @@ -52,7 +63,7 @@ func CheckPassword(hash, password string) bool { // The error is intentionally discarded: we always return false here. // The comparison is performed only to consume time and prevent // timing-based username enumeration. - _ = bcrypt.CompareHashAndPassword(dummyHash, []byte(password)) + _ = bcrypt.CompareHashAndPassword(getDummyHash(), []byte(password)) return false } err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) diff --git a/Server/config/config.go b/Server/config/config.go index 835a9249..d720b9ce 100644 --- a/Server/config/config.go +++ b/Server/config/config.go @@ -208,7 +208,9 @@ func Load(cfgPath string) (*Config, error) { // Apply voice defaults for zero-value fields (koanf loses defaults when // the YAML section is present but fields are commented out / omitted). - applyVoiceDefaults(&cfg.Voice) + if err := applyVoiceDefaults(&cfg.Voice); err != nil { + return nil, fmt.Errorf("applying voice defaults: %w", err) + } // Warn if using default dev credentials — these are public and insecure. // Clear credentials so downstream consumers (e.g. NewLiveKitClient) see @@ -238,12 +240,12 @@ func IsDefaultVoiceCredentials(v *VoiceConfig) bool { } // generateRandomKey returns a crypto-random hex string of the given byte length. -func generateRandomKey(byteLen int) string { +func generateRandomKey(byteLen int) (string, error) { b := make([]byte, byteLen) if _, err := rand.Read(b); err != nil { - panic("crypto/rand failed: " + err.Error()) + return "", fmt.Errorf("crypto/rand: %w", err) } - return hex.EncodeToString(b) + return hex.EncodeToString(b), nil } // applyVoiceDefaults fills in zero-value voice fields with sensible defaults. @@ -251,13 +253,21 @@ func generateRandomKey(byteLen int) string { // overwrites struct defaults with Go zero values. // When API key/secret are empty, unique random credentials are generated // so voice works out of the box without shipping known-public defaults. -func applyVoiceDefaults(v *VoiceConfig) { +func applyVoiceDefaults(v *VoiceConfig) error { if v.LiveKitAPIKey == "" { - v.LiveKitAPIKey = "key-" + generateRandomKey(8) + key, err := generateRandomKey(8) + if err != nil { + return fmt.Errorf("generating LiveKit API key: %w", err) + } + v.LiveKitAPIKey = "key-" + key slog.Warn("generated random LiveKit API key — voice tokens will break on restart; set voice.livekit_api_key in config.yaml for stable operation") } if v.LiveKitAPISecret == "" { - v.LiveKitAPISecret = generateRandomKey(32) // 64 hex chars, well above 32-char minimum + secret, err := generateRandomKey(32) + if err != nil { + return fmt.Errorf("generating LiveKit API secret: %w", err) + } + v.LiveKitAPISecret = secret slog.Warn("generated random LiveKit API secret — set voice.livekit_api_secret in config.yaml for stable operation") } if v.LiveKitURL == "" { @@ -266,6 +276,7 @@ func applyVoiceDefaults(v *VoiceConfig) { if v.Quality == "" { v.Quality = "medium" } + return nil } // validateYAML checks that raw bytes are valid YAML. diff --git a/Server/db/migrate.go b/Server/db/migrate.go index 5f78ad1d..0278a1c9 100644 --- a/Server/db/migrate.go +++ b/Server/db/migrate.go @@ -111,11 +111,21 @@ func sqlFilenames(fsys fs.FS) ([]string, error) { // without executing them. This is called once when upgrading a pre-tracking // database. func seedExistingDatabase(d *DB, filenames []string) error { + tx, err := d.sqlDB.Begin() + if err != nil { + return fmt.Errorf("begin seed tx: %w", err) + } for _, name := range filenames { - if err := recordApplied(d, name); err != nil { - return fmt.Errorf("seeding %s: %w", name, err) + if _, execErr := tx.Exec( + "INSERT INTO schema_versions (version) VALUES (?)", name, + ); execErr != nil { + _ = tx.Rollback() + return fmt.Errorf("seeding %s: %w", name, execErr) } } + if commitErr := tx.Commit(); commitErr != nil { + return fmt.Errorf("commit seed tx: %w", commitErr) + } return nil }