Files
OwnCord/Server/storage/storage.go
T
jevb a40b42bbed fix: resolve 24 critical and high issues from full code & security review
CRITICAL (5):
- Hub panic recovery now calls h.Stop() after 3 panics (ws/hub.go)
- Ring buffer EventsSince returns non-nil empty slice for current seq (ws/ringbuffer.go)
- PTT event listener stores unsubscribe handle to prevent leak (ptt.ts)
- verifyTotp respects config.allowSelfSigned instead of hardcoding (api.ts)
- ptt_listen_for_key uses spawn_blocking to avoid thread pool starvation (ptt.rs)

HIGH - Server (13):
- TOTP rate-limit checked after body decode; counters reset on success
- TOTP enable returns 409 if already enabled (must disable first)
- Global search pre-computes accessible channel IDs for FTS WHERE clause
- DeleteAccount queries roles by name instead of hard-coded IDs
- BackupToSafe uses absClean in VACUUM INTO
- Voice camera slot uses atomic EnableCameraIfUnderLimit DB method
- readPump snapshots voiceChID before unregister for TOCTOU safety
- Voice join sets state after token send; rollback takes broadcast flag
- Updater download uses probe pattern instead of overflow write
- Webhook checks Authorization header before reading body
- Storage.Save adds fsync and fixes double-close
- Default WS origin denies cross-origin (was: accept all)

HIGH - Client (6):
- WS reconnect uses generation counter to discard stale events
- AudioPipeline uses generation counter against stale worklet callbacks
- Screenshare mute state preserved across reconnect (not full leave)
- handleVoiceToken uses iterative loop instead of unbounded recursion
- store.ts re-entrancy guard with pending update queue
- Notification AudioContext cleaned up on logout

Reviewed by 4 parallel agents across Server Core, Server Realtime,
Client & Tauri, and Security. 55 total findings; 24 CRITICAL+HIGH
fixed here, 31 MEDIUM+LOW tracked in vault backlog (T-265–T-295).
2026-04-01 09:23:17 +02:00

183 lines
5.6 KiB
Go

// Package storage handles file upload validation and storage for the OwnCord server.
package storage
import (
"bytes"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"strings"
)
// blockedMagic maps format names to their magic byte signatures. Files whose
// leading bytes match any entry are rejected by ValidateFileType.
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.)
}
// ValidateFileType checks the first few bytes of a file against known blocked
// magic bytes. It returns an error if the content matches a blocked file type,
// or nil if the content is allowed.
func ValidateFileType(header []byte) error {
for _, blocked := range blockedMagic {
if len(header) >= len(blocked.magic) && bytes.Equal(header[:len(blocked.magic)], blocked.magic) {
return fmt.Errorf("blocked file type: %s", blocked.name)
}
}
return nil
}
// Storage manages file uploads on disk.
type Storage struct {
dir string
maxSizeMB int
}
// New creates a Storage instance that stores files in dir.
// dir is created if it does not exist.
func New(dir string, maxSizeMB int) (*Storage, error) {
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, fmt.Errorf("creating storage dir %s: %w", dir, err)
}
return &Storage{dir: dir, maxSizeMB: maxSizeMB}, nil
}
// sanitizeFilename validates that name is safe to use as a filename inside the
// storage directory. It must be a plain basename with no path separators, must
// not be empty, ".", or "..", and must not start with ".".
func sanitizeFilename(name string) error {
if name == "" {
return fmt.Errorf("invalid filename: empty string")
}
// filepath.Base strips any directory component; if it differs from the
// original input the caller smuggled a path separator.
base := filepath.Base(name)
if base != name {
return fmt.Errorf("invalid filename %q: must not contain path separators", name)
}
// Reject "." and ".." explicitly.
if name == "." || name == ".." {
return fmt.Errorf("invalid filename %q: reserved name", name)
}
// Reject filenames starting with "." (hidden/config files).
if strings.HasPrefix(name, ".") {
return fmt.Errorf("invalid filename %q: must not start with '.'", name)
}
// Explicitly reject embedded separators on both Unix and Windows.
if strings.ContainsAny(name, "/\\") {
return fmt.Errorf("invalid filename %q: must not contain path separators", name)
}
return nil
}
// resolvedPath builds the absolute target path and verifies it stays within
// the storage directory.
func (s *Storage) resolvedPath(name string) (string, error) {
absDir, err := filepath.Abs(s.dir)
if err != nil {
return "", fmt.Errorf("resolving storage dir: %w", err)
}
target := filepath.Join(absDir, name)
// Ensure the joined path is still under absDir.
if !strings.HasPrefix(target, absDir+string(filepath.Separator)) &&
target != absDir {
return "", fmt.Errorf("resolved path %q escapes storage directory", target)
}
return target, nil
}
// Save writes the content from r to a file named by uuid within the storage dir.
// It reads the first 8 bytes to validate the file type (rejecting executables
// and scripts) before writing the full content to disk.
// The caller is responsible for generating a UUID filename.
func (s *Storage) Save(uuid string, r io.Reader) error {
if err := sanitizeFilename(uuid); err != nil {
return err
}
dst, err := s.resolvedPath(uuid)
if err != nil {
return err
}
// Read the first 8 bytes to check magic bytes without consuming the stream.
var header [8]byte
n, err := io.ReadFull(r, header[:])
if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF {
return fmt.Errorf("reading file header: %w", err)
}
headerSlice := header[:n]
if err := ValidateFileType(headerSlice); err != nil {
return err
}
f, err := os.Create(dst)
if err != nil {
return fmt.Errorf("creating file %s: %w", dst, err)
}
closed := false
defer func() {
if !closed {
_ = f.Close()
}
}()
// Reconstruct the full stream: header bytes we already read + remainder.
maxBytes := int64(s.maxSizeMB) * 1024 * 1024
full := io.MultiReader(bytes.NewReader(headerSlice), r)
limited := io.LimitReader(full, maxBytes)
written, err := io.Copy(f, limited)
if err != nil {
return fmt.Errorf("writing file: %w", err)
}
// Probe for one more byte to detect if the file exceeds the limit.
if written == maxBytes {
var probe [1]byte
if n, _ := full.Read(probe[:]); n > 0 {
_ = f.Close()
closed = true
if removeErr := os.Remove(dst); removeErr != nil {
slog.Error("storage: failed to remove oversized file", "path", dst, "err", removeErr)
}
return fmt.Errorf("file exceeds maximum size of %d MB", s.maxSizeMB)
}
}
if syncErr := f.Sync(); syncErr != nil {
return fmt.Errorf("syncing file %s: %w", dst, syncErr)
}
return nil
}
// Delete removes the file named uuid from the storage dir.
func (s *Storage) Delete(uuid string) error {
if err := sanitizeFilename(uuid); err != nil {
return err
}
dst, err := s.resolvedPath(uuid)
if err != nil {
return err
}
return os.Remove(dst)
}
// Open opens the file named uuid for reading.
func (s *Storage) Open(uuid string) (*os.File, error) {
if err := sanitizeFilename(uuid); err != nil {
return nil, err
}
dst, err := s.resolvedPath(uuid)
if err != nil {
return nil, err
}
return os.Open(dst)
}