Skip to content

fix(web): cap maximum upload size#1930

Open
richiemcilroy wants to merge 4 commits into
mainfrom
security/upload-size-limit
Open

fix(web): cap maximum upload size#1930
richiemcilroy wants to merge 4 commits into
mainfrom
security/upload-size-limit

Conversation

@richiemcilroy

@richiemcilroy richiemcilroy commented Jun 19, 2026

Copy link
Copy Markdown
Member

Caps the maximum upload size on presigned POST uploads and at multipart completion to prevent unbounded storage usage.

Greptile Summary

This PR caps upload sizes at 100 GiB by enforcing a content-length-range policy on all presigned POST URLs and adding a server-side total-size gate at multipart completion. It also tightens the part-size Zod schema to z.number().int().nonnegative() and imports MAX_UPLOAD_BYTES from a single exported source of truth.

  • S3BucketAccess.ts: Injects a content-length-range condition into every presigned POST, capping the effective max at MAX_UPLOAD_BYTES even when a caller supplies a looser range, and exports the constant.
  • multipart.ts: Sums client-reported part sizes against MAX_UPLOAD_BYTES before calling CompleteMultipartUpload; on excess it aborts the incomplete MPU, deletes the videoUploads row (each step independently guarded), and returns 413. Part size is now validated as a non-negative integer, closing both the negative-value and fractional-value bypass paths.
  • index.ts: Re-exports MAX_UPLOAD_BYTES so it can be shared without duplication.

Confidence Score: 5/5

This PR is safe to merge; the upload cap and schema hardening are self-contained and do not alter any existing data path.

The changes are additive guards — a new schema constraint, a new presigned-POST policy injection, and a new total-size check with cleanup. All previously flagged issues (negative-size bypass, MAX_UPLOAD_BYTES duplication, fractional-size bypass) have been addressed. The cleanup logic for the 413 path correctly handles abort and delete independently so a failure in one step does not suppress the other.

No files require special attention.

Important Files Changed

Filename Overview
apps/web/app/api/upload/[...route]/multipart.ts Imports MAX_UPLOAD_BYTES from @cap/web-backend (removing duplication), tightens the part-size schema to z.number().int().nonnegative(), and adds a total-size gate with MPU abort + DB row cleanup before the 413 response.
packages/web-backend/src/S3Buckets/S3BucketAccess.ts Adds MAX_UPLOAD_BYTES (100 GiB) and injects a content-length-range condition into every presigned POST URL, capping the max at that constant while respecting a caller-supplied tighter range.
packages/web-backend/src/index.ts Re-exports MAX_UPLOAD_BYTES so callers can share the single source of truth without duplicating the constant.

Comments Outside Diff (1)

  1. apps/web/app/api/upload/[...route]/multipart.ts, line 293-299 (link)

    P1 Negative-size bypass of the upload capz.number() accepts any real number, including negatives. A client that submits size: -N for each part makes totalUploadSize negative, so totalUploadSize > MAX_UPLOAD_BYTES is always false and the guard never fires. Adding .nonnegative() (i.e., z.number().nonnegative()) to the size field closes this bypass at the schema layer before the cap logic is ever reached.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: apps/web/app/api/upload/[...route]/multipart.ts
    Line: 293-299
    
    Comment:
    **Negative-size bypass of the upload cap**`z.number()` accepts any real number, including negatives. A client that submits `size: -N` for each part makes `totalUploadSize` negative, so `totalUploadSize > MAX_UPLOAD_BYTES` is always `false` and the guard never fires. Adding `.nonnegative()` (i.e., `z.number().nonnegative()`) to the `size` field closes this bypass at the schema layer before the cap logic is ever reached.
    
    How can I resolve this? If you propose a fix, please make it concise.

Reviews (5): Last reviewed commit: "fix(web): require integer part sizes and..." | Re-trigger Greptile

@polarityinc

polarityinc Bot commented Jun 19, 2026

Copy link
Copy Markdown

Paragon Review Skipped

Hi @richiemcilroy! Your Polarity credit balance is insufficient to complete this review.

Please visit https://app.paragon.run to finish your review.

