- Slim::storeCroppedImage() vertraute dem vom Client gemeldeten Typ und uebernahm die Endung aus dem Dateinamen; "shell.php" mit Typ image/png landete als .php im public-Disk. Jetzt entscheidet getimagesizefromstring() ueber die Bytes, die Endung kommt aus dem geprueften Typ. - Foto loeschen war ein GET-Link und liess sich per fremdem <img src> ausloesen (CSRF); jetzt POST-Formular mit Token. - Ohne Konto (users.account_id) bricht Upload/Loeschen mit 404 ab statt mit einem Fatal. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
121 lines
5 KiB
PHP
121 lines
5 KiB
PHP
<?php
|
|
|
|
use App\Http\Controllers\ProfileImageController;
|
|
use App\Models\UserAccount;
|
|
use App\Services\Slim;
|
|
use App\User;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Request;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Facades\URL;
|
|
use Tests\TestCase;
|
|
|
|
uses(TestCase::class, RefreshDatabase::class);
|
|
|
|
function piMakeUser(): User
|
|
{
|
|
$account = UserAccount::forceCreate([
|
|
'first_name' => 'Pia',
|
|
'last_name' => 'Probe',
|
|
]);
|
|
|
|
return User::forceCreate([
|
|
'email' => 'pi-'.uniqid('', true).'@example.com',
|
|
'password' => bcrypt('secret'),
|
|
'lang' => 'de',
|
|
'm_level' => 1,
|
|
'account_id' => $account->id,
|
|
'payment_account' => now()->addYear(),
|
|
]);
|
|
}
|
|
|
|
/** Echte Bytes eines 1x1-JPEG bzw. -PNG — storeCroppedImage() prueft den Inhalt, nicht die Client-Angabe. */
|
|
function piImageBytes(string $type): string
|
|
{
|
|
$base64 = $type === 'png'
|
|
? 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='
|
|
: '/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDABALDA4MChAODQ4SERATGCgaGBYWGDEjJR0oOjM9PDkzODdASFxOQERXRTc4UG1RV19iZ2hnPk1xeXBkeFxlZ2P/2wBDARESEhgVGC8aGi9jQjhCY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2P/wAARCAABAAEDASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6ery8/T19vf4+fr/xAAfAQADAQEBAQEBAQEBAAAAAAAAAQIDBAUGBwgJCgv/xAC1EQACAQIEBAMEBwUEBAABAncAAQIDEQQFITEGEkFRB2FxEyIygQgUQpGhscEJIzNS8BVictEKFiQ04SXxFxgZGiYnKCkqNTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqCg4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2dri4+Tl5ufo6ery8/T19vf4+fr/2gAMAwEAAhEDEQA/APP6KKKAP//Z';
|
|
|
|
return base64_decode($base64);
|
|
}
|
|
|
|
it('verweigert den Foto-Upload ohne Einwilligung', function (): void {
|
|
URL::resolveMissingNamedRoutesUsing(fn (string $name, $parameters = []) => '/'.$name);
|
|
Storage::fake('public');
|
|
|
|
$user = piMakeUser();
|
|
$this->actingAs($user);
|
|
Request::merge(['profile_image_consent' => null]);
|
|
|
|
app(ProfileImageController::class)->uploadImage();
|
|
|
|
expect(session('alert-danger'))->toBe(__('account.profile_image_consent_required'))
|
|
->and($user->account->fresh()->profile_image)->toBeNull();
|
|
});
|
|
|
|
it('speichert ein Slim-Upload nur fuer jpg/png und liefert die Metadaten', function (): void {
|
|
Storage::fake('public');
|
|
|
|
$image = [
|
|
'input' => ['size' => 1234],
|
|
'output' => ['data' => piImageBytes('jpg'), 'name' => 'Mein Foto!.jpg', 'type' => 'image/jpeg'],
|
|
];
|
|
|
|
$stored = Slim::storeCroppedImage($image, 'images/profile');
|
|
|
|
expect($stored)->not->toBeNull()
|
|
->and($stored['ext'])->toBe('jpg')
|
|
->and($stored['mime'])->toBe('image/jpeg')
|
|
->and($stored['size'])->toBe(1234);
|
|
Storage::disk('public')->assertExists('images/profile/'.$stored['filename']);
|
|
|
|
$gif = ['input' => [], 'output' => ['data' => 'X', 'name' => 'a.gif', 'type' => 'image/gif']];
|
|
expect(Slim::storeCroppedImage($gif, 'images/profile'))->toBeNull();
|
|
});
|
|
|
|
it('legt die Datei-Endung nach dem geprueften Inhalt an, nicht nach dem Dateinamen', function (): void {
|
|
// Angriffsbild: der Client deklariert image/png, nennt die Datei aber .php.
|
|
// Frueher landete genau dieser Name im oeffentlichen Disk.
|
|
Storage::fake('public');
|
|
|
|
$image = [
|
|
'input' => ['size' => 10],
|
|
'output' => ['data' => piImageBytes('png'), 'name' => 'shell.php', 'type' => 'image/png'],
|
|
];
|
|
|
|
$stored = Slim::storeCroppedImage($image, 'images/profile');
|
|
|
|
expect($stored)->not->toBeNull()
|
|
->and($stored['filename'])->toEndWith('.png')
|
|
->and($stored['filename'])->not->toContain('.php');
|
|
Storage::disk('public')->assertExists('images/profile/'.$stored['filename']);
|
|
});
|
|
|
|
it('lehnt einen Upload ab, dessen Inhalt kein Bild ist', function (): void {
|
|
// Die Typ-Angabe kommt aus dem geposteten JSON und ist damit frei waehlbar —
|
|
// massgeblich sind die Bytes.
|
|
Storage::fake('public');
|
|
|
|
$image = [
|
|
'input' => ['size' => 10],
|
|
'output' => ['data' => '<?php echo 1;', 'name' => 'foto.png', 'type' => 'image/png'],
|
|
];
|
|
|
|
expect(Slim::storeCroppedImage($image, 'images/profile'))->toBeNull();
|
|
expect(Storage::disk('public')->allFiles('images/profile'))->toBe([]);
|
|
});
|
|
|
|
it('loescht das Profilfoto samt Datei', function (): void {
|
|
URL::resolveMissingNamedRoutesUsing(fn (string $name, $parameters = []) => '/'.$name);
|
|
Storage::fake('public');
|
|
|
|
$user = piMakeUser();
|
|
Storage::disk('public')->put('images/profile/test_pic.jpg', 'BINARY');
|
|
$user->account->forceFill(['profile_image' => 'test_pic.jpg'])->save();
|
|
|
|
$this->actingAs($user);
|
|
app(ProfileImageController::class)->deleteImage();
|
|
|
|
expect($user->account->fresh()->profile_image)->toBeNull();
|
|
Storage::disk('public')->assertMissing('images/profile/test_pic.jpg');
|
|
});
|