mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
fix: address remaining code review findings (C-3, H-5, H-6, M-1 through M-16)
- C-3: inject setupLimiter into NewAdminAPI instead of package-level global - H-5: generateRandomKey returns error instead of panicking - H-6: replace init() bcrypt with sync.Once lazy initialization - M-2: remove unsafe-inline from admin CSP script-src and style-src - M-3: sanitize upload filenames (strip control chars, truncate to 255) - M-5: truncate User-Agent to 512 bytes before storing as device - M-10: MaxBodySizeUnless uses prefix matching instead of exact path - M-12: wrap seedExistingDatabase in a single transaction - M-13: use errors.Is for EOF check in upload handler - M-14: log writeJSON encoding errors instead of discarding - M-16: standardize error codes to INTERNAL_ERROR across all handlers
This commit is contained in:
@@ -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)))
|
||||
|
||||
+3
-1
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
+31
-21
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+23
-12
@@ -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))
|
||||
|
||||
+18
-7
@@ -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.
|
||||
|
||||
+12
-2
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user