Files
OwnCord/Server/api/upload_handler.go
T
J3vbandClaude Opus 5 17b17eb1b3 fix(security): close all 13 findings from the 2026-07-28 server scan, plus dependabot rollup (#1264)
* fix(admin): reject banned users in admin auth (F1)

adminAuthMiddleware accepted a Bearer token on session validity plus the
ADMINISTRATOR bit alone and never consulted ban state, so a ban never
revoked admin-panel access. Adds the auth.IsEffectivelyBanned guard that
api.AuthMiddleware already uses, at both admin credential-resolution points.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ws): gate the voice-channel text subscription on READ_MESSAGES (F2)

registerNow subscribed any client with voice state to that channel's
text-message topic regardless of READ_MESSAGES. The handshake's
already-computed readable-channel set is now passed into registerNow and the
subscription only happens when the voice channel is in it, preserving
authorized reconnect delivery.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(service): require READ_MESSAGES to delete messages (F4)

The non-DM delete gate checked MANAGE_MESSAGES without READ_MESSAGES, so a
role locked out of a private channel could still delete every message in it.
Requires ReadMessages alongside ManageMessages (and alongside SendMessages on
the author path) and derives the mod flag from that same gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(service): require READ_MESSAGES alongside MANAGE_MESSAGES in SetMessagePinned (F8)

Pin/unpin checked only MANAGE_MESSAGES, so a role denied READ on a private
channel could still pin and unpin its messages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(service): enforce the DM block at every DM interaction sink (F5)

The DM block was only checked on send, leaving edit, reactions, pins and
typing as bypasses. One shared requireDMNotBlocked is now called from all of
them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ws): re-check CONNECT_VOICE when minting a refreshed LiveKit token (F6)

voice_token_refresh re-minted a LiveKit token without re-checking
CONNECT_VOICE, so a revoked permission kept working for the life of the
session. The permission is now re-checked where the token is minted, and a
60s sweep evicts participants whose permission was revoked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ws): rate-limit voice_e2ee_offer after validation, keyed on server state (F7)

The limiter key was built from unvalidated client input, letting an attacker
grow the limiter map without bound. The limiter now runs after validation and
keys on (sender, voiceChannelID), never on client input.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ws): deliver voice_state/voice_leave only to roles that may read the channel (F9)

Voice state of private channels was broadcast to every connected client,
leaking channel membership. All 11 emit sites now route through one
READ-filtered fan-out, channel-tagged so replay filters too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(api): redact the LiveKit access token from proxy dial-failure logs (F10)

A dial failure wrote the LiveKit access-token JWT into the server log via the
URL in the error. redactKey now runs on the error before it reaches slog.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(auth): reserve the [deleted-N] username namespace (F11, F12)

The tombstone username namespace used by account deletion was freely
registrable, letting a user impersonate a deleted account. The namespace is
now reserved at validation, and DeleteAccount retries with a random suffix on
collision.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(api): strip Unicode format characters from upload filenames (F13)

The attachment filename sanitizer stripped control characters but not
unicode.Cf, allowing bidi-override extension spoofing. Cf is now stripped
alongside controls and foreign path separators are cut.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(api): reserve the login attempt before the bcrypt compare (F3)

The per-username lockout was a read-only IsLockedOut check followed by a
failure recorded only after the ~250ms bcrypt compare, so N concurrent
requests all passed the stale check before any of them recorded a failure.
The per-username cap is the only cross-IP brute-force defence (the middleware
limits per IP), so a distributed burst landed N guesses per 15-minute window
instead of 10.

Both counters are now reserved atomically with limiter.Allow before the
compare, and the lockout decision moves to the read-only limiter.Check so the
reservation is not double-counted. The limits are sized at threshold+1, which
leaves the sequential accepted-input set byte-identical to the previous
behaviour: failures 1-10 still land, the 10th still trips the lockout, and the
account owner's correct password on attempt 10 still returns 200. Sizing at
threshold instead would make 9 cheap wrong guesses convert the victim's own
correct password into a 15-minute lockout - the regression that got two
earlier attempts at this fix rejected, now pinned by a boundary test.

Deliberately scoped to handleLogin. The report also suggested widening to the
password-confirmation endpoints, but those are authenticated, share a single
pw_confirm_fail key across the TOTP endpoints, and widening there is what got
the first attempt rejected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(deps): bump five Rust dependencies in /Client/tauri-client/src-tauri

