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:
Claude
2026-04-04 16:48:57 +00:00
parent 330fd8eed7
commit 1673c37b9c
17 changed files with 395 additions and 31 deletions
+1 -1
View File
@@ -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);
+4 -1
View File
@@ -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();
});
+138
View File
@@ -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})
}
}
+27
View File
@@ -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 {
+11
View File
@@ -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
+15 -2
View File
@@ -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
View File
@@ -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,
})
}
}
+8 -2
View File
@@ -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",
+8 -1
View File
@@ -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
}
+21 -2
View File
@@ -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)
+91
View File
@@ -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
}
+3 -2
View File
@@ -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)
+13
View File
@@ -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;
+9 -5
View File
@@ -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
+14
View File
@@ -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")