Files
surtic86andClaude Opus 5.5 84c8b81f0a Fix the security audit's findings
Fixes the nine findings of security audit run 1 on 2.4.1 (T1-T11).

F1: Uploads that are not complete now count only the bytes the server
received, and a chunk that goes above the storage quota gets 422. An
anonymous uploader can no longer fill the quota with declared sizes.

F2: The share password and the admin login have a second limit without
the address (20 attempts per minute per share and per e-mail), because
a forged X-Forwarded-For changes the address.

F3: The system password gate and the admin check now also run on
Livewire updates, so an old uploader snapshot cannot register files.

F4: The session keeps a fingerprint of the system password's hash.
When the admin changes the system password, earlier verifications end.

F5: The page after the upload opens only in the session that created
the share, and only while recipients can still reach the share.

F6: The share password limit counts an attempt before the password is
checked, so parallel attempts cannot pass it.

F7: The system password has the same two limits (5 per address, 20 in
total per minute).

F8: A password change or reset signs the user's other sessions out.

F9: The only admin cannot delete the account, which would open /setup
to every visitor.

The setup wizard also holds the admin password to the password
defaults, and Livewire applies EnsureAdmin to updates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 14:46:38 +02:00

474 lines
17 KiB
PHP

<?php
use App\Livewire\FileUploader;
use App\Livewire\SystemPasswordPrompt;
use App\Models\Setting;
use App\Models\Share;
use App\Models\ShareFile;
use App\Services\ShareService;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Storage;
use Livewire\Features\SupportTesting\Testable;
use Livewire\Livewire;
/**
* Upload files through the page as the browser does: register them in one batch, then store each
* one's encrypted content (the chunk endpoint itself is covered by UploadChunkTest).
*
* @param array<string, string> $files name => content
* @return array<int, array<string, mixed>|null> what the page handed the browser
*/
function uploadThroughPage(Testable $component, array $files): array
{
$targets = [];
$component->call('registerFiles', collect($files)->map(fn (string $content, string $name): array => ['name' => $name, 'size' => strlen($content), 'path' => null])->values()->all())
->assertReturned(function (array $returned) use (&$targets): bool {
$targets = $returned;
return true;
});
foreach (array_values($files) as $position => $content) {
if ($targets[$position] !== null) {
$file = ShareFile::query()->findOrFail($targets[$position]['id']);
app(ShareService::class)->storeChunk($file, 0, encryptedChunk($file, $content, 0, true));
}
}
return $targets;
}
test('upload page can be rendered', function () {
$response = $this->get(route('upload'));
$response->assertOk();
});
test('upload page requires system password when configured', function () {
Setting::set('system_password', bcrypt('system-secret'));
$response = $this->get(route('upload'));
$response->assertRedirect(route('system-password'));
});
test('upload page accessible after system password verified', function () {
$systemPassword = bcrypt('system-secret');
Setting::set('system_password', $systemPassword);
$response = $this->withSession(['system_password_verified' => hash('sha256', $systemPassword)])
->get(route('upload'));
$response->assertOk();
});
test('a verification of an earlier system password no longer opens the upload page', function () {
$oldSystemPassword = bcrypt('system-secret');
Setting::set('system_password', $oldSystemPassword);
$this->withSession(['system_password_verified' => hash('sha256', $oldSystemPassword)]);
Setting::set('system_password', bcrypt('new-system-secret'));
$this->get(route('upload'))->assertRedirect(route('system-password'));
});
test('a verification stored before 2.4.2 no longer opens the upload page', function () {
Setting::set('system_password', bcrypt('system-secret'));
$this->withSession(['system_password_verified' => true])
->get(route('upload'))
->assertRedirect(route('system-password'));
});
test('file upload creates share', function () {
Storage::fake('shares');
$component = Livewire::test(FileUploader::class);
uploadThroughPage($component, ['document.pdf' => 'the document']);
$component->call('createShare')
->assertRedirectContains('/share/');
$share = Share::query()->sole();
expect($share->isCompleted())->toBeTrue();
expect($share->files->pluck('original_name')->all())->toBe(['document.pdf']);
expect(session('pending_shares'))->not->toContain($share->token);
expect(session('created_shares'))->toBe([$share->token]);
});
test('registering files hands the browser each file\'s chunk URL, the share key and the file\'s nonce prefix', function () {
Storage::fake('shares');
config(['uploads.chunk_size' => 4]);
$component = Livewire::test(FileUploader::class);
$targets = uploadThroughPage($component, ['a.txt' => 'abc']);
$file = ShareFile::query()->sole();
expect($targets[0])->toMatchArray([
'id' => $file->id,
'url' => url('upload/files/'.$file->id.'/chunks'),
'key' => $file->share->encryption_key,
'chunkSize' => 4,
'chunkCount' => 1,
]);
expect($targets[0]['noncePrefix'])->toBe(bin2hex(substr(file_get_contents(app(ShareService::class)->storedFilePath($file)), 12, 7)));
expect(session('pending_shares'))->toBe([$file->share->token]);
$component->assertSet('pendingToken', $file->share->token)
->assertSeeHtml('data-test="selected-file"');
});
test('each upload page gets its own pending share', function () {
Storage::fake('shares');
$first = Livewire::test(FileUploader::class);
$second = Livewire::test(FileUploader::class);
uploadThroughPage($first, ['one.txt' => 'one']);
uploadThroughPage($second, ['two.txt' => 'two']);
expect($first->get('pendingToken'))->not->toBe($second->get('pendingToken'));
expect(Share::query()->pluck('total_size')->all())->toBe([3, 3]);
});
test('a file an admin limit refuses gets no target and shows why, while the rest of the batch is registered', function () {
Storage::fake('shares');
Setting::set('max_file_size', 1024 * 1024);
$component = Livewire::test(FileUploader::class);
$component->call('registerFiles', [
['name' => 'small.txt', 'size' => 3, 'path' => null],
['name' => 'large.txt', 'size' => 2 * 1024 * 1024, 'path' => null],
]);
$component->assertReturned(fn (array $targets): bool => $targets[0] !== null && $targets[1] === null);
expect($component->errors()->first('files'))->toBe('"large.txt" is too large (2 MB). Maximum file size is 1 MB.');
expect(ShareFile::query()->pluck('original_name')->all())->toBe(['small.txt']);
});
test('removing files takes them out of the pending share', function () {
Storage::fake('shares');
$component = Livewire::test(FileUploader::class);
$targets = uploadThroughPage($component, ['keep.txt' => 'keep', 'remove.txt' => 'remove']);
$component->call('removeFiles', [$targets[1]['id']]);
expect(ShareFile::query()->pluck('original_name')->all())->toBe(['keep.txt']);
expect(Share::query()->sole()->total_size)->toBe(4);
});
test('registering the private text hands the browser its chunk target, like a file', function () {
Storage::fake('shares');
$component = Livewire::test(FileUploader::class);
$component->call('registerText', 20)
->assertReturned(fn (?array $target): bool => $target !== null);
$file = ShareFile::query()->sole();
expect($file->is_text)->toBeTrue();
expect($file->file_size)->toBe(20);
expect(session('pending_shares'))->toBe([$file->share->token]);
});
test('registering the private text again replaces the earlier text row', function () {
Storage::fake('shares');
$component = Livewire::test(FileUploader::class)
->call('registerText', 20);
$firstId = ShareFile::query()->sole()->id;
$component->call('registerText', 30);
$file = ShareFile::query()->sole();
expect($file->id)->not->toBe($firstId);
expect($file->file_size)->toBe(30);
});
test('registering the private text with 0 bytes removes it', function () {
Storage::fake('shares');
$component = Livewire::test(FileUploader::class)
->call('registerText', 20);
$component->call('registerText', 0)
->assertReturned(fn (?array $target): bool => $target === null);
expect(ShareFile::query()->count())->toBe(0);
});
test('a text-only pending share completes', function () {
Storage::fake('shares');
$component = Livewire::test(FileUploader::class)
->call('registerText', 20);
$file = ShareFile::query()->sole();
app(ShareService::class)->storeChunk($file, 0, encryptedChunk($file, str_repeat('a', 20), 0, true));
$component->call('createShare')
->assertRedirectContains('/share/');
$share = Share::query()->sole();
expect($share->isCompleted())->toBeTrue();
expect($share->files->first()->is_text)->toBeTrue();
});
test('the selected files list does not show the private text', function () {
Storage::fake('shares');
$component = Livewire::test(FileUploader::class);
uploadThroughPage($component, ['document.pdf' => 'the document']);
$component->call('registerText', 20);
$component->assertSeeHtml('data-test="selected-file"')
->assertSee('document.pdf')
->assertDontSee('text.txt');
});
test('a share cannot be created while a file is still uploading', function () {
Storage::fake('shares');
$component = Livewire::test(FileUploader::class)
->call('registerFiles', [['name' => 'unfinished.txt', 'size' => 10, 'path' => null]]);
$component->call('createShare');
expect($component->errors()->first('files'))->toBe('Wait until every file has finished uploading, or remove the ones that failed.');
expect(Share::query()->sole()->isCompleted())->toBeFalse();
});
test('the page offers a warning for browsers without a secure context', function () {
Livewire::test(FileUploader::class)
->assertSeeHtml('data-test="insecure-context"')
->assertSee('Uploads need a secure connection (HTTPS).');
});
test('file upload with password creates password-protected share', function () {
Storage::fake('shares');
$component = Livewire::test(FileUploader::class);
uploadThroughPage($component, ['secret.txt' => 'secret']);
$component
->set('usePassword', true)
->set('password', 'my-password')
->call('createShare')
->assertRedirectContains('/share/');
$share = Share::query()->first();
expect($share->isPasswordProtected())->toBeTrue();
});
test('switching password protection on leaves the field empty and offers a Generate button by default', function () {
$component = Livewire::test(FileUploader::class)
->set('usePassword', true);
$component->assertSet('password', '')
->assertSeeHtml('data-test="generate-password"')
->assertSeeHtml('data-test="copy-password"');
});
test('a generated password protects the share and is flashed, encrypted, for the page the upload leads to', function () {
Storage::fake('shares');
$component = Livewire::test(FileUploader::class);
uploadThroughPage($component, ['secret.txt' => 'secret']);
$component->set('usePassword', true)
->call('generatePassword');
$password = $component->get('password');
$component->call('createShare');
$share = Share::query()->first();
expect($password)->toMatch('/^[A-Za-z0-9]{20}$/');
expect(app(ShareService::class)->verifyPassword($share, $password))->toBeTrue();
expect(session('share_password.token'))->toBe($share->token);
expect(Crypt::decryptString(session('share_password.password')))->toBe($password);
});
test('a share without a password flashes no password', function () {
Storage::fake('shares');
$component = Livewire::test(FileUploader::class);
uploadThroughPage($component, ['document.pdf' => 'the document']);
$component->call('createShare')
->assertRedirectContains('/share/');
expect(session()->has('share_password'))->toBeFalse();
});
test('a prefilling generator fills in a password as protection is switched on', function () {
Setting::set('password_generator_mode', 'prefill');
$component = Livewire::test(FileUploader::class)
->set('usePassword', true);
expect($component->get('password'))->toMatch('/^[A-Za-z0-9]{20}$/');
});
test('a prefilling generator keeps a password the uploader already typed', function () {
Setting::set('password_generator_mode', 'prefill');
Livewire::test(FileUploader::class)
->set('password', 'my-own-password')
->set('usePassword', true)
->assertSet('password', 'my-own-password');
});
test('a generator switched off offers no Generate button and generates nothing', function () {
Setting::set('password_generator_mode', 'off');
Livewire::test(FileUploader::class)
->set('usePassword', true)
->assertDontSeeHtml('data-test="generate-password"')
->assertSeeHtml('data-test="copy-password"')
->call('generatePassword')
->assertSet('password', '');
});
test('file upload with expiration sets expires_at', function () {
Storage::fake('shares');
$component = Livewire::test(FileUploader::class);
uploadThroughPage($component, ['file.txt' => 'content']);
$component
->set('expiration', '24h')
->call('createShare')
->assertRedirectContains('/share/');
$share = Share::query()->first();
expect($share->expires_at)->not->toBeNull();
});
test('a 30 day expiration lasts 30 days, not a calendar month', function () {
Storage::fake('shares');
$this->travelTo(new DateTimeImmutable('2026-02-01 12:00:00'));
$component = Livewire::test(FileUploader::class);
uploadThroughPage($component, ['file.txt' => 'content']);
$component
->set('expiration', '30d')
->call('createShare')
->assertRedirectContains('/share/');
expect(Share::query()->first()->expires_at->toDateTimeString())->toBe('2026-03-03 12:00:00');
});
test('file upload with max downloads sets limit', function () {
Storage::fake('shares');
$component = Livewire::test(FileUploader::class);
uploadThroughPage($component, ['file.txt' => 'content']);
$component
->set('maxDownloads', 5)
->call('createShare')
->assertRedirectContains('/share/');
$share = Share::query()->first();
expect($share->max_downloads)->toBe(5);
});
test('a file of 6 GB is accepted when the admin limits allow it', function () {
Storage::fake('shares');
Setting::set('max_file_size', 15000 * 1024 * 1024);
Setting::set('max_size_per_share', 20 * 1024 * 1024 * 1024);
Setting::set('max_storage_quota', 50 * 1024 * 1024 * 1024);
$component = Livewire::test(FileUploader::class)
->call('registerFiles', [['name' => 'backup.dump', 'size' => 6 * 1024 * 1024 * 1024, 'path' => null]]);
$component->assertHasNoErrors('files');
expect(Share::query()->sole()->total_size)->toBe(6 * 1024 * 1024 * 1024);
expect(ShareFile::query()->sole()->file_size)->toBe(6 * 1024 * 1024 * 1024);
});
test('file upload requires at least one file', function () {
$component = Livewire::test(FileUploader::class)
->call('createShare');
expect($component->errors()->first('files'))->toBe('Add files or a text to share.');
expect(Share::query()->count())->toBe(0);
});
test('file upload blocks when storage is full', function () {
Storage::fake('shares');
Setting::set('max_storage_quota', 100);
Share::factory()->create(['total_size' => 100]);
$component = Livewire::test(FileUploader::class)
->call('registerFiles', [['name' => 'file.txt', 'size' => 1, 'path' => null]]);
expect($component->errors()->first('files'))->toBe('Storage is full. Please contact the administrator.');
expect(ShareFile::query()->count())->toBe(0);
});
test('files declared by one session without any chunk do not block another session', function () {
Storage::fake('shares');
Setting::set('max_file_size', 200 * 1024 * 1024);
Setting::set('max_size_per_share', 1024 * 1024 * 1024);
Setting::set('max_files_per_share', 10);
Setting::set('max_storage_quota', 300 * 1024 * 1024);
Livewire::test(FileUploader::class)->call('registerFiles', [
['name' => 'a.bin', 'size' => 100 * 1024 * 1024, 'path' => null],
['name' => 'b.bin', 'size' => 100 * 1024 * 1024, 'path' => null],
['name' => 'c.bin', 'size' => 100 * 1024 * 1024, 'path' => null],
])->assertHasNoErrors('files');
session()->flush();
$component = Livewire::test(FileUploader::class)
->call('registerFiles', [['name' => 'd.txt', 'size' => 1024, 'path' => null]]);
$component->assertHasNoErrors('files');
expect(ShareFile::query()->count())->toBe(4);
});
test('system password prompt verifies correct password', function () {
Setting::set('system_password', bcrypt('system-secret'));
Livewire::test(SystemPasswordPrompt::class)
->set('password', 'system-secret')
->call('verify')
->assertRedirect(route('upload'));
});
test('system password prompt rejects incorrect password', function () {
Setting::set('system_password', bcrypt('system-secret'));
Livewire::test(SystemPasswordPrompt::class)
->set('password', 'wrong')
->call('verify')
->assertHasErrors(['password']);
});
test('the system password prompt refuses a sixth attempt from the same address, even with the correct password', function () {
Setting::set('system_password', bcrypt('system-secret'));
$component = Livewire::test(SystemPasswordPrompt::class);
for ($i = 0; $i < 5; $i++) {
$component->set('password', 'wrong')
->call('verify')
->assertHasErrors(['password']);
}
$component->set('password', 'system-secret')
->call('verify')
->assertHasErrors(['password'])
->assertSee('Too many attempts')
->assertNoRedirect();
});
test('the global system password limit refuses the correct password from any address', function () {
Setting::set('system_password', bcrypt('system-secret'));
for ($i = 0; $i < 20; $i++) {
RateLimiter::hit('system-password', 60);
}
Livewire::test(SystemPasswordPrompt::class)
->set('password', 'system-secret')
->call('verify')
->assertHasErrors(['password'])
->assertSee('Too many attempts')
->assertNoRedirect();
expect(session()->has('system_password_verified'))->toBeFalse();
});