mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
fix: comprehensive security hardening from full codebase audit
Addresses 14 findings from the security audit across all severity levels: CRITICAL: - C-1: Add user blocking system (migration, DB queries, REST API, WS DM send check) to prevent harassment via unconsented DMs - C-2: Remove server version from unauthenticated /health and /info endpoints to prevent fingerprinting HIGH: - H-1: Remove dangerous-settings feature from tauri-plugin-http - H-3: Default allowSelfSigned to false in API client (was hardcoded true) - H-4: Cap invite expiration to 30 days (720 hours) - H-5: Add 256KB message size limit to LiveKit WS proxy (prevents OOM) - H-6: Cap concurrent sessions to 25 per user (evicts oldest on overflow) - H-8: Restrict /diagnostics/connectivity to ADMINISTRATOR role MEDIUM: - M-2: Deny access to legacy NULL-uploader unlinked attachments - M-4: Log warnings on TOTP plaintext decryption fallback paths - M-8: Remove acceptInvalidCerts from OG preview fetches - M-10: Expand file upload blocklist (Java .class, OLE2, WASM, .lnk) - M-12: Add LIMIT to ListInvites (200) and ListMembers (1000) - M-14: Add CHECK constraint trigger on channels.type (text/voice/dm) https://claude.ai/code/session_01KKo3RwjdmcNzkgXNfUkgNT
This commit is contained in:
@@ -23,7 +23,7 @@ tauri-plugin-global-shortcut = "2"
|
|||||||
tauri-plugin-notification = "2"
|
tauri-plugin-notification = "2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
tauri-plugin-http = { version = "2.5.7", features = ["rustls-tls", "dangerous-settings"] }
|
tauri-plugin-http = { version = "2.5.7", features = ["rustls-tls"] }
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
tauri-plugin-dialog = "2"
|
tauri-plugin-dialog = "2"
|
||||||
tauri-plugin-fs = "2"
|
tauri-plugin-fs = "2"
|
||||||
|
|||||||
@@ -184,13 +184,10 @@ function fetchOgMeta(url: string): Promise<OgMeta> {
|
|||||||
"User-Agent": "facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)",
|
"User-Agent": "facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
if (isTrustedServerUrl(url)) {
|
// M-8: Removed acceptInvalidCerts for OG fetches. Even for trusted server
|
||||||
(
|
// URLs, TLS validation should not be bypassed as it enables MITM attacks.
|
||||||
fetchOpts as RequestInit & {
|
// Self-signed servers are handled by the Rust TLS proxy for WebSocket;
|
||||||
danger?: { acceptInvalidCerts: boolean; acceptInvalidHostnames: boolean };
|
// OG preview fetches should respect standard certificate validation.
|
||||||
}
|
|
||||||
).danger = { acceptInvalidCerts: true, acceptInvalidHostnames: false };
|
|
||||||
}
|
|
||||||
const res = await tauriFetch(url, fetchOpts);
|
const res = await tauriFetch(url, fetchOpts);
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
|
|
||||||
|
|||||||
@@ -85,7 +85,10 @@ if (!appEl) {
|
|||||||
|
|
||||||
// Create core services
|
// Create core services
|
||||||
const router = createRouter("connect");
|
const router = createRouter("connect");
|
||||||
const api = createApiClient({ host: "", allowSelfSigned: true }, () => {
|
// H-3: Default to strict TLS verification. Self-signed cert support is handled
|
||||||
|
// by the Rust-side TOFU WS proxy and the CertMismatchModal, not by disabling
|
||||||
|
// TLS validation in the HTTP client.
|
||||||
|
const api = createApiClient({ host: "", allowSelfSigned: false }, () => {
|
||||||
log.warn("Session expired (401), clearing auth");
|
log.warn("Session expired (401), clearing auth");
|
||||||
clearAuth();
|
clearAuth();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/owncord/server/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// handleBlockUser blocks a user (prevents DM creation and messaging).
|
||||||
|
func handleBlockUser(database *db.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := getUserFromContext(r)
|
||||||
|
if user == nil {
|
||||||
|
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
||||||
|
Error: "UNAUTHORIZED",
|
||||||
|
Message: "not authenticated",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
targetID, err := strconv.ParseInt(chi.URLParam(r, "userId"), 10, 64)
|
||||||
|
if err != nil || targetID <= 0 {
|
||||||
|
writeJSON(w, http.StatusBadRequest, errorResponse{
|
||||||
|
Error: "BAD_REQUEST",
|
||||||
|
Message: "invalid user ID",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if targetID == user.ID {
|
||||||
|
writeJSON(w, http.StatusBadRequest, errorResponse{
|
||||||
|
Error: "BAD_REQUEST",
|
||||||
|
Message: "cannot block yourself",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify target user exists.
|
||||||
|
target, err := database.GetUserByID(targetID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("handleBlockUser GetUserByID", "err", err, "target_id", targetID)
|
||||||
|
writeJSON(w, http.StatusInternalServerError, errorResponse{
|
||||||
|
Error: "INTERNAL_ERROR",
|
||||||
|
Message: "failed to look up user",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if target == nil {
|
||||||
|
writeJSON(w, http.StatusNotFound, errorResponse{
|
||||||
|
Error: "NOT_FOUND",
|
||||||
|
Message: "user not found",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := database.BlockUser(user.ID, targetID); err != nil {
|
||||||
|
slog.Error("handleBlockUser BlockUser", "err", err,
|
||||||
|
"blocker_id", user.ID, "blocked_id", targetID)
|
||||||
|
writeJSON(w, http.StatusInternalServerError, errorResponse{
|
||||||
|
Error: "INTERNAL_ERROR",
|
||||||
|
Message: "failed to block user",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("user blocked", "blocker_id", user.ID, "blocked_id", targetID)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"message": "user blocked"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUnblockUser removes a block on a user.
|
||||||
|
func handleUnblockUser(database *db.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := getUserFromContext(r)
|
||||||
|
if user == nil {
|
||||||
|
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
||||||
|
Error: "UNAUTHORIZED",
|
||||||
|
Message: "not authenticated",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
targetID, err := strconv.ParseInt(chi.URLParam(r, "userId"), 10, 64)
|
||||||
|
if err != nil || targetID <= 0 {
|
||||||
|
writeJSON(w, http.StatusBadRequest, errorResponse{
|
||||||
|
Error: "BAD_REQUEST",
|
||||||
|
Message: "invalid user ID",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := database.UnblockUser(user.ID, targetID); err != nil {
|
||||||
|
slog.Error("handleUnblockUser UnblockUser", "err", err,
|
||||||
|
"blocker_id", user.ID, "target_id", targetID)
|
||||||
|
writeJSON(w, http.StatusInternalServerError, errorResponse{
|
||||||
|
Error: "INTERNAL_ERROR",
|
||||||
|
Message: "failed to unblock user",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("user unblocked", "blocker_id", user.ID, "unblocked_id", targetID)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"message": "user unblocked"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListBlocks returns all users blocked by the authenticated user.
|
||||||
|
func handleListBlocks(database *db.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := getUserFromContext(r)
|
||||||
|
if user == nil {
|
||||||
|
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
||||||
|
Error: "UNAUTHORIZED",
|
||||||
|
Message: "not authenticated",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ids, err := database.ListBlockedUsers(user.ID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("handleListBlocks ListBlockedUsers", "err", err, "user_id", user.ID)
|
||||||
|
writeJSON(w, http.StatusInternalServerError, errorResponse{
|
||||||
|
Error: "INTERNAL_ERROR",
|
||||||
|
Message: "failed to list blocked users",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ids == nil {
|
||||||
|
ids = []int64{}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"blocked_user_ids": ids})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,14 @@ func MountDMRoutes(r chi.Router, database *db.DB, broadcaster DMBroadcaster) {
|
|||||||
r.Get("/", handleListDMs(database))
|
r.Get("/", handleListDMs(database))
|
||||||
r.Delete("/{channelId}", handleCloseDM(database, broadcaster))
|
r.Delete("/{channelId}", handleCloseDM(database, broadcaster))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// User blocking routes — prevent DM creation and messaging.
|
||||||
|
r.Route("/api/v1/blocks", func(r chi.Router) {
|
||||||
|
r.Use(AuthMiddleware(database))
|
||||||
|
r.Get("/", handleListBlocks(database))
|
||||||
|
r.Put("/{userId}", handleBlockUser(database))
|
||||||
|
r.Delete("/{userId}", handleUnblockUser(database))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// createDMRequest is the JSON body for POST /api/v1/dms.
|
// createDMRequest is the JSON body for POST /api/v1/dms.
|
||||||
@@ -101,6 +109,25 @@ func handleCreateDM(database *db.DB) http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if either user has blocked the other.
|
||||||
|
blocked, blockErr := database.IsEitherBlocked(user.ID, req.RecipientID)
|
||||||
|
if blockErr != nil {
|
||||||
|
slog.Error("handleCreateDM IsEitherBlocked", "err", blockErr,
|
||||||
|
"user_id", user.ID, "recipient_id", req.RecipientID)
|
||||||
|
writeJSON(w, http.StatusInternalServerError, errorResponse{
|
||||||
|
Error: "INTERNAL_ERROR",
|
||||||
|
Message: "failed to check block status",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if blocked {
|
||||||
|
writeJSON(w, http.StatusForbidden, errorResponse{
|
||||||
|
Error: "BLOCKED",
|
||||||
|
Message: "cannot create DM with this user",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Get or create the DM channel.
|
// Get or create the DM channel.
|
||||||
ch, created, err := database.GetOrCreateDMChannel(user.ID, req.RecipientID) //nolint:contextcheck // TODO: propagate context through this call path
|
ch, created, err := database.GetOrCreateDMChannel(user.ID, req.RecipientID) //nolint:contextcheck // TODO: propagate context through this call path
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -69,6 +70,16 @@ func handleCreateInvite(database *db.DB) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var expiresAt *time.Time
|
var expiresAt *time.Time
|
||||||
|
// H-4: Cap invite expiration to 30 days (720 hours) to prevent
|
||||||
|
// effectively permanent invites that survive admin revocation policies.
|
||||||
|
const maxExpiresInHours = 720
|
||||||
|
if req.ExpiresInHours > maxExpiresInHours {
|
||||||
|
writeJSON(w, http.StatusBadRequest, errorResponse{
|
||||||
|
Error: "BAD_REQUEST",
|
||||||
|
Message: fmt.Sprintf("expires_in_hours cannot exceed %d (30 days)", maxExpiresInHours),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
if req.ExpiresInHours > 0 {
|
if req.ExpiresInHours > 0 {
|
||||||
t := time.Now().Add(time.Duration(req.ExpiresInHours) * time.Hour)
|
t := time.Now().Add(time.Duration(req.ExpiresInHours) * time.Hour)
|
||||||
expiresAt = &t
|
expiresAt = &t
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -179,21 +180,33 @@ func proxyWebSocket(w http.ResponseWriter, r *http.Request, target *url.URL, all
|
|||||||
<-errc
|
<-errc
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// wsProxyMaxMessageSize is the maximum WebSocket message size the LiveKit
|
||||||
|
// proxy will forward. Messages exceeding this are dropped to prevent OOM.
|
||||||
|
// 256 KB is generous for LiveKit signaling (typically < 10 KB).
|
||||||
|
const wsProxyMaxMessageSize = 256 * 1024
|
||||||
|
|
||||||
// copyWS reads messages from src and writes them to dst until an error or
|
// copyWS reads messages from src and writes them to dst until an error or
|
||||||
// context cancellation.
|
// context cancellation. H-5: Messages exceeding wsProxyMaxMessageSize are
|
||||||
|
// rejected to prevent memory exhaustion via oversized frames.
|
||||||
func copyWS(ctx context.Context, dst, src *websocket.Conn) error {
|
func copyWS(ctx context.Context, dst, src *websocket.Conn) error {
|
||||||
for {
|
for {
|
||||||
msgType, reader, err := src.Reader(ctx)
|
msgType, reader, err := src.Reader(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// Wrap reader with a size limit to prevent OOM from oversized messages.
|
||||||
|
limited := io.LimitReader(reader, wsProxyMaxMessageSize+1)
|
||||||
writer, err := dst.Writer(ctx, msgType)
|
writer, err := dst.Writer(ctx, msgType)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, copyErr := io.Copy(writer, reader); copyErr != nil {
|
n, copyErr := io.Copy(writer, limited)
|
||||||
|
if copyErr != nil {
|
||||||
return copyErr
|
return copyErr
|
||||||
}
|
}
|
||||||
|
if n > wsProxyMaxMessageSize {
|
||||||
|
return fmt.Errorf("livekit proxy: message exceeds %d byte limit", wsProxyMaxMessageSize)
|
||||||
|
}
|
||||||
if closeErr := writer.Close(); closeErr != nil {
|
if closeErr := writer.Close(); closeErr != nil {
|
||||||
return closeErr
|
return closeErr
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-8
@@ -15,6 +15,7 @@ import (
|
|||||||
"github.com/owncord/server/auth"
|
"github.com/owncord/server/auth"
|
||||||
"github.com/owncord/server/config"
|
"github.com/owncord/server/config"
|
||||||
"github.com/owncord/server/db"
|
"github.com/owncord/server/db"
|
||||||
|
"github.com/owncord/server/permissions"
|
||||||
"github.com/owncord/server/storage"
|
"github.com/owncord/server/storage"
|
||||||
"github.com/owncord/server/updater"
|
"github.com/owncord/server/updater"
|
||||||
"github.com/owncord/server/ws"
|
"github.com/owncord/server/ws"
|
||||||
@@ -176,9 +177,10 @@ func NewRouter(cfg *config.Config, database *db.DB, ver string, logBuf *admin.Ri
|
|||||||
// hub can send real-time dm_channel_close events to WebSocket clients.
|
// hub can send real-time dm_channel_close events to WebSocket clients.
|
||||||
MountDMRoutes(r, database, hub)
|
MountDMRoutes(r, database, hub)
|
||||||
|
|
||||||
// Connectivity diagnostics — any authenticated user can check.
|
// H-8: Connectivity diagnostics restricted to admin users only.
|
||||||
// BUG-121: Rate limit 5 req/min as documented.
|
// Exposes Go runtime version and LiveKit node IP which aid targeted attacks.
|
||||||
r.With(AuthMiddleware(database),
|
r.With(AuthMiddleware(database),
|
||||||
|
RequirePermission(database, permissions.Administrator),
|
||||||
RateLimitMiddleware(limiter, 5, time.Minute, cfg.Server.TrustedProxies)).
|
RateLimitMiddleware(limiter, 5, time.Minute, cfg.Server.TrustedProxies)).
|
||||||
Get("/api/v1/diagnostics/connectivity",
|
Get("/api/v1/diagnostics/connectivity",
|
||||||
handleDiagnosticsConnectivity(cfg, ver, hub))
|
handleDiagnosticsConnectivity(cfg, ver, hub))
|
||||||
@@ -228,22 +230,22 @@ var serverStartTime = time.Now()
|
|||||||
// healthResponse is the JSON shape returned by GET /health.
|
// healthResponse is the JSON shape returned by GET /health.
|
||||||
type healthResponse struct {
|
type healthResponse struct {
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Version string `json:"version"`
|
|
||||||
Uptime int64 `json:"uptime"`
|
Uptime int64 `json:"uptime"`
|
||||||
OnlineUsers int `json:"online_users"`
|
OnlineUsers int `json:"online_users"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// infoResponse is the JSON shape returned by GET /api/v1/info.
|
// infoResponse is the JSON shape returned by GET /api/v1/info.
|
||||||
type infoResponse struct {
|
type infoResponse struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Version string `json:"version"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleHealth(ver string, getOnlineUsers func() int) http.HandlerFunc {
|
func handleHealth(ver string, getOnlineUsers func() int) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// C-2: Version removed from unauthenticated health endpoint to prevent
|
||||||
|
// server fingerprinting. Version is available on the authenticated
|
||||||
|
// diagnostics endpoint instead.
|
||||||
writeJSON(w, http.StatusOK, healthResponse{
|
writeJSON(w, http.StatusOK, healthResponse{
|
||||||
Status: "ok",
|
Status: "ok",
|
||||||
Version: ver,
|
|
||||||
Uptime: int64(time.Since(serverStartTime).Seconds()),
|
Uptime: int64(time.Since(serverStartTime).Seconds()),
|
||||||
OnlineUsers: getOnlineUsers(),
|
OnlineUsers: getOnlineUsers(),
|
||||||
})
|
})
|
||||||
@@ -252,9 +254,9 @@ func handleHealth(ver string, getOnlineUsers func() int) http.HandlerFunc {
|
|||||||
|
|
||||||
func handleInfo(cfg *config.Config, ver string) http.HandlerFunc {
|
func handleInfo(cfg *config.Config, ver string) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// C-2: Version removed from unauthenticated info endpoint.
|
||||||
writeJSON(w, http.StatusOK, infoResponse{
|
writeJSON(w, http.StatusOK, infoResponse{
|
||||||
Name: cfg.Server.Name,
|
Name: cfg.Server.Name,
|
||||||
Version: ver,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -234,9 +234,15 @@ func handleServeFile(database *db.DB, store *storage.Storage, allowedOrigins []s
|
|||||||
if !isAdmin {
|
if !isAdmin {
|
||||||
if aa.ChannelID == nil {
|
if aa.ChannelID == nil {
|
||||||
// Unlinked attachment — only the uploader may access.
|
// Unlinked attachment — only the uploader may access.
|
||||||
// Legacy rows (NULL uploader_id) are allowed through with a warning.
|
// M-2: Legacy rows (NULL uploader_id) are now denied rather than
|
||||||
|
// served to any authenticated user.
|
||||||
if aa.UploaderID == nil {
|
if aa.UploaderID == nil {
|
||||||
slog.Warn("legacy attachment served without uploader_id", "id", fileID)
|
slog.Warn("legacy attachment access denied (NULL uploader_id)", "id", fileID)
|
||||||
|
writeJSON(w, http.StatusForbidden, errorResponse{
|
||||||
|
Error: "FORBIDDEN",
|
||||||
|
Message: "you do not have access to this file",
|
||||||
|
})
|
||||||
|
return
|
||||||
} else if user == nil || *aa.UploaderID != user.ID {
|
} else if user == nil || *aa.UploaderID != user.ID {
|
||||||
writeJSON(w, http.StatusForbidden, errorResponse{
|
writeJSON(w, http.StatusForbidden, errorResponse{
|
||||||
Error: "FORBIDDEN",
|
Error: "FORBIDDEN",
|
||||||
|
|||||||
@@ -104,13 +104,17 @@ func EncryptTOTPSecret(key []byte, plaintext string) (string, error) {
|
|||||||
// continue to work.
|
// continue to work.
|
||||||
func DecryptTOTPSecret(key []byte, ciphertext string) (string, error) {
|
func DecryptTOTPSecret(key []byte, ciphertext string) (string, error) {
|
||||||
// Backwards compatibility: if it doesn't look encrypted, return as-is.
|
// Backwards compatibility: if it doesn't look encrypted, return as-is.
|
||||||
|
// M-4: Log a warning so operators can detect unencrypted TOTP secrets
|
||||||
|
// and migrate them (e.g. after key rotation or initial setup).
|
||||||
if len(ciphertext) < minEncryptedHexLen {
|
if len(ciphertext) < minEncryptedHexLen {
|
||||||
|
slog.Warn("TOTP secret returned as plaintext (too short for encrypted format) — consider encrypting legacy secrets")
|
||||||
return ciphertext, nil
|
return ciphertext, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := hex.DecodeString(ciphertext)
|
data, err := hex.DecodeString(ciphertext)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Not valid hex -- treat as unencrypted plaintext (backwards compat).
|
// Not valid hex -- treat as unencrypted plaintext (backwards compat).
|
||||||
|
slog.Warn("TOTP secret returned as plaintext (not valid hex) — consider encrypting legacy secrets")
|
||||||
return ciphertext, nil //nolint:nilerr
|
return ciphertext, nil //nolint:nilerr
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,14 +131,17 @@ func DecryptTOTPSecret(key []byte, ciphertext string) (string, error) {
|
|||||||
nonceSize := gcm.NonceSize()
|
nonceSize := gcm.NonceSize()
|
||||||
if len(data) < nonceSize+gcm.Overhead() {
|
if len(data) < nonceSize+gcm.Overhead() {
|
||||||
// Too short to be valid encrypted data -- return as plaintext.
|
// Too short to be valid encrypted data -- return as plaintext.
|
||||||
|
slog.Warn("TOTP secret returned as plaintext (data too short for nonce+tag)")
|
||||||
return ciphertext, nil
|
return ciphertext, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
nonce, sealed := data[:nonceSize], data[nonceSize:]
|
nonce, sealed := data[:nonceSize], data[nonceSize:]
|
||||||
plaintext, err := gcm.Open(nil, nonce, sealed, nil)
|
plaintext, err := gcm.Open(nil, nonce, sealed, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Decryption failed -- likely an unencrypted legacy secret.
|
// Decryption failed -- likely an unencrypted legacy secret or wrong key.
|
||||||
// Return as-is for backwards compatibility.
|
// Return as-is for backwards compatibility.
|
||||||
|
slog.Warn("TOTP secret decryption failed — returning as plaintext (check TOTP_ENCRYPTION_KEY)",
|
||||||
|
"error", err)
|
||||||
return ciphertext, nil //nolint:nilerr
|
return ciphertext, nil //nolint:nilerr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -222,9 +222,26 @@ func (d *DB) UnbanUser(id int64) error {
|
|||||||
|
|
||||||
// ─── Session Operations ───────────────────────────────────────────────────────
|
// ─── Session Operations ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// maxSessionsPerUser is the maximum number of concurrent sessions allowed per
|
||||||
|
// user. When exceeded, the oldest session is evicted. This prevents unbounded
|
||||||
|
// session accumulation from credential stuffing or token theft (H-6).
|
||||||
|
const maxSessionsPerUser = 25
|
||||||
|
|
||||||
// CreateSession inserts a new session and returns the session ID.
|
// CreateSession inserts a new session and returns the session ID.
|
||||||
// tokenHash must already be hashed (never store plaintext tokens).
|
// tokenHash must already be hashed (never store plaintext tokens).
|
||||||
|
// H-6: Enforces a per-user session cap by evicting the oldest session when
|
||||||
|
// the limit is reached.
|
||||||
func (d *DB) CreateSession(userID int64, tokenHash, device, ip string) (int64, error) {
|
func (d *DB) CreateSession(userID int64, tokenHash, device, ip string) (int64, error) {
|
||||||
|
// Evict oldest sessions if at or above the cap.
|
||||||
|
_, _ = d.sqlDB.Exec(
|
||||||
|
`DELETE FROM sessions WHERE id IN (
|
||||||
|
SELECT id FROM sessions WHERE user_id = ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT -1 OFFSET ?
|
||||||
|
)`,
|
||||||
|
userID, maxSessionsPerUser-1,
|
||||||
|
)
|
||||||
|
|
||||||
expiresAt := time.Now().Add(sessionTTL).UTC().Format("2006-01-02T15:04:05Z")
|
expiresAt := time.Now().Add(sessionTTL).UTC().Format("2006-01-02T15:04:05Z")
|
||||||
res, err := d.sqlDB.Exec(
|
res, err := d.sqlDB.Exec(
|
||||||
`INSERT INTO sessions (user_id, token, device, ip_address, expires_at)
|
`INSERT INTO sessions (user_id, token, device, ip_address, expires_at)
|
||||||
@@ -451,14 +468,16 @@ type MemberSummary struct {
|
|||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListMembers returns all non-banned users as lightweight summaries.
|
// ListMembers returns non-banned users as lightweight summaries.
|
||||||
|
// M-12: Limited to 1000 rows to prevent unbounded result sets on large servers.
|
||||||
func (d *DB) ListMembers() ([]MemberSummary, error) {
|
func (d *DB) ListMembers() ([]MemberSummary, error) {
|
||||||
rows, err := d.sqlDB.Query(
|
rows, err := d.sqlDB.Query(
|
||||||
`SELECT u.id, u.username, u.avatar, u.status, LOWER(r.name)
|
`SELECT u.id, u.username, u.avatar, u.status, LOWER(r.name)
|
||||||
FROM users u
|
FROM users u
|
||||||
JOIN roles r ON u.role_id = r.id
|
JOIN roles r ON u.role_id = r.id
|
||||||
WHERE u.banned = 0
|
WHERE u.banned = 0
|
||||||
ORDER BY u.username ASC`,
|
ORDER BY u.username ASC
|
||||||
|
LIMIT 1000`,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("ListMembers: %w", err)
|
return nil, fmt.Errorf("ListMembers: %w", err)
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// BlockUser adds a block from blocker to blocked. Idempotent — re-blocking
|
||||||
|
// a user that is already blocked is a no-op (INSERT OR IGNORE).
|
||||||
|
func (d *DB) BlockUser(blockerID, blockedID int64) error {
|
||||||
|
_, err := d.sqlDB.Exec(
|
||||||
|
`INSERT OR IGNORE INTO user_blocks (blocker_id, blocked_id) VALUES (?, ?)`,
|
||||||
|
blockerID, blockedID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("BlockUser: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnblockUser removes a block. Idempotent — unblocking a non-blocked user is
|
||||||
|
// a no-op.
|
||||||
|
func (d *DB) UnblockUser(blockerID, blockedID int64) error {
|
||||||
|
_, err := d.sqlDB.Exec(
|
||||||
|
`DELETE FROM user_blocks WHERE blocker_id = ? AND blocked_id = ?`,
|
||||||
|
blockerID, blockedID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("UnblockUser: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsBlocked returns true if blockerID has blocked blockedID.
|
||||||
|
func (d *DB) IsBlocked(blockerID, blockedID int64) (bool, error) {
|
||||||
|
var exists int
|
||||||
|
err := d.sqlDB.QueryRow(
|
||||||
|
`SELECT 1 FROM user_blocks WHERE blocker_id = ? AND blocked_id = ? LIMIT 1`,
|
||||||
|
blockerID, blockedID,
|
||||||
|
).Scan(&exists)
|
||||||
|
if err != nil {
|
||||||
|
if err.Error() == "sql: no rows in result set" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, fmt.Errorf("IsBlocked: %w", err)
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsEitherBlocked returns true if either user has blocked the other.
|
||||||
|
// Used for DM authorization — if either party has blocked the other,
|
||||||
|
// messaging is denied.
|
||||||
|
func (d *DB) IsEitherBlocked(userA, userB int64) (bool, error) {
|
||||||
|
var exists int
|
||||||
|
err := d.sqlDB.QueryRow(
|
||||||
|
`SELECT 1 FROM user_blocks
|
||||||
|
WHERE (blocker_id = ? AND blocked_id = ?)
|
||||||
|
OR (blocker_id = ? AND blocked_id = ?)
|
||||||
|
LIMIT 1`,
|
||||||
|
userA, userB, userB, userA,
|
||||||
|
).Scan(&exists)
|
||||||
|
if err != nil {
|
||||||
|
if err.Error() == "sql: no rows in result set" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, fmt.Errorf("IsEitherBlocked: %w", err)
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListBlockedUsers returns the IDs of all users blocked by the given user.
|
||||||
|
func (d *DB) ListBlockedUsers(blockerID int64) ([]int64, error) {
|
||||||
|
rows, err := d.sqlDB.Query(
|
||||||
|
`SELECT blocked_id FROM user_blocks WHERE blocker_id = ? ORDER BY created_at DESC`,
|
||||||
|
blockerID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ListBlockedUsers: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close() //nolint:errcheck
|
||||||
|
|
||||||
|
var ids []int64
|
||||||
|
for rows.Next() {
|
||||||
|
var id int64
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
return nil, fmt.Errorf("ListBlockedUsers scan: %w", err)
|
||||||
|
}
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
if rows.Err() != nil {
|
||||||
|
return nil, fmt.Errorf("ListBlockedUsers rows: %w", rows.Err())
|
||||||
|
}
|
||||||
|
return ids, nil
|
||||||
|
}
|
||||||
@@ -2,11 +2,12 @@ package db
|
|||||||
|
|
||||||
import "fmt"
|
import "fmt"
|
||||||
|
|
||||||
// ListInvites returns all invites ordered by creation time descending.
|
// ListInvites returns invites ordered by creation time descending.
|
||||||
|
// M-12: Limited to 200 rows to prevent unbounded result sets.
|
||||||
func (d *DB) ListInvites() ([]*Invite, error) {
|
func (d *DB) ListInvites() ([]*Invite, error) {
|
||||||
rows, err := d.sqlDB.Query(
|
rows, err := d.sqlDB.Query(
|
||||||
`SELECT id, code, created_by, max_uses, use_count, expires_at, revoked, created_at
|
`SELECT id, code, created_by, max_uses, use_count, expires_at, revoked, created_at
|
||||||
FROM invites ORDER BY created_at DESC`,
|
FROM invites ORDER BY created_at DESC LIMIT 200`,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("ListInvites: %w", err)
|
return nil, fmt.Errorf("ListInvites: %w", err)
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- User blocking table: prevents DM creation and messaging between users.
|
||||||
|
-- blocker_id is the user who initiated the block.
|
||||||
|
-- blocked_id is the user being blocked.
|
||||||
|
CREATE TABLE IF NOT EXISTS user_blocks (
|
||||||
|
blocker_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
blocked_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
PRIMARY KEY (blocker_id, blocked_id),
|
||||||
|
CHECK (blocker_id != blocked_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index for efficient "is user X blocked by user Y" lookups (DM send path).
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_blocks_blocked ON user_blocks(blocked_id, blocker_id);
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
-- Add CHECK constraint on channels.type to prevent invalid channel types.
|
||||||
|
-- SQLite does not support ALTER TABLE ADD CONSTRAINT, so we recreate the
|
||||||
|
-- constraint via a trigger that rejects invalid types on INSERT and UPDATE.
|
||||||
|
CREATE TRIGGER IF NOT EXISTS trg_channels_type_check_insert
|
||||||
|
BEFORE INSERT ON channels
|
||||||
|
FOR EACH ROW
|
||||||
|
WHEN NEW.type NOT IN ('text', 'voice', 'dm')
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'invalid channel type: must be text, voice, or dm');
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS trg_channels_type_check_update
|
||||||
|
BEFORE UPDATE OF type ON channels
|
||||||
|
FOR EACH ROW
|
||||||
|
WHEN NEW.type NOT IN ('text', 'voice', 'dm')
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'invalid channel type: must be text, voice, or dm');
|
||||||
|
END;
|
||||||
@@ -17,11 +17,15 @@ var blockedMagic = []struct {
|
|||||||
name string
|
name string
|
||||||
magic []byte
|
magic []byte
|
||||||
}{
|
}{
|
||||||
{"PE executable", []byte("MZ")}, // Windows .exe / .dll
|
{"PE executable", []byte("MZ")}, // Windows .exe / .dll
|
||||||
{"ELF binary", []byte("\x7fELF")}, // Linux binaries
|
{"ELF binary", []byte("\x7fELF")}, // Linux binaries
|
||||||
{"Mach-O 64", []byte("\xcf\xfa\xed\xfe")}, // macOS 64-bit
|
{"Mach-O 64", []byte("\xcf\xfa\xed\xfe")}, // macOS 64-bit
|
||||||
{"Mach-O 32", []byte("\xce\xfa\xed\xfe")}, // macOS 32-bit
|
{"Mach-O 32", []byte("\xce\xfa\xed\xfe")}, // macOS 32-bit
|
||||||
{"shell script", []byte("#!")}, // Shebang scripts (.sh, .py, etc.)
|
{"shell script", []byte("#!")}, // Shebang scripts (.sh, .py, etc.)
|
||||||
|
{"Java class", []byte("\xca\xfe\xba\xbe")}, // .class files
|
||||||
|
{"OLE2 document", []byte("\xd0\xcf\x11\xe0")}, // .doc/.xls with macros
|
||||||
|
{"WebAssembly", []byte("\x00asm")}, // .wasm modules
|
||||||
|
{"Windows shortcut", []byte{0x4c, 0x00, 0x00, 0x00}}, // .lnk files
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidateFileType checks the first few bytes of a file against known blocked
|
// ValidateFileType checks the first few bytes of a file against known blocked
|
||||||
|
|||||||
@@ -113,6 +113,20 @@ func (h *Hub) checkChatSendPermission(c *Client, channelID int64, isDM bool) boo
|
|||||||
c.sendMsg(buildErrorMsg(ErrCodeForbidden, "you are not a participant in this DM"))
|
c.sendMsg(buildErrorMsg(ErrCodeForbidden, "you are not a participant in this DM"))
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
// Check if either DM participant has blocked the other.
|
||||||
|
recipient, recErr := h.db.GetDMRecipient(channelID, c.userID)
|
||||||
|
if recErr == nil && recipient != nil {
|
||||||
|
blocked, blkErr := h.db.IsEitherBlocked(c.userID, recipient.ID)
|
||||||
|
if blkErr != nil {
|
||||||
|
slog.Error("ws checkChatSendPermission IsEitherBlocked", "err", blkErr)
|
||||||
|
c.sendMsg(buildErrorMsg(ErrCodeInternal, "failed to check block status"))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if blocked {
|
||||||
|
c.sendMsg(buildErrorMsg(ErrCodeForbidden, "cannot send messages — user is blocked"))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return h.requireChannelPerm(c, channelID, permissions.ReadMessages|permissions.SendMessages, "SEND_MESSAGES")
|
return h.requireChannelPerm(c, channelID, permissions.ReadMessages|permissions.SendMessages, "SEND_MESSAGES")
|
||||||
|
|||||||
Reference in New Issue
Block a user