mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
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
512 lines
15 KiB
Go
512 lines
15 KiB
Go
package db
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
// ─── User Operations ──────────────────────────────────────────────────────────
|
|
|
|
// CreateUser inserts a new user record and returns the assigned ID.
|
|
func (d *DB) CreateUser(username, passwordHash string, roleID int) (int64, error) {
|
|
res, err := d.sqlDB.Exec(
|
|
`INSERT INTO users (username, password, role_id) VALUES (?, ?, ?)`,
|
|
username, passwordHash, roleID,
|
|
)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("CreateUser: %w", err)
|
|
}
|
|
return res.LastInsertId()
|
|
}
|
|
|
|
// CreateOwnerIfEmpty atomically checks that no users exist and inserts the
|
|
// first owner in a single transaction. Returns ErrConflict if any user already
|
|
// exists, closing the TOCTOU race in the setup endpoint (BUG-119).
|
|
func (d *DB) CreateOwnerIfEmpty(username, passwordHash string, roleID int) (int64, error) {
|
|
tx, err := d.sqlDB.Begin()
|
|
if err != nil {
|
|
return 0, fmt.Errorf("CreateOwnerIfEmpty begin: %w", err)
|
|
}
|
|
committed := false
|
|
defer func() {
|
|
if !committed {
|
|
_ = tx.Rollback()
|
|
}
|
|
}()
|
|
|
|
var count int64
|
|
if err := tx.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&count); err != nil {
|
|
return 0, fmt.Errorf("CreateOwnerIfEmpty count: %w", err)
|
|
}
|
|
if count > 0 {
|
|
return 0, ErrConflict
|
|
}
|
|
|
|
res, err := tx.Exec(
|
|
`INSERT INTO users (username, password, role_id) VALUES (?, ?, ?)`,
|
|
username, passwordHash, roleID,
|
|
)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("CreateOwnerIfEmpty insert: %w", err)
|
|
}
|
|
|
|
uid, err := res.LastInsertId()
|
|
if err != nil {
|
|
return 0, fmt.Errorf("CreateOwnerIfEmpty last_id: %w", err)
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return 0, fmt.Errorf("CreateOwnerIfEmpty commit: %w", err)
|
|
}
|
|
committed = true
|
|
return uid, nil
|
|
}
|
|
|
|
// CreateUserWithInvite atomically consumes an invite and creates the user in
|
|
// the same transaction so a failed registration does not burn the invite.
|
|
func (d *DB) CreateUserWithInvite(username, passwordHash string, roleID int, inviteCode string) (int64, error) {
|
|
tx, err := d.sqlDB.Begin()
|
|
if err != nil {
|
|
return 0, fmt.Errorf("CreateUserWithInvite begin: %w", err)
|
|
}
|
|
committed := false
|
|
defer func() {
|
|
if !committed {
|
|
_ = tx.Rollback()
|
|
}
|
|
}()
|
|
|
|
result, err := tx.Exec(
|
|
`UPDATE invites SET use_count = use_count + 1
|
|
WHERE code = ? AND revoked = 0
|
|
AND (max_uses IS NULL OR use_count < max_uses)
|
|
AND (expires_at IS NULL OR strftime('%s', expires_at) > strftime('%s', 'now'))`,
|
|
inviteCode,
|
|
)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("CreateUserWithInvite use invite: %w", err)
|
|
}
|
|
rows, err := result.RowsAffected()
|
|
if err != nil {
|
|
return 0, fmt.Errorf("CreateUserWithInvite invite rows: %w", err)
|
|
}
|
|
if rows == 0 {
|
|
return 0, fmt.Errorf("CreateUserWithInvite invite unavailable: %w", ErrNotFound)
|
|
}
|
|
|
|
result, err = tx.Exec(
|
|
`INSERT INTO users (username, password, role_id) VALUES (?, ?, ?)`,
|
|
username, passwordHash, roleID,
|
|
)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("CreateUserWithInvite create user: %w", err)
|
|
}
|
|
uid, err := result.LastInsertId()
|
|
if err != nil {
|
|
return 0, fmt.Errorf("CreateUserWithInvite last insert id: %w", err)
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return 0, fmt.Errorf("CreateUserWithInvite commit: %w", err)
|
|
}
|
|
committed = true
|
|
return uid, nil
|
|
}
|
|
|
|
// GetUserByUsername returns the user with the given username (case-insensitive),
|
|
// or nil if not found.
|
|
func (d *DB) GetUserByUsername(username string) (*User, error) {
|
|
row := d.sqlDB.QueryRow(
|
|
`SELECT id, username, password, avatar, role_id, totp_secret, status,
|
|
created_at, last_seen, banned, ban_reason, ban_expires
|
|
FROM users WHERE username = ? COLLATE NOCASE`,
|
|
username,
|
|
)
|
|
return scanUser(row)
|
|
}
|
|
|
|
// GetUserByID returns the user with the given ID, or nil if not found.
|
|
func (d *DB) GetUserByID(id int64) (*User, error) {
|
|
row := d.sqlDB.QueryRow(
|
|
`SELECT id, username, password, avatar, role_id, totp_secret, status,
|
|
created_at, last_seen, banned, ban_reason, ban_expires
|
|
FROM users WHERE id = ?`,
|
|
id,
|
|
)
|
|
return scanUser(row)
|
|
}
|
|
|
|
// scanUser reads a User from a *sql.Row, returning nil (not an error) when the
|
|
// row is not found.
|
|
func scanUser(row *sql.Row) (*User, error) {
|
|
u := &User{}
|
|
var banned int
|
|
err := row.Scan(
|
|
&u.ID, &u.Username, &u.PasswordHash, &u.Avatar, &u.RoleID,
|
|
&u.TOTPSecret, &u.Status, &u.CreatedAt, &u.LastSeen,
|
|
&banned, &u.BanReason, &u.BanExpires,
|
|
)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scanUser: %w", err)
|
|
}
|
|
u.Banned = banned != 0
|
|
return u, nil
|
|
}
|
|
|
|
// UpdateUserStatus sets the status column for the given user ID.
|
|
func (d *DB) UpdateUserStatus(id int64, status string) error {
|
|
_, err := d.sqlDB.Exec(
|
|
`UPDATE users SET status = ?, last_seen = datetime('now') WHERE id = ?`,
|
|
status, id,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("UpdateUserStatus: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UpdateUserTOTPSecret sets or clears the TOTP secret for a user.
|
|
func (d *DB) UpdateUserTOTPSecret(id int64, secret *string) error {
|
|
_, err := d.sqlDB.Exec(`UPDATE users SET totp_secret = ? WHERE id = ?`, secret, id)
|
|
if err != nil {
|
|
return fmt.Errorf("UpdateUserTOTPSecret: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ResetAllUserStatuses sets all users to "offline". Called on server startup
|
|
// to clear stale statuses from a previous run or crash.
|
|
func (d *DB) ResetAllUserStatuses() error {
|
|
_, err := d.sqlDB.Exec(`UPDATE users SET status = 'offline' WHERE status != 'offline'`)
|
|
if err != nil {
|
|
return fmt.Errorf("ResetAllUserStatuses: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// BanUser marks a user as banned with an optional expiry. Pass nil for a
|
|
// permanent ban.
|
|
func (d *DB) BanUser(id int64, reason string, expires *time.Time) error {
|
|
var expiresStr *string
|
|
if expires != nil {
|
|
s := expires.UTC().Format("2006-01-02T15:04:05Z")
|
|
expiresStr = &s
|
|
}
|
|
_, err := d.sqlDB.Exec(
|
|
`UPDATE users SET banned = 1, ban_reason = ?, ban_expires = ? WHERE id = ?`,
|
|
reason, expiresStr, id,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("BanUser: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UnbanUser removes the ban from a user.
|
|
func (d *DB) UnbanUser(id int64) error {
|
|
_, err := d.sqlDB.Exec(
|
|
`UPDATE users SET banned = 0, ban_reason = NULL, ban_expires = NULL WHERE id = ?`,
|
|
id,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("UnbanUser: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ─── 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)
|
|
VALUES (?, ?, ?, ?, ?)`,
|
|
userID, tokenHash, device, ip, expiresAt,
|
|
)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("CreateSession: %w", err)
|
|
}
|
|
return res.LastInsertId()
|
|
}
|
|
|
|
// GetSessionByTokenHash retrieves a session by its hashed token, or nil if
|
|
// not found.
|
|
func (d *DB) GetSessionByTokenHash(tokenHash string) (*Session, error) {
|
|
row := d.sqlDB.QueryRow(
|
|
`SELECT id, user_id, token, device, ip_address, created_at, last_used, expires_at
|
|
FROM sessions WHERE token = ?`,
|
|
tokenHash,
|
|
)
|
|
s := &Session{}
|
|
err := row.Scan(
|
|
&s.ID, &s.UserID, &s.TokenHash, &s.Device, &s.IP,
|
|
&s.CreatedAt, &s.LastUsed, &s.ExpiresAt,
|
|
)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("GetSessionByTokenHash: %w", err)
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// SessionWithBanStatus combines session data with user ban fields
|
|
// in a single query, avoiding two sequential DB round-trips.
|
|
type SessionWithBanStatus struct {
|
|
Session
|
|
Banned bool
|
|
BanReason *string
|
|
BanExpires *string
|
|
}
|
|
|
|
// GetSessionWithBanStatus returns the session joined with the user's ban
|
|
// status in a single query. Returns nil, nil when not found.
|
|
func (d *DB) GetSessionWithBanStatus(tokenHash string) (*SessionWithBanStatus, error) {
|
|
row := d.sqlDB.QueryRow(
|
|
`SELECT s.id, s.user_id, s.token, s.device, s.ip_address,
|
|
s.created_at, s.last_used, s.expires_at,
|
|
u.banned, u.ban_reason, u.ban_expires
|
|
FROM sessions s
|
|
JOIN users u ON s.user_id = u.id
|
|
WHERE s.token = ?`,
|
|
tokenHash,
|
|
)
|
|
r := &SessionWithBanStatus{}
|
|
var banned int
|
|
err := row.Scan(
|
|
&r.ID, &r.UserID, &r.TokenHash, &r.Device, &r.IP,
|
|
&r.CreatedAt, &r.LastUsed, &r.ExpiresAt,
|
|
&banned, &r.BanReason, &r.BanExpires,
|
|
)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("GetSessionWithBanStatus: %w", err)
|
|
}
|
|
r.Banned = banned != 0
|
|
return r, nil
|
|
}
|
|
|
|
// DeleteSession removes the session with the given token hash.
|
|
func (d *DB) DeleteSession(tokenHash string) error {
|
|
_, err := d.sqlDB.Exec(`DELETE FROM sessions WHERE token = ?`, tokenHash)
|
|
if err != nil {
|
|
return fmt.Errorf("DeleteSession: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DeleteOtherSessions removes all sessions for the given user except the one
|
|
// with keepSessionID. Used after password change or 2FA state change to
|
|
// invalidate all other sessions (BUG-108).
|
|
func (d *DB) DeleteOtherSessions(userID, keepSessionID int64) (int64, error) {
|
|
result, err := d.sqlDB.Exec(
|
|
`DELETE FROM sessions WHERE user_id = ? AND id != ?`,
|
|
userID, keepSessionID,
|
|
)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("DeleteOtherSessions: %w", err)
|
|
}
|
|
n, _ := result.RowsAffected()
|
|
return n, nil
|
|
}
|
|
|
|
// DeleteExpiredSessions removes all sessions whose expires_at is in the past.
|
|
// Compares using strftime to handle both ISO-8601 and SQLite datetime formats.
|
|
func (d *DB) DeleteExpiredSessions() error {
|
|
_, err := d.sqlDB.Exec(
|
|
`DELETE FROM sessions WHERE strftime('%s', expires_at) < strftime('%s', 'now')`,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("DeleteExpiredSessions: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// TouchSession updates last_used for the session with the given token hash.
|
|
func (d *DB) TouchSession(tokenHash string) error {
|
|
_, err := d.sqlDB.Exec(
|
|
`UPDATE sessions SET last_used = datetime('now') WHERE token = ?`,
|
|
tokenHash,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("TouchSession: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ─── Invite Operations ────────────────────────────────────────────────────────
|
|
|
|
// CreateInvite generates a random invite code, persists it, and returns the
|
|
// code. maxUses=0 means unlimited. expiresAt=nil means never expires.
|
|
func (d *DB) CreateInvite(createdBy int64, maxUses int, expiresAt *time.Time) (string, error) {
|
|
code, err := generateInviteCode()
|
|
if err != nil {
|
|
return "", fmt.Errorf("CreateInvite generate code: %w", err)
|
|
}
|
|
|
|
var maxUsesVal *int
|
|
if maxUses > 0 {
|
|
maxUsesVal = &maxUses
|
|
}
|
|
var expiresStr *string
|
|
if expiresAt != nil {
|
|
s := expiresAt.UTC().Format("2006-01-02T15:04:05Z")
|
|
expiresStr = &s
|
|
}
|
|
|
|
_, err = d.sqlDB.Exec(
|
|
`INSERT INTO invites (code, created_by, max_uses, expires_at) VALUES (?, ?, ?, ?)`,
|
|
code, createdBy, maxUsesVal, expiresStr,
|
|
)
|
|
if err != nil {
|
|
return "", fmt.Errorf("CreateInvite insert: %w", err)
|
|
}
|
|
return code, nil
|
|
}
|
|
|
|
// GetInvite returns the invite for the given code, or nil if not found.
|
|
func (d *DB) GetInvite(code string) (*Invite, error) {
|
|
row := d.sqlDB.QueryRow(
|
|
`SELECT id, code, created_by, max_uses, use_count, expires_at, revoked, created_at
|
|
FROM invites WHERE code = ?`,
|
|
code,
|
|
)
|
|
inv := &Invite{}
|
|
var revoked int
|
|
err := row.Scan(
|
|
&inv.ID, &inv.Code, &inv.CreatedBy, &inv.MaxUses,
|
|
&inv.Uses, &inv.ExpiresAt, &revoked, &inv.CreatedAt,
|
|
)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("GetInvite: %w", err)
|
|
}
|
|
inv.Revoked = revoked != 0
|
|
return inv, nil
|
|
}
|
|
|
|
// UseInviteAtomic validates and increments the use_count in a single SQL
|
|
// statement, eliminating the TOCTOU race that exists when GetInvite and
|
|
// UseInvite are called as separate operations.
|
|
//
|
|
// The UPDATE only matches rows where:
|
|
// - the code exists
|
|
// - revoked = 0
|
|
// - max_uses IS NULL (unlimited) OR uses < max_uses
|
|
// - expires_at IS NULL (never) OR expires_at > now
|
|
//
|
|
// If zero rows are affected the invite is missing, revoked, expired, or
|
|
// exhausted — an error is returned in all such cases.
|
|
func (d *DB) UseInviteAtomic(code string) error {
|
|
result, err := d.sqlDB.Exec(
|
|
`UPDATE invites SET use_count = use_count + 1
|
|
WHERE code = ? AND revoked = 0
|
|
AND (max_uses IS NULL OR use_count < max_uses)
|
|
AND (expires_at IS NULL OR strftime('%s', expires_at) > strftime('%s', 'now'))`,
|
|
code,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("UseInviteAtomic: %w", err)
|
|
}
|
|
rows, err := result.RowsAffected()
|
|
if err != nil {
|
|
return fmt.Errorf("UseInviteAtomic rows: %w", err)
|
|
}
|
|
if rows == 0 {
|
|
return fmt.Errorf("UseInviteAtomic: invite not found, revoked, expired, or exhausted: %w", ErrNotFound)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RevokeInvite marks an invite as revoked.
|
|
func (d *DB) RevokeInvite(code string) error {
|
|
_, err := d.sqlDB.Exec(`UPDATE invites SET revoked = 1 WHERE code = ?`, code)
|
|
if err != nil {
|
|
return fmt.Errorf("RevokeInvite: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
// MemberSummary is a lightweight user shape for the ready payload.
|
|
type MemberSummary struct {
|
|
ID int64 `json:"id"`
|
|
Username string `json:"username"`
|
|
Avatar *string `json:"avatar"`
|
|
Status string `json:"status"`
|
|
Role string `json:"role"`
|
|
}
|
|
|
|
// 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
|
|
LIMIT 1000`,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("ListMembers: %w", err)
|
|
}
|
|
defer rows.Close() //nolint:errcheck
|
|
|
|
var members []MemberSummary
|
|
for rows.Next() {
|
|
var m MemberSummary
|
|
if err := rows.Scan(&m.ID, &m.Username, &m.Avatar, &m.Status, &m.Role); err != nil {
|
|
return nil, fmt.Errorf("ListMembers scan: %w", err)
|
|
}
|
|
members = append(members, m)
|
|
}
|
|
if rows.Err() != nil {
|
|
return nil, fmt.Errorf("ListMembers rows: %w", rows.Err())
|
|
}
|
|
if members == nil {
|
|
members = []MemberSummary{}
|
|
}
|
|
return members, nil
|
|
}
|
|
|
|
// generateInviteCode produces a random 8-byte (16-char hex) code.
|
|
func generateInviteCode() (string, error) {
|
|
b := make([]byte, 8)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return "", err
|
|
}
|
|
return hex.EncodeToString(b), nil
|
|
}
|