[web] enforce size limit on individual files when cloning project (#35211)

* [web] enforce size limit on individual files when cloning project

Co-authored-by: Brian Gough <brian.gough@overleaf.com>

* [web] improve test coverage

* [agents] speed up running frontend tests

* [web] gracefully handle project too large error

---------

Co-authored-by: Brian Gough <brian.gough@overleaf.com>
GitOrigin-RevId: c7b35a3a9688e15a9bb51c61816da82d39e0d29d
This commit is contained in:
Jakob Ackermann
2026-09-17 11:20:03 +00:00
committed by Copybot
co-authored by Brian Gough
parent f62bd990d4
commit 84c6a68eae
13 changed files with 365 additions and 22 deletions
@@ -14,7 +14,11 @@ import ProjectGetter from '../Project/ProjectGetter.mjs'
import HistoryBackupDeletionHandler from './HistoryBackupDeletionHandler.mjs'
import { db, waitForDb } from '../../infrastructure/mongodb.mjs'
import Metrics from '@overleaf/metrics'
import { NotFoundError, HistoryResyncPendingError } from '../Errors/Errors.js'
import {
NotFoundError,
HistoryResyncPendingError,
FileTooLargeError,
} from '../Errors/Errors.js'
const HISTORY_V1_URL = settings.apis.v1_history.url
const HISTORY_V1_BASIC_AUTH = {
@@ -173,15 +177,27 @@ async function uploadBlobFromDisk(historyId, hash, byteLength, fsPath) {
})
}
async function copyBlob(sourceHistoryId, targetHistoryId, hash) {
const url = `${HISTORY_V1_URL}/projects/${targetHistoryId}/blobs/${hash}`
await fetchNothing(
`${url}?${new URLSearchParams({ copyFrom: sourceHistoryId })}`,
{
async function copyBlob(sourceHistoryId, targetHistoryId, hash, sizeLimit) {
const url = new URL(
`${HISTORY_V1_URL}/projects/${targetHistoryId}/blobs/${hash}`
)
url.searchParams.set('copyFrom', sourceHistoryId)
if (sizeLimit) url.searchParams.set('sizeLimit', sizeLimit)
try {
await fetchNothing(url, {
method: 'POST',
basicAuth: HISTORY_V1_BASIC_AUTH,
})
} catch (err) {
if (err instanceof RequestFailedError && err.response.status === 413) {
let size
try {
;({ size } = JSON.parse(err.body))
} catch {}
throw new FileTooLargeError('file too large', { size })
}
)
throw err
}
}
async function requestBlobWithProjectId(
@@ -57,6 +57,7 @@ import UserSettingsHelper from './UserSettingsHelper.mjs'
import AiFeatureUsageRateLimiter from '../../infrastructure/rate-limiters/AiFeatureUsageRateLimiter.mjs'
import WorkbenchRateLimiter from '../../infrastructure/rate-limiters/WorkbenchRateLimiter.mjs'
import PermissionsManager from '../Authorization/PermissionsManager.mjs'
import { FileTooLargeError } from '../Errors/Errors.js'
const { checkUserPermissions } = PermissionsManager.promises
const { isPaidSubscription } = SubscriptionHelper
@@ -309,6 +310,17 @@ const _ProjectController = {
projectId,
userId: currentUser._id,
})
const { path, size } = OError.getFullInfo(err)
if (err instanceof FileTooLargeError && path) {
res.status(413).json({
message: {
text: 'file too large to copy',
key: 'file_too_large_to_copy',
info: { path, size },
},
})
return
}
return next(err)
}
},
@@ -1,6 +1,7 @@
import { callbackify } from 'node:util'
import Path from 'node:path'
import logger from '@overleaf/logger'
import Settings from '@overleaf/settings'
import OError from '@overleaf/o-error'
import { promiseMapWithLimit } from '@overleaf/promise-utils'
import { Doc } from '../../models/Doc.mjs'
@@ -332,11 +333,13 @@ async function _copyFiles(
await HistoryManager.promises.copyBlob(
sourceHistoryId,
targetHistoryId,
file.hash
file.hash,
Settings.maxUploadSize
)
return { createdBlob: true, file, path }
} catch (err) {
throw OError.tag(err, 'unexpected error copying blob', {
path: path.replace(/^\//, ''),
sourceProjectId: sourceProject._id,
targetProjectId: targetProject._id,
sourceFile,