Rolls up dependabot #1259, #1260, #1261, #1262 and #1263:

  tauri-build        2.5.6 -> 2.6.3
  tauri-plugin-fs    2.4.5 -> 2.5.1
  tauri-plugin-http  2.5.7 -> 2.5.9
  tauri-plugin-store 2.4.2 -> 2.4.4
  webpki-roots       1.0.6 -> 1.0.9

All five are lockfile-only; the manifest constraints already permitted the
new versions. The five PRs each rewrote overlapping regions of the same
Cargo.lock and so could not be merged independently, so the lockfile was
regenerated with cargo update --precise for each crate instead. The combined
result is smaller than the sum of the five diffs because they share
transitive updates.

Verified with cargo check --locked --all-targets (exit 0).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(deps): bump typescript-eslint from 8.58.0 to 8.65.0 in /Client/tauri-client

Dependabot #1258.

8.65.0 improves @typescript-eslint/no-unnecessary-type-assertion, which
surfaces four assertions that were already redundant and now fail the lint
gate. They are removed here rather than in a follow-up so no commit in this
branch leaves `npm run lint` red:

  UserBar.ts / members.store.ts  "online" as UserStatus -> "online"
                                 (the receiver already accepts the literal)
  media.ts                       drops `as RequestInit` on a literal that is
                                 already assignable
  LoginForm.ts                   drops `as { message: unknown }` made
                                 redundant by the `"message" in err` narrowing

All four are the rule's own autofix. Verified: npm run typecheck, npm run
lint, npm run format:check all clean, and the unit suite is 3572/3572 green
across 129 files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:48:47 +02:00

