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

@ -21,6 +21,12 @@ class ProfileImageController extends Controller
{
$user = Auth::user();
// Das Foto haengt am Konto (users.account_id). Ohne Konto gibt es nichts
// zu speichern — sonst liefe der Zugriff darunter in einen Fatal.
if (! $user->account) {
abort(404);
}
if (! Request::get('profile_image_consent')) {
\Session()->flash('alert-danger', __('account.profile_image_consent_required'));
@ -66,7 +72,7 @@ class ProfileImageController extends Controller
{
$user = Auth::user();
if ($user->account->profile_image) {
if ($user->account?->profile_image) {
\Storage::disk('public')->delete('images/profile/'.$user->account->profile_image);
$user->account->profile_image = null;

View file

@ -17,7 +17,17 @@ class Slim
/**
* Speichert ein Slim-Cropper-Upload (Base64-Payload aus getImages()) im
* public-Disk und liefert Dateiname + Metadaten — gemeinsame Logik für
* Shop- und Profilfoto-Upload. Null bei nicht erlaubtem Dateityp.
* Shop- und Profilfoto-Upload. Null, wenn der Inhalt kein JPEG/PNG ist.
*
* Zwei Dinge sind hier sicherheitsrelevant und dürfen nicht vereinfacht
* werden:
*
* 1. `output.type` ist eine ANGABE DES CLIENTS aus dem geposteten JSON, kein
* geprüfter Inhaltstyp. Massgeblich ist deshalb, was
* getimagesizefromstring() in den Bytes findet.
* 2. Die Endung kommt IMMER aus dem geprüften Typ, nie aus dem übermittelten
* Dateinamen: {@see sanitizeFileName()} lässt jede Endung stehen, ein
* "foto.php" landete sonst als ausführbarer Dateiname im public-Disk.
*
* @param array $image Ein Eintrag aus {@see getImages()}
* @return array{filename: string, originalname: string, ext: string, mime: string, size: mixed}|null
@ -26,19 +36,31 @@ class Slim
{
$allowed = ['image/jpeg' => 'jpg', 'image/png' => 'png'];
if (! isset($allowed[$image['output']['type']])) {
$declaredType = $image['output']['type'] ?? null;
$data = $image['output']['data'] ?? null;
if (! is_string($declaredType) || ! isset($allowed[$declaredType]) || ! is_string($data) || $data === '') {
return null;
}
$name = uniqid().'_'.Slim::sanitizeFileName($image['output']['name']);
$info = @getimagesizefromstring($data);
$mime = is_array($info) ? ($info['mime'] ?? null) : null;
\Storage::disk('public')->put(rtrim($directory, '/').'/'.$name, $image['output']['data']);
if (! is_string($mime) || ! isset($allowed[$mime])) {
return null;
}
$ext = $allowed[$mime];
$base = Slim::sanitizeFileName(pathinfo((string) ($image['output']['name'] ?? ''), PATHINFO_FILENAME));
$name = uniqid().'_'.$base.'.'.$ext;
\Storage::disk('public')->put(rtrim($directory, '/').'/'.$name, $data);
return [
'filename' => $name,
'originalname' => $image['output']['name'],
'ext' => $allowed[$image['output']['type']],
'mime' => $image['output']['type'],
'originalname' => $image['output']['name'] ?? '',
'ext' => $ext,
'mime' => $mime,
'size' => $image['input']['size'] ?? null,
];
}
@ -118,7 +140,7 @@ class Slim
if (isset($data->output)) {
$outputDate = null;
$outputData = null;
if (isset($data->output->image)) {
$outputData = Slim::getBase64Data($data->output->image);
} elseif (isset($data->output->field)) {