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>
190 lines
9.5 KiB
PHP
190 lines
9.5 KiB
PHP
<?php
|
|
|
|
use App\Models\Share;
|
|
use App\Services\QrCodeService;
|
|
use App\Services\ShareService;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Str;
|
|
use Tests\Screenshots\DemoData;
|
|
use Tests\Screenshots\Publisher;
|
|
|
|
/*
|
|
* The screenshots on the website and in the README, from fixed demo data with the clock frozen.
|
|
* Not part of any test suite: `composer screenshots` runs this directory (Chromium), and each capture
|
|
* is published to website/img/screenshots as soon as it is taken.
|
|
*/
|
|
|
|
beforeEach(function () {
|
|
// Sessions have to outlive a request: a sign-in, an unlocked share.
|
|
config(['session.driver' => 'file']);
|
|
Storage::fake('shares');
|
|
|
|
$this->travelTo(Carbon::parse('2026-10-01 09:30'));
|
|
|
|
DemoData::shares();
|
|
});
|
|
|
|
afterEach(function () {
|
|
Str::createRandomStringsNormally();
|
|
});
|
|
|
|
/**
|
|
* A visited page on a device in a theme, once it can be used. Tests call `visit()` themselves, on a
|
|
* line of its own: Pest starts its browser only for tests under tests/Browser or whose body calls
|
|
* `visit(` after whitespace.
|
|
*/
|
|
function shotPage(mixed $visit, string $device, string $theme): mixed
|
|
{
|
|
$pending = $device === 'desktop' ? $visit->on()->macbook14() : $visit->on()->iPhone15Pro();
|
|
$page = $theme === 'dark' ? $pending->inDarkMode() : $pending->inLightMode();
|
|
|
|
return $page->waitForEvent('networkidle')
|
|
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
|
}
|
|
|
|
/**
|
|
* Capture the viewport once fonts are in and every finite animation has run, then publish it.
|
|
*
|
|
* The in-process server listens on 127.0.0.1 at a random port, so links would read differently on
|
|
* every run: the page is shown as it reads on an installation at https://files.example.com, the
|
|
* server's origin replaced in text and fields, and the QR code drawn for that address.
|
|
*/
|
|
function shoot(mixed $page, string $device, string $theme, string $name, string $token = DemoData::DELIVERY_TOKEN): void
|
|
{
|
|
$origin = 'https://files.example.com';
|
|
$qrCode = app(QrCodeService::class)->svg($origin.'/s/'.$token);
|
|
|
|
$page->script("document.head.insertAdjacentHTML('beforeend', '<style>*{caret-color:transparent!important}</style>')");
|
|
$page->script('(() => { const from = location.origin, to = '.json_encode($origin).'; document.querySelectorAll("input").forEach((input) => { input.value = input.value.replaceAll(from, to) }); const text = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); while (text.nextNode()) { text.currentNode.nodeValue = text.currentNode.nodeValue.replaceAll(from, to) } document.querySelectorAll("[data-qr-code]").forEach((panel) => { panel.innerHTML = '.json_encode($qrCode).' }) })()');
|
|
|
|
// Counting figures run on requestAnimationFrame rather than as Web Animations.
|
|
$page->wait(1.2)
|
|
->assertScript("document.fonts.status === 'loaded' && document.getAnimations().every((animation) => animation.playState !== 'running' || animation.effect?.getComputedTiming().iterations === Infinity)");
|
|
|
|
$capture = "{$device}-{$theme}-{$name}";
|
|
|
|
$page->screenshot(fullPage: false, filename: $capture);
|
|
|
|
Publisher::forProject()->publish($capture, $device, $theme, $name, $device === 'desktop' ? [1600, 800] : [1080, 540]);
|
|
}
|
|
|
|
/**
|
|
* Put files into the uploader the way a finished upload does: registered through the page's own
|
|
* `registerFiles`, their encrypted content stored here as the browser would have sent it (zeros of
|
|
* the demo size), and the list refreshed to show them uploaded.
|
|
*
|
|
* @param array<string, int> $files relative path => size in kilobytes
|
|
*/
|
|
function selectFiles(mixed $page, array $files): void
|
|
{
|
|
$selection = collect($files)->map(fn (int $kilobytes, string $path): array => [
|
|
'name' => basename($path),
|
|
'size' => $kilobytes * 1024,
|
|
'path' => str_contains($path, '/') ? $path : null,
|
|
])->values();
|
|
$wire = 'Livewire.find(document.querySelector("[data-test=drop-zone]").closest("[wire\\\\:id]").getAttribute("wire:id"))';
|
|
|
|
$page->script('(async () => { await '.$wire.'.registerFiles('.json_encode($selection).') })()');
|
|
$page->waitForText('Selected Files ('.count($files).')');
|
|
|
|
$shareService = app(ShareService::class);
|
|
|
|
foreach (Share::query()->whereNull('completed_at')->latest('id')->firstOrFail()->files as $file) {
|
|
$header = $shareService->readHeader($file);
|
|
|
|
for ($index = 0; $index < $header['chunkCount']; $index++) {
|
|
$length = min($header['chunkSize'], $file->file_size - $index * $header['chunkSize']);
|
|
$shareService->storeChunk($file->refresh(), $index, encryptedChunk($file, str_repeat("\0", $length), $index, $index === $header['chunkCount'] - 1));
|
|
}
|
|
}
|
|
|
|
$page->script('(async () => { await '.$wire.'.$refresh() })()');
|
|
$page->assertSee('Selected Files ('.count($files).')');
|
|
}
|
|
|
|
$files = ['Q3 Report.pdf' => 2400, 'Contract 2026.pdf' => 380, 'Product photos/hero-shot.jpg' => 4800, 'Product photos/detail.jpg' => 3900];
|
|
|
|
test('desktop', function (string $theme) use ($files) {
|
|
// The share this upload creates takes a fixed token (share tokens are the only 16-character random
|
|
// strings); sessions and everything else stay random.
|
|
Str::createRandomStringsUsing(fn (int $length): string => $length === 16
|
|
? DemoData::CREATED_TOKEN
|
|
: substr(bin2hex(random_bytes($length)), 0, $length));
|
|
|
|
$upload = visit('/upload');
|
|
$page = shotPage($upload, 'desktop', $theme);
|
|
selectFiles($page, $files);
|
|
// Creating the share sends this text through the page's own upload before the share is created.
|
|
$page->type('[data-test="share-text"]', 'The signed contract is in the folder. Call me on +41 44 555 01 23 if anything is missing.');
|
|
$page->click('label:has-text("Password protect")')
|
|
->click('[data-test="generate-password"]')
|
|
->wait(1)
|
|
->assertScript("document.querySelector('input[autocomplete=\"new-password\"]').value.length > 0");
|
|
$page->type('input[wire\:model="maxDownloads"]', '5');
|
|
// The foot of the drop zone, the files, the text and the password fill the window; typing left the page wherever it scrolled.
|
|
$page->script("document.activeElement?.blur(); window.scrollTo(0, document.querySelector('[data-test=drop-zone]').getBoundingClientRect().bottom + window.scrollY - 80)");
|
|
shoot($page, 'desktop', $theme, '01-upload');
|
|
|
|
// Creating the share is what offers the password once more beside the link.
|
|
$page->click('[data-test="create-share"]')
|
|
->waitForText('Share Created!');
|
|
$page->waitForEvent('networkidle')
|
|
->assertScript("document.querySelector('[data-test=\"share-password\"]') !== null");
|
|
shoot($page, 'desktop', $theme, '02-share-created', DemoData::CREATED_TOKEN);
|
|
|
|
$page->click('[data-test="show-qr-code"]')
|
|
->assertScript("document.querySelector('[data-test=\"qr-code-dialog\"]').open");
|
|
shoot($page, 'desktop', $theme, '03-qr-code', DemoData::CREATED_TOKEN);
|
|
|
|
// The dashboard shows the nine demo shares, as before.
|
|
app(ShareService::class)->deleteShare(Share::query()->where('token', DemoData::CREATED_TOKEN)->firstOrFail());
|
|
|
|
$download = visit(route('share.download', DemoData::DELIVERY_TOKEN, false));
|
|
shoot(shotPage($download, 'desktop', $theme), 'desktop', $theme, '04-download');
|
|
|
|
$this->actingAs(DemoData::admin());
|
|
|
|
$dashboard = visit('/admin/dashboard');
|
|
shoot(shotPage($dashboard, 'desktop', $theme), 'desktop', $theme, '05-admin-dashboard');
|
|
|
|
// Branding typed in but not saved, so the other shots keep SealShare's own.
|
|
$settings = visit('/admin/settings');
|
|
$page = shotPage($settings, 'desktop', $theme)
|
|
->type('input[wire\\:model="siteTitle"]', 'Acme Files')
|
|
->type('textarea[wire\\:model="siteDescription"]', 'Send and receive files securely with Acme Engineering.');
|
|
$page->script('document.activeElement?.blur(); window.scrollTo(0, 0)');
|
|
shoot($page, 'desktop', $theme, '06-admin-settings');
|
|
})->with(['light', 'dark']);
|
|
|
|
test('phone', function (string $theme) use ($files) {
|
|
$upload = visit('/upload');
|
|
$page = shotPage($upload, 'phone', $theme);
|
|
selectFiles($page, $files);
|
|
shoot($page, 'phone', $theme, '01-upload');
|
|
|
|
$password = visit(route('share.download', DemoData::PROTECTED_TOKEN, false));
|
|
shoot(shotPage($password, 'phone', $theme), 'phone', $theme, '02-password');
|
|
|
|
// Phones have no folder picker, so the page offers the ZIP there; desktop Chromium would offer the folder download.
|
|
$download = visit(route('share.download', DemoData::DELIVERY_TOKEN, false));
|
|
$page = shotPage($download, 'phone', $theme);
|
|
$page->script("Alpine.\$data(document.querySelector('[data-test=share-file-list]')).supported = false");
|
|
shoot($page, 'phone', $theme, '03-download');
|
|
|
|
$this->withSession(['created_shares' => [DemoData::DELIVERY_TOKEN]]);
|
|
$created = visit(route('share.created', DemoData::DELIVERY_TOKEN, false));
|
|
$page = shotPage($created, 'phone', $theme);
|
|
$page->click('[data-test="show-qr-code"]')
|
|
->assertScript("document.querySelector('[data-test=\"qr-code-dialog\"]').open");
|
|
shoot($page, 'phone', $theme, '04-qr-code');
|
|
|
|
// The text is fetched only when the recipient asks for it.
|
|
$text = visit(route('share.download', DemoData::TEXT_TOKEN, false));
|
|
$page = shotPage($text, 'phone', $theme);
|
|
$page->click('[data-test="show-text"]')
|
|
->waitForText('maple-lantern-8127')
|
|
->assertVisible('[data-test="shared-text"]');
|
|
shoot($page, 'phone', $theme, '05-private-text', DemoData::TEXT_TOKEN);
|
|
})->with(['light', 'dark']);
|