mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-02 19:43:10 +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"
|
||||
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"
|
||||
|
||||
@@ -184,13 +184,10 @@ function fetchOgMeta(url: string): Promise<OgMeta> {
|
||||
"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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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.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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+10
-8
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
// 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)
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user