feat(OXICLOUD_DIRECT_PUT_MAX_BYTES): add a security limit on direct PUT

ensure files does not exeed OXICLOUD_MAX_UPLOAD_SIZE, prefer to deny from header rather consuming bandwidth
    add OXICLOUD_DIRECT_PUT_MAX_BYTES for direct PUT (non chunked), admins can fine tune their prefered values
This commit is contained in:
Edouard Vanbelle
2026-06-09 11:00:51 +02:00
parent 4e36de49eb
commit 50ea406719
11 changed files with 282 additions and 44 deletions
+17
View File
@@ -218,6 +218,16 @@ pub struct StorageConfig {
/// be far tighter than the whole-file cap and prevents one HTTP request
/// from monopolising server memory or disk. Env: `OXICLOUD_CHUNK_MAX_BYTES`.
pub chunk_max_bytes: usize,
/// Maximum size of a single non-chunked PUT body, in bytes (default:
/// 1 GiB). Set below `max_upload_size` so files larger than this are
/// pushed onto the chunked-upload protocol (`/api/uploads/…` or
/// `/dav/uploads/…`) — which is resilient to mid-transfer failures,
/// resumable, and bounded per-request by `chunk_max_bytes`. Without
/// this cap a 10 GB direct PUT spools 10 GB to disk in a single
/// request; a connection drop at 95 % loses everything. The server
/// returns 413 with a "use chunked upload" hint when a direct PUT
/// exceeds this cap. Env: `OXICLOUD_DIRECT_PUT_MAX_BYTES`.
pub direct_put_max_bytes: usize,
/// Directory for upload spool temp files. When `Some`, large uploads are
/// spooled here instead of the OS default temp dir (often tmpfs/RAM in
/// containers, where the spool's page-cache counts against the cgroup
@@ -376,6 +386,7 @@ impl Default for StorageConfig {
trash_retention_days: 30, // 30 days
max_upload_size: MAX_UPLOAD_SIZE,
chunk_max_bytes: 100 * 1024 * 1024, // 100 MB — sane upper bound for a single chunked-upload PUT
direct_put_max_bytes: 1024 * 1024 * 1024, // 1 GiB — pushes larger uploads onto the chunked protocol
upload_temp_dir: None,
chunk_dir: None,
usage_reconcile_secs: 600, // 10 minutes
@@ -1247,6 +1258,12 @@ impl AppConfig {
{
config.storage.chunk_max_bytes = val;
}
if let Ok(direct_max) =
env::var("OXICLOUD_DIRECT_PUT_MAX_BYTES").map(|v| v.parse::<usize>())
&& let Ok(val) = direct_max
{
config.storage.direct_put_max_bytes = val;
}
// Upload spool directory — keep large upload temp files off tmpfs/RAM
// (otherwise their page-cache counts against the cgroup memory limit).
@@ -162,6 +162,33 @@ impl ChunkedUploadHandler {
.into_response();
}
// ── Whole-file cap ──────────────────────────────────────────
// Reject upfront, before any chunk is uploaded — wasting
// bandwidth + server disk on an upload that's going to be
// rejected at /complete is the worst-of-both-worlds outcome.
// `max_upload_size` is the same ceiling that bounds direct
// PUTs (per-byte during streaming there; declared per-session
// here). When quotas are disabled, this is the only whole-file
// limit for chunked uploads — without it a hostile client
// could declare `total_size: 1 TB` and accumulate chunks
// until disk fills.
let max_upload = state.core.config.storage.max_upload_size as u64;
if request.total_size > max_upload {
tracing::warn!(
"⛔ CHUNKED UPLOAD REJECTED (total_size cap): user={}, file={}, declared={}, max={}",
auth_user.username,
request.filename,
request.total_size,
max_upload
);
return AppError::payload_too_large(format!(
"Declared total_size {} exceeds the server's `max_upload_size` cap ({} bytes). \
Raise OXICLOUD_MAX_UPLOAD_SIZE on the server if larger uploads are expected.",
request.total_size, max_upload
))
.into_response();
}
// ── Permission pre-check: caller must have Create on the target
// folder BEFORE we allocate a session and accept chunks. The
// upload service re-checks at finalize time, but failing here
@@ -927,8 +927,10 @@ async fn handle_put(
// another user — acceptable risk since PathResolver should always
// be enabled in production)
// Hard upload size limit from config
let max_upload = state.core.config.storage.max_upload_size;
// Direct PUT cap — see `nextcloud/webdav_handler::handle_put` for
// the reasoning. Files above `direct_put_max_bytes` must go through
// the chunked-upload protocol (`/api/uploads/…`) which is resumable.
let max_upload = state.core.config.storage.direct_put_max_bytes;
// Extract content type before consuming the request
let content_type = req
+8 -1
View File
@@ -596,7 +596,14 @@ async fn handle_put(
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<i64>().ok());
let max_upload = state.core.config.storage.max_upload_size;
// ── Direct PUT cap ───────────────────────────────────────────────
// We use `direct_put_max_bytes` (default 1 GiB), not `max_upload_size`
// (default 10 GB). Larger files must come through the chunked upload
// protocol (`/dav/uploads/...`) which is resumable on failure and
// bounded per-request by `chunk_max_bytes`. Trying to stream a
// multi-GB body through a single PUT is a footgun: a connection drop
// at 95 % loses everything.
let max_upload = state.core.config.storage.direct_put_max_bytes;
// Stream the body to a temp file + incremental hash — never buffer the
// full upload in RAM. The old `body::to_bytes` path loaded the entire
+4 -1
View File
@@ -70,7 +70,10 @@ pub async fn spool_body_to_temp(
drop(file);
let _ = tokio::fs::remove_file(&temp_path).await;
return Err(AppError::payload_too_large(format!(
"Upload exceeds maximum size of {max_upload} bytes"
"Upload body exceeds the direct-PUT cap ({max_upload} bytes). \
Use the chunked-upload protocol (REST: `/api/uploads/...`, \
NextCloud: `/remote.php/dav/uploads/...`) for files larger than this. \
Chunked uploads are resumable on transient failure."
)));
}
hasher.update(chunk);
+1
View File
@@ -150,6 +150,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// hardest to spot from chunked-upload tests.
tracing::info!(
max_upload_size_mb = config.storage.max_upload_size / (1024 * 1024),
direct_put_max_bytes_mb = config.storage.direct_put_max_bytes / (1024 * 1024),
chunk_max_bytes_mb = config.storage.chunk_max_bytes / (1024 * 1024),
"Upload limits loaded from config"
);