From 44641473ea7ddac24aecdfafbb1f657b96c92361 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sun, 19 Jul 2026 15:22:03 +0200 Subject: [PATCH] fix(quota): stop /me/quota leaking raw disk to guests The per-user quota widget was shown to everyone and the /me/quota payload returned free_disk_bytes (raw server free space) and active_uploaders to any authenticated guest. Gate the widget to staff (host/admin) on the upload and account pages, and zero the server-wide telemetry fields for non-staff in the handler. Guests still get their own used/limit so enforcement stays transparent. Co-Authored-By: Claude Opus 4.8 --- backend/src/handlers/me.rs | 20 +++++++++++++++++--- frontend/src/routes/account/+page.svelte | 5 +++-- frontend/src/routes/upload/+page.svelte | 12 +++++++++--- 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/backend/src/handlers/me.rs b/backend/src/handlers/me.rs index 76c4757..df717c8 100644 --- a/backend/src/handlers/me.rs +++ b/backend/src/handlers/me.rs @@ -14,7 +14,7 @@ use serde::Serialize; use crate::auth::middleware::AuthUser; use crate::error::AppError; use crate::handlers::upload::compute_storage_quota; -use crate::models::user::User; +use crate::models::user::{User, UserRole}; use crate::services::config; use crate::state::AppState; @@ -37,12 +37,26 @@ pub async fn get_quota( let estimate = compute_storage_quota(&state).await; + // Raw server telemetry (free disk, concurrent uploader count) is staff-only — it + // must never reach a guest, even though the guest upload UI no longer renders it. + // A guest still gets their own `used`/`limit` so enforcement stays transparent to + // the code paths that consume it; only the server-wide fields are zeroed. + let is_staff = matches!(auth.role, UserRole::Host | UserRole::Admin); + Ok(Json(QuotaDto { enabled: estimate.limit_bytes.is_some(), used_bytes: user.total_upload_bytes, limit_bytes: estimate.limit_bytes, - active_uploaders: estimate.active_uploaders, - free_disk_bytes: estimate.free_disk_bytes, + active_uploaders: if is_staff { + estimate.active_uploaders + } else { + 0 + }, + free_disk_bytes: if is_staff { + estimate.free_disk_bytes + } else { + 0 + }, })) } diff --git a/frontend/src/routes/account/+page.svelte b/frontend/src/routes/account/+page.svelte index eceb89e..739cef7 100644 --- a/frontend/src/routes/account/+page.svelte +++ b/frontend/src/routes/account/+page.svelte @@ -431,8 +431,9 @@ - - {#if $quotaStore.enabled && $quotaStore.limit_bytes != null} + + {#if (role === 'host' || role === 'admin') && $quotaStore.enabled && $quotaStore.limit_bytes != null}

import { goto } from '$app/navigation'; - import { getToken } from '$lib/auth'; + import { getToken, getRole } from '$lib/auth'; import { addToQueue, loadQueue } from '$lib/upload-queue'; import { toast } from '$lib/toast-store'; import { showBottomNav } from '$lib/ui-store'; @@ -24,6 +24,11 @@ const MAX_CAPTION_LENGTH = 2000; + // The storage widget is staff-only (host/admin). Guests never see server-derived + // storage figures — the backend also zeroes the raw-disk fields for non-staff, so + // this is UI-consistency on top of an API guarantee, not the security boundary. + const isStaff = getRole() === 'host' || getRole() === 'admin'; + // Quick-tag chips derived from caption as the user types let captionTags = $derived.by(() => { const matches = [...caption.matchAll(/#(\w+)/g)]; @@ -254,8 +259,9 @@

{/if} - - {#if $quotaStore.enabled && $quotaStore.limit_bytes != null} + + {#if isStaff && $quotaStore.enabled && $quotaStore.limit_bytes != null}