Comment thread apps/web/app/api/upload/[...route]/multipart.ts Outdated
Comment thread apps/web/app/api/upload/[...route]/multipart.ts Outdated
Comment thread apps/web/app/api/upload/[...route]/multipart.ts Outdated
Comment thread packages/web-backend/src/S3Buckets/S3BucketAccess.ts
@richiemcilroy

Copy link
Copy Markdown
Member Author

@greptileai please review the PR

@richiemcilroy

Copy link
Copy Markdown
Member Author

@greptileai please review the PR

Comment thread apps/web/app/api/upload/[...route]/multipart.ts
@superagent-security superagent-security Bot added the pr:flagged PR flagged for review by security analysis. label Jun 19, 2026
Comment thread apps/web/app/api/upload/[...route]/multipart.ts
Comment thread packages/web-backend/src/S3Buckets/S3BucketAccess.ts
@richiemcilroy

Copy link
Copy Markdown
Member Author

@greptileai please review the PR

@superagent-security superagent-security Bot removed the pr:flagged PR flagged for review by security analysis. label Jun 19, 2026
@superagent-security

Copy link
Copy Markdown

Superagent didn't find any vulnerabilities or security issues in this PR.

Comment thread apps/web/app/api/upload/[...route]/multipart.ts Outdated
Comment thread packages/web-backend/src/S3Buckets/S3BucketAccess.ts
@richiemcilroy

Copy link
Copy Markdown
Member Author

@greptileai please review the PR

Comment on lines +288 to +312
const callerMin =
callerRange &&
typeof callerRange[1] === "number" &&
Number.isFinite(callerRange[1])
? Math.max(0, callerRange[1])
: 0;
const callerMax =
callerRange &&
typeof callerRange[2] === "number" &&
Number.isFinite(callerRange[2])
? Math.max(0, callerRange[2])
: MAX_UPLOAD_BYTES;
const otherConditions = callerConditions.filter(
(condition) => !isLengthRange(condition),
);
return createPresignedPost(client, {
...args,
Conditions: [
[
"content-length-range",
callerMin,
Math.min(callerMax, MAX_UPLOAD_BYTES),
],
...otherConditions,
],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One edge case: if the caller supplies a content-length-range where min exceeds MAX_UPLOAD_BYTES (or min > max), we’ll generate an invalid policy (min > max). Might be worth clamping + normalizing so we always emit a valid range.

Suggested change
const callerMin =
callerRange &&
typeof callerRange[1] === "number" &&
Number.isFinite(callerRange[1])
? Math.max(0, callerRange[1])
: 0;
const callerMax =
callerRange &&
typeof callerRange[2] === "number" &&
Number.isFinite(callerRange[2])
? Math.max(0, callerRange[2])
: MAX_UPLOAD_BYTES;
const otherConditions = callerConditions.filter(
(condition) => !isLengthRange(condition),
);
return createPresignedPost(client, {
...args,
Conditions: [
[
"content-length-range",
callerMin,
Math.min(callerMax, MAX_UPLOAD_BYTES),
],
...otherConditions,
],
const callerMin =
callerRange &&
typeof callerRange[1] === "number" &&
Number.isFinite(callerRange[1])
? Math.max(0, callerRange[1])
: 0;
const callerMax =
callerRange &&
typeof callerRange[2] === "number" &&
Number.isFinite(callerRange[2])
? Math.max(0, callerRange[2])
: MAX_UPLOAD_BYTES;
const minBytes = Math.min(callerMin, MAX_UPLOAD_BYTES);
const maxBytes = Math.min(Math.max(callerMax, minBytes), MAX_UPLOAD_BYTES);
const otherConditions = callerConditions.filter(
(condition) => !isLengthRange(condition),
);
return createPresignedPost(client, {
...args,
Conditions: [
["content-length-range", minBytes, maxBytes],
...otherConditions,
],
Bucket: provider.bucket,
Key: key,
});

// Server-side backstop for the maximum upload size. Presigned POST URLs
// enforce a content-length-range policy, but presigned PUT part URLs
// cannot enforce a total size, so reject an oversized assembled upload
// here before persisting (and before paying to assemble it). Part sizes

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth calling out: this guard trusts part.size from the request body. A malicious client can under-report sizes and still complete an oversized MPU (S3 only cares about PartNumber/ETag). If the intent is a hard storage cap, you probably need to sum authoritative sizes from S3 (ListParts) or enforce Content-Length on the presigned part PUTs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant