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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user