Profilfoto: Upload nach Inhalt pruefen, Loeschen nur per POST

- 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>
This commit is contained in:
Kevin 2026-09-23 15:42:11 +00:00
parent 22dcc0f972
commit e009b11259
5 changed files with 90 additions and 14 deletions

View file

@ -29,6 +29,16 @@ function piMakeUser(): User
]);
}
/** 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');
@ -48,7 +58,7 @@ it('speichert ein Slim-Upload nur fuer jpg/png und liefert die Metadaten', funct
$image = [
'input' => ['size' => 1234],
'output' => ['data' => 'BINARYDATA', 'name' => 'Mein Foto!.jpg', 'type' => 'image/jpeg'],
'output' => ['data' => piImageBytes('jpg'), 'name' => 'Mein Foto!.jpg', 'type' => 'image/jpeg'],
];
$stored = Slim::storeCroppedImage($image, 'images/profile');
@ -63,6 +73,38 @@ it('speichert ein Slim-Upload nur fuer jpg/png und liefert die Metadaten', funct
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');