diff --git a/apps/web/app/api/upload/[...route]/multipart.ts b/apps/web/app/api/upload/[...route]/multipart.ts index 5947b0775b5..0541bfcbd3a 100644 --- a/apps/web/app/api/upload/[...route]/multipart.ts +++ b/apps/web/app/api/upload/[...route]/multipart.ts @@ -4,6 +4,7 @@ import { serverEnv } from "@cap/env"; import { userIsPro } from "@cap/utils"; import { Database, + MAX_UPLOAD_BYTES, makeCurrentUserLayer, provideOptionalAuth, Storage, @@ -290,7 +291,10 @@ app.post( z.object({ partNumber: z.number(), etag: z.string(), - size: z.number(), + // A non-negative integer (bytes) so neither a negative nor a + // fractional size can drag the summed total below the cap and + // bypass the upload-size limit. + size: z.number().int().nonnegative(), }), ), durationInSecs: stringOrNumberOptional, @@ -399,6 +403,63 @@ app.post( } } + // 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 + // are client-reported, so this raises the bar rather than enforcing + // authoritatively. + let totalUploadSize = 0; + for (const part of parts) { + totalUploadSize += part.size; + if (totalUploadSize > MAX_UPLOAD_BYTES) break; + } + if (totalUploadSize > MAX_UPLOAD_BYTES) { + // Avoid leaving the parts as incomplete-MPU storage and a stale + // videoUploads row, mirroring the free-plan rejection cleanup. Each + // step is caught independently so a failed abort doesn't skip the DB + // cleanup (and vice versa). The 413 stands regardless of either. + yield* Effect.gen(function* () { + const [bucket] = yield* Storage.getAccessForVideo(video); + yield* bucket.multipart + .abort(fileKey, uploadId) + .pipe( + Effect.catchAll((error) => + Effect.logError( + "Failed to abort rejected oversized multipart upload", + error, + ), + ), + ); + yield* db + .use((db) => + db + .delete(Db.videoUploads) + .where(eq(Db.videoUploads.videoId, videoId)), + ) + .pipe( + Effect.catchAll((error) => + Effect.logError( + "Failed to delete videoUploads row for rejected upload", + error, + ), + ), + ); + }).pipe( + Effect.catchAll((error) => + Effect.logError( + "Failed to clean up rejected oversized multipart upload", + error, + ), + ), + ); + + c.status(413); + return c.text( + "Upload exceeds the maximum allowed size and cannot be completed.", + ); + } + return yield* Effect.gen(function* () { const [bucket] = yield* Storage.getAccessForVideo(video); diff --git a/packages/web-backend/src/S3Buckets/S3BucketAccess.ts b/packages/web-backend/src/S3Buckets/S3BucketAccess.ts index 106e98985a3..b5b7c11a0dc 100644 --- a/packages/web-backend/src/S3Buckets/S3BucketAccess.ts +++ b/packages/web-backend/src/S3Buckets/S3BucketAccess.ts @@ -14,6 +14,11 @@ import { S3BucketClientProvider } from "./S3BucketClientProvider.ts"; const DEFAULT_PRESIGNED_GET_EXPIRES_SECONDS = 3600; const DEFAULT_PRESIGNED_PUT_EXPIRES_SECONDS = 3600; +// Upper bound on a single upload to prevent unbounded storage abuse. Generous +// on purpose so legitimate long/high-bitrate recordings are never blocked; +// tune here if the product ever needs a larger ceiling. +export const MAX_UPLOAD_BYTES = 100 * 1024 * 1024 * 1024; // 100 GiB + type NodeReadableWebStream = Parameters[0]; const wrapS3Promise = ( @@ -266,13 +271,49 @@ export const createS3BucketAccess = Effect.gen(function* () { ) => wrapS3Promise( provider.getPublic.pipe( - Effect.map((client) => - createPresignedPost(client, { + Effect.map((client) => { + // Enforce an upper bound on the uploaded object size. The POST + // policy rejects the upload at S3 if the body exceeds this, + // closing the unbounded-storage hole for presigned POSTs. We emit + // exactly one content-length-range whose max never exceeds + // MAX_UPLOAD_BYTES, even if a caller supplied a looser one, so a + // caller can tighten but never raise/disable the cap. + const callerConditions = args.Conditions ?? []; + const isLengthRange = (condition: unknown): boolean => + Array.isArray(condition) && + condition[0] === "content-length-range"; + const callerRange = callerConditions.find(isLengthRange) as + | [unknown, unknown, unknown] + | undefined; + 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, + ], Bucket: provider.bucket, Key: key, - }), - ), + }); + }), ), ), multipart: { diff --git a/packages/web-backend/src/index.ts b/packages/web-backend/src/index.ts index 7cd60bc8fb6..2c235b94692 100644 --- a/packages/web-backend/src/index.ts +++ b/packages/web-backend/src/index.ts @@ -10,6 +10,7 @@ export { Organisations } from "./Organisations/index.ts"; export { OrganisationsPolicy } from "./Organisations/OrganisationsPolicy.ts"; export * from "./Rpcs.ts"; export { S3Buckets } from "./S3Buckets/index.ts"; +export { MAX_UPLOAD_BYTES } from "./S3Buckets/S3BucketAccess.ts"; export { Spaces } from "./Spaces/index.ts"; export { SpacesPolicy } from "./Spaces/SpacesPolicy.ts"; export * from "./Storage/GoogleDrive.ts";