351 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package api
import (
"errors"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"io"
"log/slog"
"mime"
"net/http"
"path/filepath"
"strings"
"time"
"unicode"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/owncord/server/auth"
"github.com/owncord/server/db"
"github.com/owncord/server/permissions"
"github.com/owncord/server/service"
"github.com/owncord/server/storage"
)
// uploadResponse is the JSON shape returned by POST /api/v1/uploads.
type uploadResponse struct {
ID string `json:"id"`
Filename string `json:"filename"`
Size int64 `json:"size"`
Mime string `json:"mime"`
URL string `json:"url"`
Width *int `json:"width,omitempty"`
Height *int `json:"height,omitempty"`
}
// sanitizeUploadFilename cleans an upload filename: strips control and
// invisible formatting characters, removes path separators, and truncates to a
// safe length.
func sanitizeUploadFilename(name string) string {
// Strip path components — use only the base name.
name = filepath.Base(name)
// filepath.Base only understands the *server* OS's separator, so a
// backslash survives on a Linux server and is then a path separator on the
// victim's Windows client, where the name is pre-filled into a save dialog.
if i := strings.LastIndexByte(name, '\\'); i >= 0 {
name = name[i+1:]
}
// Remove control characters and invisible formatting characters.
var sb strings.Builder
for _, r := range name {
// unicode.Cf covers the bidi overrides (U+202AU+202E, U+2066U+2069):
// invisible characters that reorder how the name renders, so an
// attachment can display a harmless-looking extension to every other
// member of the channel while really being an executable script — and
// the same string is what the native save dialog pre-fills. This is the
// rule auth.ValidateUsername already applies to usernames.
if unicode.IsControl(r) || unicode.In(r, unicode.Cf) {
continue
}
sb.WriteRune(r)
}
name = strings.TrimSpace(sb.String())
// Truncate to 255 characters (filesystem limit).
if len(name) > maxUploadFilenameLength {
name = name[:maxUploadFilenameLength]
}
if name == "" || name == "." || name == ".." {
name = "unnamed"
}
return name
}
// isUnsafeInlineMIME returns true for MIME types that could execute active
// content (scripts, markup) if served inline under the OwnCord origin.
func isUnsafeInlineMIME(mimeType string) bool {
// Normalize: take the base type before any parameters (e.g. "text/html; charset=utf-8").
base := strings.SplitN(mimeType, ";", 2)[0]
base = strings.TrimSpace(strings.ToLower(base))
switch base {
case "text/html", "application/xhtml+xml",
"image/svg+xml", "text/xml", "application/xml",
"application/pdf",
"text/xsl", "text/xslt":
return true
}
return false
}
// MountUploadRoutes registers upload and file-serving endpoints.
// allowedOrigins controls the Access-Control-Allow-Origin header on served files.
//
// permSvc MUST be non-nil — handleServeFile dereferences it to enforce
// per-channel ACLs on every file download. A nil permSvc would panic for
// any authenticated file request, so we fail fast at mount time rather
// than let the first user hit a 500.
func MountUploadRoutes(r chi.Router, database *db.DB, store *storage.Storage, limiter *auth.RateLimiter, allowedOrigins []string, permSvc *service.PermissionService) {
if permSvc == nil {
panic("api: MountUploadRoutes requires a non-nil PermissionService")
}
// Upload requires authentication and a higher body size limit (100 MB).
r.With(
AuthMiddleware(database),
MaxBodySize(uploadMaxBodySize),
).Post("/api/v1/uploads", handleUpload(database, store, limiter))
// File serving requires authentication for channel-level access control.
r.With(AuthMiddleware(database)).Get("/api/v1/files/{id}", handleServeFile(database, store, allowedOrigins, permSvc))
}
func handleUpload(database *db.DB, store *storage.Storage, limiter *auth.RateLimiter) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// BUG-131: Per-user upload rate limit to prevent disk exhaustion.
user, ok := r.Context().Value(UserKey).(*db.User)
if ok && user != nil {
uploadKey := fmt.Sprintf("upload:%d", user.ID)
if !limiter.Allow(uploadKey, uploadRateLimitPerMinute, time.Minute) {
writeJSON(w, http.StatusTooManyRequests, errorResponse{
Error: "RATE_LIMITED",
Message: "upload rate limit exceeded, try again later",
})
return
}
}
// Limit request body size to prevent abuse.
r.Body = http.MaxBytesReader(w, r.Body, uploadMaxBodySize)
// Parse multipart form — 10 MB in memory, rest on disk.
if err := r.ParseMultipartForm(multipartMemoryLimit); err != nil {
writeJSON(w, http.StatusBadRequest, errorResponse{
Error: "BAD_REQUEST",
Message: "invalid multipart form",
})
return
}
file, header, err := r.FormFile("file")
if err != nil {
writeJSON(w, http.StatusBadRequest, errorResponse{
Error: "BAD_REQUEST",
Message: "missing file field",
})
return
}
defer file.Close() //nolint:errcheck
// Generate UUID for storage.
fileID := uuid.New().String()
// Detect MIME type from actual file bytes (never trust client header).
var sniffBuf [512]byte
n, readErr := file.Read(sniffBuf[:])
if readErr != nil && !errors.Is(readErr, io.EOF) && !errors.Is(readErr, io.ErrUnexpectedEOF) {
writeJSON(w, http.StatusBadRequest, errorResponse{
Error: "BAD_REQUEST",
Message: "failed to read uploaded file",
})
return
}
detectedMime := http.DetectContentType(sniffBuf[:n])
// Seek back so the full content is available for storage.
if _, seekErr := file.Seek(0, 0); seekErr != nil {
writeJSON(w, http.StatusInternalServerError, errorResponse{
Error: "INTERNAL_ERROR",
Message: "failed to process uploaded file",
})
return
}
mime := detectedMime
// Store file on disk (validates file type via magic bytes).
writtenBytes, saveErr := store.Save(fileID, file)
if saveErr != nil {
slog.Warn("file upload rejected", "error", saveErr)
writeJSON(w, http.StatusBadRequest, errorResponse{
Error: "BAD_REQUEST",
Message: fmt.Sprintf("upload rejected: %s", saveErr),
})
return
}
// Extract image dimensions if the file is an image.
var width, height *int
if strings.HasPrefix(mime, "image/") {
f, openErr := store.Open(fileID)
if openErr == nil {
cfg, _, decErr := image.DecodeConfig(f)
f.Close() //nolint:errcheck
if decErr == nil {
w2, h2 := cfg.Width, cfg.Height
width = &w2
height = &h2
} else {
slog.Warn("failed to decode image dimensions", "id", fileID, "error", decErr)
}
}
}
// Insert attachment record in DB (unlinked — message_id is NULL).
user, _ = r.Context().Value(UserKey).(*db.User)
safeFilename := sanitizeUploadFilename(header.Filename)
if err := database.CreateAttachment(r.Context(), fileID, user.ID, safeFilename, fileID, mime, writtenBytes, width, height); err != nil {
// Clean up stored file on DB failure.
_ = store.Delete(fileID)
slog.Error("failed to create attachment record", "error", err)
writeJSON(w, http.StatusInternalServerError, errorResponse{
Error: "INTERNAL_ERROR",
Message: "failed to save attachment",
})
return
}
slog.Info("file uploaded", "id", fileID, "filename", safeFilename, "size", writtenBytes, "mime", mime)
writeJSON(w, http.StatusCreated, uploadResponse{
ID: fileID,
Filename: safeFilename,
Size: writtenBytes,
Mime: mime,
URL: "/api/v1/files/" + fileID,
Width: width,
Height: height,
})
}
}
func handleServeFile(database *db.DB, store *storage.Storage, allowedOrigins []string, permSvc *service.PermissionService) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
fileID := chi.URLParam(r, "id")
if fileID == "" {
http.NotFound(w, r)
return
}
user, _ := r.Context().Value(UserKey).(*db.User)
role, _ := r.Context().Value(RoleKey).(*db.Role)
// Look up attachment metadata with channel context.
aa, err := database.GetAttachmentWithChannel(r.Context(), fileID)
if err != nil {
slog.Error("failed to look up attachment", "id", fileID, "error", err)
writeJSON(w, http.StatusInternalServerError, errorResponse{
Error: "INTERNAL_ERROR",
Message: "internal server error",
})
return
}
if aa == nil {
http.NotFound(w, r)
return
}
// ── Access control ──────────────────────────────────────────────
isAdmin := role != nil && permissions.HasAdmin(role.Permissions)
if !isAdmin {
if aa.ChannelID == nil {
// Unlinked attachment — only the uploader may access.
// 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 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",
Message: "you do not have access to this file",
})
return
}
} else {
// Linked attachment — check channel permissions.
if aa.ChannelType == "dm" {
if user == nil {
writeJSON(w, http.StatusForbidden, errorResponse{
Error: "FORBIDDEN",
Message: "you do not have access to this file",
})
return
}
ok, dmErr := database.IsDMParticipant(r.Context(), user.ID, *aa.ChannelID)
if dmErr != nil || !ok {
writeJSON(w, http.StatusForbidden, errorResponse{
Error: "FORBIDDEN",
Message: "you do not have access to this file",
})
return
}
} else if user == nil || !permSvc.HasChannelPerm(r.Context(), user.ID, *aa.ChannelID, permissions.ReadMessages) {
writeJSON(w, http.StatusForbidden, errorResponse{
Error: "FORBIDDEN",
Message: "you do not have access to this file",
})
return
}
}
}
// Open file from storage.
f, err := store.Open(aa.StoredAs)
if err != nil {
http.NotFound(w, r)
return
}
defer f.Close() //nolint:errcheck
// Set headers before ServeContent to ensure correct MIME type.
w.Header().Set("Content-Type", aa.MimeType)
// BUG-118: Force download for MIME types that could execute content
// under the OwnCord origin (HTML, SVG, XML, PDF).
disposition := "inline"
if isUnsafeInlineMIME(aa.MimeType) {
disposition = "attachment"
}
w.Header().Set("Content-Disposition", mime.FormatMediaType(disposition, map[string]string{"filename": aa.Filename}))
// These downloads are access-controlled, so they must never be stored by
// shared/proxy caches (info-leak). Mark private and force revalidation.
// W3-4: no-cache forces revalidation on every use, so a max-age is dead
// weight alongside it — private + no-cache expresses the intent exactly.
w.Header().Set("Cache-Control", "private, no-cache")
// The Access-Control-Allow-Origin header below reflects the request
// Origin, so responses vary by Origin and must not be cross-served.
w.Header().Set("Vary", "Origin")
// CORS: allow webview to read the response body using configured origins.
if origin := r.Header.Get("Origin"); origin != "" {
for _, allowed := range allowedOrigins {
if allowed == "*" || strings.EqualFold(allowed, origin) {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Expose-Headers", "Content-Type, Content-Length")
break
}
}
}
w.Header().Set("X-Content-Type-Options", "nosniff")
// Use the actual file modification time so If-Modified-Since works correctly.
var modTime time.Time
if info, statErr := f.Stat(); statErr == nil {
modTime = info.ModTime()
}
http.ServeContent(w, r, aa.Filename, modTime, f)
}
}