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 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-19 15:22:03 +02:00
parent 57a907eca5
commit 44641473ea
3 changed files with 29 additions and 8 deletions

View File

@@ -14,7 +14,7 @@ use serde::Serialize;
use crate::auth::middleware::AuthUser; use crate::auth::middleware::AuthUser;
use crate::error::AppError; use crate::error::AppError;
use crate::handlers::upload::compute_storage_quota; use crate::handlers::upload::compute_storage_quota;
use crate::models::user::User; use crate::models::user::{User, UserRole};
use crate::services::config; use crate::services::config;
use crate::state::AppState; use crate::state::AppState;
@@ -37,12 +37,26 @@ pub async fn get_quota(
let estimate = compute_storage_quota(&state).await; 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 { Ok(Json(QuotaDto {
enabled: estimate.limit_bytes.is_some(), enabled: estimate.limit_bytes.is_some(),
used_bytes: user.total_upload_bytes, used_bytes: user.total_upload_bytes,
limit_bytes: estimate.limit_bytes, limit_bytes: estimate.limit_bytes,
active_uploaders: estimate.active_uploaders, active_uploaders: if is_staff {
free_disk_bytes: estimate.free_disk_bytes, estimate.active_uploaders
} else {
0
},
free_disk_bytes: if is_staff {
estimate.free_disk_bytes
} else {
0
},
})) }))
} }

View File

@@ -431,8 +431,9 @@
</div> </div>
</div> </div>
<!-- Per-user quota widget --> <!-- Per-user quota widget — staff-only (host/admin); guests never see the
{#if $quotaStore.enabled && $quotaStore.limit_bytes != null} server-derived storage figures. -->
{#if (role === 'host' || role === 'admin') && $quotaStore.enabled && $quotaStore.limit_bytes != null}
<div class="card p-5"> <div class="card p-5">
<h2 <h2
class="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400" class="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { getToken } from '$lib/auth'; import { getToken, getRole } from '$lib/auth';
import { addToQueue, loadQueue } from '$lib/upload-queue'; import { addToQueue, loadQueue } from '$lib/upload-queue';
import { toast } from '$lib/toast-store'; import { toast } from '$lib/toast-store';
import { showBottomNav } from '$lib/ui-store'; import { showBottomNav } from '$lib/ui-store';
@@ -24,6 +24,11 @@
const MAX_CAPTION_LENGTH = 2000; 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 // Quick-tag chips derived from caption as the user types
let captionTags = $derived.by(() => { let captionTags = $derived.by(() => {
const matches = [...caption.matchAll(/#(\w+)/g)]; const matches = [...caption.matchAll(/#(\w+)/g)];
@@ -254,8 +259,9 @@
</div> </div>
{/if} {/if}
<!-- Per-user quota — hidden when admin disabled enforcement --> <!-- Per-user quota — staff-only (never shown to guests), and also hidden when
{#if $quotaStore.enabled && $quotaStore.limit_bytes != null} admin disabled enforcement. -->
{#if isStaff && $quotaStore.enabled && $quotaStore.limit_bytes != null}
<div class="px-4 pt-3 text-xs text-gray-500 dark:text-gray-400"> <div class="px-4 pt-3 text-xs text-gray-500 dark:text-gray-400">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span <span