mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-17 17:15:08 +00:00
6ad26bb61e
Reported by @ry2811 as GHSA-6jh6-gvj5-pv8v. A resumable upload declares its size, and that declaration is what store() weighs against the maximum file size and the client's storage quota. Only the assembled file was ever held to it. The parts in between were bounded one request at a time and never added up, so a client could declare one byte and then stream parts: ten thousand part numbers at twice a 20 MB part is about 400 GB, per session, and the number of sessions was not bounded either. None of it counted against anything, because nothing becomes a File row until the upload completes and ClientStorageUsage sums File rows. A client with a 1 MB quota could fill the volume and repeat. putPart()'s own comment described this defect and treated the per-part cap as the answer to it: "without a cap here the exposure is a day's worth of disk". A cap on one request bounds one request. The exposure was a day's worth of disk multiplied by however many requests somebody cared to make. Three limits, and each one exists because the other two do not cover it. A session may not stage more than it declared. The room for a part is claimed before the body is read — a body's length is not known until it has arrived, and by then it is on the disk being protected — and the write is then capped at exactly what was claimed, so an over-long body is cut off mid-stream as it always was, against a smaller number. The claim is a read and a conditional update under a per-session lock, the same shape complete() already uses: the protocol sends parts in parallel and how many is the client's choice, so an unlocked read lets every part in flight claim the same room, while an atomic claim alone refuses the honest parallel upload instead. Whatever the part really weighs is settled back afterwards, in a finally, or a client's own retries would exhaust a session with room to spare. Open sessions count against the quota at the size they declared. A quota measured against finished files alone is spent twice by opening sessions one after another — each is told there is room, because the ones before it have not finished. The cost is that an abandoned transfer holds its share until it is cancelled or swept, so the sweeper now runs hourly rather than daily: that gap is now somebody unable to upload, which it was not before. And a cap on open sessions, because for anyone with no quota to spend — staff, and clients on an installation that sets none — the session count is the only thing between a declared size and any multiple of it. Four tests fail on the unfixed code, and three existing ones had to change: they declared a tiny size and sent a large part deliberately, to reach the re-checks at complete(). That route is now closed at putPart(), so they reach those re-checks the way a real install would instead — the file-size limit or the quota moving while a long transfer is running, which is the reason complete() re-asks rather than trusting what store() decided. The staged-byte total is BIGINT UNSIGNED, and the suite runs SQLite, which has no unsigned integers. The first version of the bounds read `staged_bytes + :delta BETWEEN 0 AND size` and raised SQLSTATE 22003 on MySQL for any refund — in the comparison, so the bound written to prevent the underflow was the statement that underflowed. Every SQLite test passed on it. Both bounds are now arranged so the column is never inside a subtraction, and UploadSessionStagedBytesMysqlTest skips loudly unless the connection is MySQL. Verified against 8.4, as was the report itself: three sessions declaring one byte each put 6 MB on the volume of a client with a 1 MB quota before, and nothing at all after. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CNFU55Tkq6MuEQ73nbbBRx
370 lines
14 KiB
PHP
370 lines
14 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Modules\Files\Uploads;
|
|
|
|
use App\Modules\Files\Storage\ResolvingUploadDisk;
|
|
use Illuminate\Support\Facades\Event;
|
|
use Illuminate\Support\Facades\File as FileSystem;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Facades\URL;
|
|
use RuntimeException;
|
|
use Throwable;
|
|
|
|
/**
|
|
* Part storage for installs without object storage (any VPS / mounted
|
|
* volume): parts arrive as signed PUTs to the app, land in a temp
|
|
* session directory, and are stream-assembled onto whichever disk the
|
|
* ResolvingUploadDisk event resolves (local 'files' by default; the
|
|
* external storage settings, when active, redirect this to
|
|
* 'files_external') with the sha256 computed during the single
|
|
* concatenation pass.
|
|
*
|
|
* The cloud edition replaces this with an S3 implementation handing
|
|
* out real presigned part URLs behind the same contract.
|
|
*/
|
|
class LocalPartStore
|
|
{
|
|
/**
|
|
* Said twice, because a full temp volume can announce itself in the
|
|
* middle of the copy or only when the last buffer is flushed.
|
|
*/
|
|
private const WRITE_FAILED = 'Could not assemble the upload: writing to the temporary directory failed.';
|
|
|
|
/**
|
|
* The route name is a parameter because the same flow is mounted twice:
|
|
* once on the session-authenticated web routes for the browser, once on
|
|
* the token-authenticated API routes. The signature is over the URL, so
|
|
* it has to be minted against the route the caller will actually PUT to.
|
|
*/
|
|
public function signPartUrl(UploadSession $session, int $partNumber, string $routeName = 'uploads.parts.put'): string
|
|
{
|
|
return URL::temporarySignedRoute(
|
|
$routeName,
|
|
now()->addMinutes(30),
|
|
['session' => $session->id, 'part' => $partNumber],
|
|
);
|
|
}
|
|
|
|
/**
|
|
* $maxBytes is enforced while copying, not just from Content-Length:
|
|
* that header can be absent or untrue under chunked transfer encoding,
|
|
* so the byte count during the copy is the only figure worth trusting.
|
|
* An over-long part is discarded rather than truncated — a truncated
|
|
* part would assemble into a silently corrupt file.
|
|
*
|
|
* @param resource $stream
|
|
*/
|
|
public function storePart(UploadSession $session, int $partNumber, $stream, ?int $maxBytes = null): string
|
|
{
|
|
$directory = $this->directory($session);
|
|
|
|
try {
|
|
FileSystem::ensureDirectoryExists($directory);
|
|
} catch (Throwable $e) {
|
|
// mkdir() failures surface as promoted warnings. The raw message
|
|
// — "mkdir(): Permission denied" with a framework stack trace —
|
|
// was the single most opaque failure QA hit: name the directory
|
|
// and the likely fix instead, so the log line is actionable, and
|
|
// keep the original as the previous exception.
|
|
throw new RuntimeException(sprintf(
|
|
'%s could not be created — uploads cannot accept bytes.'
|
|
.' Almost always ownership: make storage/ writable by the user'
|
|
.' the app runs as (in the Docker image: chown -R www-data:www-data storage).',
|
|
$directory,
|
|
), previous: $e);
|
|
}
|
|
|
|
$path = $this->partPath($session, $partNumber);
|
|
|
|
$out = fopen($path, 'wb');
|
|
|
|
if ($out === false) {
|
|
throw new RuntimeException(sprintf('Could not open part file %s for writing.', $path));
|
|
}
|
|
|
|
if ($maxBytes === null) {
|
|
stream_copy_to_stream($stream, $out);
|
|
fclose($out);
|
|
|
|
return md5_file($path) ?: '';
|
|
}
|
|
|
|
$written = 0;
|
|
|
|
while (! feof($stream)) {
|
|
$buffer = fread($stream, 1024 * 1024);
|
|
|
|
if ($buffer === false || $buffer === '') {
|
|
break;
|
|
}
|
|
|
|
$written += strlen($buffer);
|
|
|
|
if ($written > $maxBytes) {
|
|
fclose($out);
|
|
@unlink($path);
|
|
|
|
throw new PartTooLargeException('Upload part exceeds the maximum part size.');
|
|
}
|
|
|
|
fwrite($out, $buffer);
|
|
}
|
|
|
|
fclose($out);
|
|
|
|
return md5_file($path) ?: '';
|
|
}
|
|
|
|
/**
|
|
* What one part number currently weighs on disk, 0 if it has never
|
|
* arrived. Read before and after a part is received, so the session's
|
|
* reservation can be settled against what is really there rather than
|
|
* against what the request claimed it would send.
|
|
*/
|
|
public function partSize(UploadSession $session, int $partNumber): int
|
|
{
|
|
$path = $this->partPath($session, $partNumber);
|
|
|
|
if (! is_file($path)) {
|
|
return 0;
|
|
}
|
|
|
|
clearstatcache(true, $path);
|
|
|
|
return (int) (filesize($path) ?: 0);
|
|
}
|
|
|
|
/**
|
|
* @return list<array{PartNumber: int, Size: int, ETag: string}>
|
|
*/
|
|
public function listParts(UploadSession $session): array
|
|
{
|
|
$directory = $this->directory($session);
|
|
|
|
if (! is_dir($directory)) {
|
|
return [];
|
|
}
|
|
|
|
$parts = [];
|
|
|
|
foreach (glob($directory.'/*.part') ?: [] as $path) {
|
|
$number = (int) basename($path, '.part');
|
|
$parts[$number] = [
|
|
'PartNumber' => $number,
|
|
'Size' => (int) filesize($path),
|
|
'ETag' => md5_file($path) ?: '',
|
|
];
|
|
}
|
|
|
|
ksort($parts);
|
|
|
|
return array_values($parts);
|
|
}
|
|
|
|
/**
|
|
* Stream-append parts in order onto the files disk, hashing as we go.
|
|
*
|
|
* The parts stay on disk until the assembled bytes are safely on the
|
|
* target disk. ChunkedUploadsController's completion lock promises that
|
|
* "a later retry still works", and everything that can fail after the
|
|
* concatenation — reopening the copy, a disk refusing the write, the
|
|
* File row itself — happens while the client has nothing but this
|
|
* session to retry with. Unlinking each part as it was consumed left
|
|
* listParts() empty, so every later complete() answered "Upload is
|
|
* incomplete: missing parts" for good.
|
|
*
|
|
* The cost is temp space: peak usage is the whole file twice over
|
|
* (every part, plus the assembled copy) rather than the file plus one
|
|
* part. Both are freed by the abort() below the moment the write lands,
|
|
* and by the failure path the moment it does not.
|
|
*
|
|
* @return array{path: string, disk: string, size: int, checksum: string}
|
|
*/
|
|
public function assemble(UploadSession $session, string $targetPath): array
|
|
{
|
|
$parts = $this->listParts($session);
|
|
|
|
$expected = range(1, count($parts));
|
|
$actual = array_column($parts, 'PartNumber');
|
|
|
|
if ($parts === [] || $actual !== $expected) {
|
|
throw new RuntimeException('Upload is incomplete: missing parts.');
|
|
}
|
|
|
|
$assembledPath = $this->directory($session).'/assembled';
|
|
|
|
try {
|
|
[$size, $checksum] = $this->concatenate($session, $parts, $assembledPath);
|
|
|
|
$readStream = fopen($assembledPath, 'rb');
|
|
|
|
if ($readStream === false) {
|
|
throw new RuntimeException('Could not reopen assembled file.');
|
|
}
|
|
|
|
$diskEvent = new ResolvingUploadDisk($session->user);
|
|
Event::dispatch($diskEvent);
|
|
$disk = $diskEvent->disk;
|
|
|
|
$written = Storage::disk($disk)->writeStream($targetPath, $readStream);
|
|
|
|
if (is_resource($readStream)) {
|
|
fclose($readStream);
|
|
}
|
|
|
|
// The disks are configured with 'throw' => false, so a refused
|
|
// write is a `false` return rather than an exception — and the
|
|
// caller goes on to record a File row for bytes that were never
|
|
// stored. Losing an upload silently is worse than failing it, and
|
|
// this is the only place that can tell the difference: a real
|
|
// instance of it was a GCS bucket rejecting the adapter's ACL,
|
|
// which looked exactly like a successful upload.
|
|
if ($written === false) {
|
|
// The reason is lost by the time it gets here — 'throw' => false
|
|
// means Flysystem swallowed the exception rather than passing it
|
|
// on — so log what was attempted. Which bucket it was is the
|
|
// difference between reading this as "my credentials expired"
|
|
// and "I typed the wrong bucket name", and only the log can say
|
|
// it: the message below is shown to whoever was uploading, which
|
|
// includes clients, and a bucket name is not theirs to see.
|
|
Log::error('Upload could not be written to storage.', [
|
|
'disk' => $disk,
|
|
'bucket' => config('filesystems.disks.'.$disk.'.bucket'),
|
|
'driver' => config('filesystems.disks.'.$disk.'.driver'),
|
|
'path' => $targetPath,
|
|
]);
|
|
|
|
throw new RuntimeException(
|
|
'Could not write the assembled upload to the "'.$disk.'" disk. '
|
|
.'Check the storage backend is reachable and its credentials are still valid.'
|
|
);
|
|
}
|
|
} catch (Throwable $failure) {
|
|
// The half-written copy belongs to this attempt and the next one
|
|
// makes its own; the parts belong to the client, and they are
|
|
// what a retry needs. Deleting the copy here is also the only
|
|
// thing that removes it at all on this path — it used to sit in
|
|
// the session directory until the sweeper came round.
|
|
FileSystem::delete($assembledPath);
|
|
|
|
throw $failure;
|
|
}
|
|
|
|
$this->abort($session);
|
|
|
|
return [
|
|
'path' => $targetPath,
|
|
'disk' => $disk,
|
|
'size' => $size,
|
|
'checksum' => $checksum,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Concatenate the parts into $assembledPath, returning the byte count
|
|
* and the sha256 of what was written.
|
|
*
|
|
* Every read and every write is checked. They were not, and while a
|
|
* failing fwrite on a full volume is loud in practice — Laravel's
|
|
* error handler turns the warning into an ErrorException — loud there
|
|
* means a 500 carrying a PHP message, where the disk-refused-the-write
|
|
* case a few lines above becomes a sentence the person uploading can
|
|
* act on. A short write arriving without a warning would be worse
|
|
* still: $size and the hash describe the buffer that was read, so an
|
|
* unchecked one yields a truncated file with a checksum matching bytes
|
|
* that were never stored.
|
|
*
|
|
* @param list<array{PartNumber: int, Size: int, ETag: string}> $parts
|
|
* @return array{0: int, 1: string}
|
|
*/
|
|
private function concatenate(UploadSession $session, array $parts, string $assembledPath): array
|
|
{
|
|
$out = fopen($assembledPath, 'wb');
|
|
|
|
if ($out === false) {
|
|
throw new RuntimeException('Could not open assembly target.');
|
|
}
|
|
|
|
$hash = hash_init('sha256');
|
|
$size = 0;
|
|
|
|
try {
|
|
foreach ($parts as $part) {
|
|
$in = fopen($this->partPath($session, $part['PartNumber']), 'rb');
|
|
|
|
if ($in === false) {
|
|
throw new RuntimeException('Could not read part '.$part['PartNumber'].'.');
|
|
}
|
|
|
|
try {
|
|
while (! feof($in)) {
|
|
$buffer = fread($in, 1024 * 1024);
|
|
|
|
if ($buffer === false) {
|
|
throw new RuntimeException('Could not read part '.$part['PartNumber'].'.');
|
|
}
|
|
|
|
if ($buffer !== '' && @fwrite($out, $buffer) !== strlen($buffer)) {
|
|
throw new RuntimeException(self::WRITE_FAILED);
|
|
}
|
|
|
|
hash_update($hash, $buffer);
|
|
$size += strlen($buffer);
|
|
}
|
|
} finally {
|
|
fclose($in);
|
|
}
|
|
}
|
|
} catch (Throwable $failure) {
|
|
fclose($out);
|
|
|
|
throw $failure;
|
|
}
|
|
|
|
// fclose flushes, so a volume that filled up on the last buffer
|
|
// fails here rather than in the loop.
|
|
if (! fclose($out)) {
|
|
throw new RuntimeException(self::WRITE_FAILED);
|
|
}
|
|
|
|
return [$size, hash_final($hash)];
|
|
}
|
|
|
|
public function abort(UploadSession $session): void
|
|
{
|
|
FileSystem::deleteDirectory($this->directory($session));
|
|
}
|
|
|
|
private function directory(UploadSession $session): string
|
|
{
|
|
return $this->root().'/'.$session->id;
|
|
}
|
|
|
|
/**
|
|
* Where part files live while a transfer is in progress.
|
|
*
|
|
* Configurable only so the test suite can hold it apart per parallel
|
|
* worker. This is a real directory rather than a faked disk, and each
|
|
* worker's database restarts session ids at 1, so two workers writing
|
|
* parts land in the same place — and ChunkedUploadsTest's afterEach
|
|
* deletes the whole tree, for everybody. Unset, which is every
|
|
* installation, the path is what it has always been.
|
|
*/
|
|
private function root(): string
|
|
{
|
|
$configured = config('projectsend.uploads.parts_path');
|
|
|
|
return is_string($configured) && $configured !== ''
|
|
? rtrim($configured, '/')
|
|
: storage_path('app/uploads-tmp');
|
|
}
|
|
|
|
private function partPath(UploadSession $session, int $partNumber): string
|
|
{
|
|
return $this->directory($session).'/'.$partNumber.'.part';
|
|
}
|
|
}
|