diff --git a/Client/tauri-client/src-tauri/Cargo.toml b/Client/tauri-client/src-tauri/Cargo.toml index c81d003b..7901a420 100644 --- a/Client/tauri-client/src-tauri/Cargo.toml +++ b/Client/tauri-client/src-tauri/Cargo.toml @@ -23,7 +23,7 @@ tauri-plugin-global-shortcut = "2" tauri-plugin-notification = "2" serde = { version = "1", features = ["derive"] } 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-dialog = "2" tauri-plugin-fs = "2" diff --git a/Client/tauri-client/src/components/message-list/embeds.ts b/Client/tauri-client/src/components/message-list/embeds.ts index b10fb109..1257befd 100644 --- a/Client/tauri-client/src/components/message-list/embeds.ts +++ b/Client/tauri-client/src/components/message-list/embeds.ts @@ -184,13 +184,10 @@ function fetchOgMeta(url: string): Promise { "User-Agent": "facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)", }, }; - if (isTrustedServerUrl(url)) { - ( - fetchOpts as RequestInit & { - danger?: { acceptInvalidCerts: boolean; acceptInvalidHostnames: boolean }; - } - ).danger = { acceptInvalidCerts: true, acceptInvalidHostnames: false }; - } + // M-8: Removed acceptInvalidCerts for OG fetches. Even for trusted server + // URLs, TLS validation should not be bypassed as it enables MITM attacks. + // Self-signed servers are handled by the Rust TLS proxy for WebSocket; + // OG preview fetches should respect standard certificate validation. const res = await tauriFetch(url, fetchOpts); clearTimeout(timer); diff --git a/Client/tauri-client/src/main.ts b/Client/tauri-client/src/main.ts index 0a527a13..8a51ae48 100644 --- a/Client/tauri-client/src/main.ts +++ b/Client/tauri-client/src/main.ts @@ -85,7 +85,10 @@ if (!appEl) { // Create core services 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"); clearAuth(); }); diff --git a/Server/api/block_handler.go b/Server/api/block_handler.go new file mode 100644 index 00000000..09803059 --- /dev/null +++ b/Server/api/block_handler.go @@ -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}) + } +} diff --git a/Server/api/dm_handler.go b/Server/api/dm_handler.go index d141ac69..fbe79989 100644 --- a/Server/api/dm_handler.go +++ b/Server/api/dm_handler.go @@ -26,6 +26,14 @@ func MountDMRoutes(r chi.Router, database *db.DB, broadcaster DMBroadcaster) { r.Get("/", handleListDMs(database)) 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. @@ -101,6 +109,25 @@ func handleCreateDM(database *db.DB) http.HandlerFunc { 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. ch, created, err := database.GetOrCreateDMChannel(user.ID, req.RecipientID) //nolint:contextcheck // TODO: propagate context through this call path if err != nil { diff --git a/Server/api/invite_handler.go b/Server/api/invite_handler.go index a02b5fca..dbb7b077 100644 --- a/Server/api/invite_handler.go +++ b/Server/api/invite_handler.go @@ -2,6 +2,7 @@ package api import ( "encoding/json" + "fmt" "io" "log/slog" "net/http" @@ -69,6 +70,16 @@ func handleCreateInvite(database *db.DB) http.HandlerFunc { } 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 { t := time.Now().Add(time.Duration(req.ExpiresInHours) * time.Hour) expiresAt = &t diff --git a/Server/api/livekit_proxy.go b/Server/api/livekit_proxy.go index 7f378c24..5b9b7f9d 100644 --- a/Server/api/livekit_proxy.go +++ b/Server/api/livekit_proxy.go @@ -2,6 +2,7 @@ package api import ( "context" + "fmt" "io" "log/slog" "net/http" @@ -179,21 +180,33 @@ func proxyWebSocket(w http.ResponseWriter, r *http.Request, target *url.URL, all <-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 -// 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 { for { msgType, reader, err := src.Reader(ctx) if err != nil { 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) if err != nil { return err } - if _, copyErr := io.Copy(writer, reader); copyErr != nil { + n, copyErr := io.Copy(writer, limited) + if copyErr != nil { return copyErr } + if n > wsProxyMaxMessageSize { + return fmt.Errorf("livekit proxy: message exceeds %d byte limit", wsProxyMaxMessageSize) + } if closeErr := writer.Close(); closeErr != nil { return closeErr } diff --git a/Server/api/router.go b/Server/api/router.go index a8da6e95..9211f86d 100644 --- a/Server/api/router.go +++ b/Server/api/router.go @@ -15,6 +15,7 @@ import ( "github.com/owncord/server/auth" "github.com/owncord/server/config" "github.com/owncord/server/db" + "github.com/owncord/server/permissions" "github.com/owncord/server/storage" "github.com/owncord/server/updater" "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. MountDMRoutes(r, database, hub) - // Connectivity diagnostics — any authenticated user can check. - // BUG-121: Rate limit 5 req/min as documented. + // H-8: Connectivity diagnostics restricted to admin users only. + // Exposes Go runtime version and LiveKit node IP which aid targeted attacks. r.With(AuthMiddleware(database), + RequirePermission(database, permissions.Administrator), RateLimitMiddleware(limiter, 5, time.Minute, cfg.Server.TrustedProxies)). Get("/api/v1/diagnostics/connectivity", handleDiagnosticsConnectivity(cfg, ver, hub)) @@ -228,22 +230,22 @@ var serverStartTime = time.Now() // healthResponse is the JSON shape returned by GET /health. type healthResponse struct { Status string `json:"status"` - Version string `json:"version"` Uptime int64 `json:"uptime"` OnlineUsers int `json:"online_users"` } // infoResponse is the JSON shape returned by GET /api/v1/info. type infoResponse struct { - Name string `json:"name"` - Version string `json:"version"` + Name string `json:"name"` } func handleHealth(ver string, getOnlineUsers func() int) http.HandlerFunc { 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{ Status: "ok", - Version: ver, Uptime: int64(time.Since(serverStartTime).Seconds()), OnlineUsers: getOnlineUsers(), }) @@ -252,9 +254,9 @@ func handleHealth(ver string, getOnlineUsers func() int) http.HandlerFunc { func handleInfo(cfg *config.Config, ver string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { + // C-2: Version removed from unauthenticated info endpoint. writeJSON(w, http.StatusOK, infoResponse{ - Name: cfg.Server.Name, - Version: ver, + Name: cfg.Server.Name, }) } } diff --git a/Server/api/upload_handler.go b/Server/api/upload_handler.go index a9b5f8e9..a6ffad27 100644 --- a/Server/api/upload_handler.go +++ b/Server/api/upload_handler.go @@ -234,9 +234,15 @@ func handleServeFile(database *db.DB, store *storage.Storage, allowedOrigins []s if !isAdmin { if aa.ChannelID == nil { // 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 { - 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 { writeJSON(w, http.StatusForbidden, errorResponse{ Error: "FORBIDDEN", diff --git a/Server/auth/totp_encrypt.go b/Server/auth/totp_encrypt.go index 60f147ad..12f8abc7 100644 --- a/Server/auth/totp_encrypt.go +++ b/Server/auth/totp_encrypt.go @@ -104,13 +104,17 @@ func EncryptTOTPSecret(key []byte, plaintext string) (string, error) { // continue to work. func DecryptTOTPSecret(key []byte, ciphertext string) (string, error) { // 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 { + slog.Warn("TOTP secret returned as plaintext (too short for encrypted format) — consider encrypting legacy secrets") return ciphertext, nil } data, err := hex.DecodeString(ciphertext) if err != nil { // 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 } @@ -127,14 +131,17 @@ func DecryptTOTPSecret(key []byte, ciphertext string) (string, error) { nonceSize := gcm.NonceSize() if len(data) < nonceSize+gcm.Overhead() { // 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 } nonce, sealed := data[:nonceSize], data[nonceSize:] plaintext, err := gcm.Open(nil, nonce, sealed, 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. + slog.Warn("TOTP secret decryption failed — returning as plaintext (check TOTP_ENCRYPTION_KEY)", + "error", err) return ciphertext, nil //nolint:nilerr } diff --git a/Server/db/auth_queries.go b/Server/db/auth_queries.go index 5116c28e..688a6b1d 100644 --- a/Server/db/auth_queries.go +++ b/Server/db/auth_queries.go @@ -222,9 +222,26 @@ func (d *DB) UnbanUser(id int64) error { // ─── 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. // 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) { + // 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") res, err := d.sqlDB.Exec( `INSERT INTO sessions (user_id, token, device, ip_address, expires_at) @@ -451,14 +468,16 @@ type MemberSummary struct { 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) { rows, err := d.sqlDB.Query( `SELECT u.id, u.username, u.avatar, u.status, LOWER(r.name) FROM users u JOIN roles r ON u.role_id = r.id WHERE u.banned = 0 - ORDER BY u.username ASC`, + ORDER BY u.username ASC + LIMIT 1000`, ) if err != nil { return nil, fmt.Errorf("ListMembers: %w", err) diff --git a/Server/db/block_queries.go b/Server/db/block_queries.go new file mode 100644 index 00000000..a0e2133c --- /dev/null +++ b/Server/db/block_queries.go @@ -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 +} diff --git a/Server/db/invite_queries.go b/Server/db/invite_queries.go index 5265d137..4587e8d5 100644 --- a/Server/db/invite_queries.go +++ b/Server/db/invite_queries.go @@ -2,11 +2,12 @@ package db 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) { rows, err := d.sqlDB.Query( `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 { return nil, fmt.Errorf("ListInvites: %w", err) diff --git a/Server/migrations/012_user_blocks.sql b/Server/migrations/012_user_blocks.sql new file mode 100644 index 00000000..ac94d9a4 --- /dev/null +++ b/Server/migrations/012_user_blocks.sql @@ -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); diff --git a/Server/migrations/013_channel_type_constraint.sql b/Server/migrations/013_channel_type_constraint.sql new file mode 100644 index 00000000..2cc7be41 --- /dev/null +++ b/Server/migrations/013_channel_type_constraint.sql @@ -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; diff --git a/Server/storage/storage.go b/Server/storage/storage.go index 08e1cd0f..babaa6f4 100644 --- a/Server/storage/storage.go +++ b/Server/storage/storage.go @@ -17,11 +17,15 @@ var blockedMagic = []struct { name string magic []byte }{ - {"PE executable", []byte("MZ")}, // Windows .exe / .dll - {"ELF binary", []byte("\x7fELF")}, // Linux binaries - {"Mach-O 64", []byte("\xcf\xfa\xed\xfe")}, // macOS 64-bit - {"Mach-O 32", []byte("\xce\xfa\xed\xfe")}, // macOS 32-bit - {"shell script", []byte("#!")}, // Shebang scripts (.sh, .py, etc.) + {"PE executable", []byte("MZ")}, // Windows .exe / .dll + {"ELF binary", []byte("\x7fELF")}, // Linux binaries + {"Mach-O 64", []byte("\xcf\xfa\xed\xfe")}, // macOS 64-bit + {"Mach-O 32", []byte("\xce\xfa\xed\xfe")}, // macOS 32-bit + {"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 diff --git a/Server/ws/handlers_chat.go b/Server/ws/handlers_chat.go index 06cda90e..a51d7ea1 100644 --- a/Server/ws/handlers_chat.go +++ b/Server/ws/handlers_chat.go @@ -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")) 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 h.requireChannelPerm(c, channelID, permissions.ReadMessages|permissions.SendMessages, "SEND_MESSAGES")