Przejdź do treści
Bezpieczeństwo

Upload przepuszcza plik, bo file.type mówi image/png. Ten string wysyła klient, nie plik

Walidacja uploadu po typie MIME sprawdza deklarację klienta, nie plik. Podpis, lista rozszerzeń, nagłówki trasy z plikami i przegląd przed oddaniem projektu.

Redakcjadeveloper front-end10 minkod + tabela

Wygenerowany endpoint przyjmujący avatar ma zwykle jedną linię walidacji: if (!file.type.startsWith('image/')) return 400. Ta linia wygląda na kontrolę typu pliku, a sprawdza tekst, który do serwera wysłał klient. Dokumentacja OWASP nie zostawia tu miejsca na interpretację: „The Content-Type for uploaded files is provided by the user, and as such cannot be trusted, as it is trivial to spoof”. Jak to wygląda w produkcji, pokazuje CVE-2026-25648 w Traccarze: uwierzytelniony użytkownik wgrywał SVG ze skryptem jako obrazek urządzenia, a serwer zapisywał plik bez sanityzacji. Podatność dotyczy wersji od 5.1 do 6.12.0 i dostała ocenę 8,7 w skali CVSS.

Trzy warstwy informacji o pliku i tylko jedna należy do serwera

Rozdzielenie tych warstw jest całą różnicą między walidacją a jej pozorem. Model generujący kod łączy je w jedno, bo w API przeglądarki wszystkie trzy wyglądają jak własności tego samego obiektu.

Warstwa Skąd się bierze Kto może ją ustawić Czym zweryfikować po stronie serwera
Nagłówek Content-Type części multipart, w JS widoczny jako file.type żądanie HTTP klient, dowolnie niczym, to deklaracja; traktuj jako podpowiedź
Rozszerzenie w nazwie pliku pole filename w żądaniu klient, dowolnie lista dozwolonych rozszerzeń, nie lista zablokowanych
Zawartość pliku ciało żądania klient, ale treść musi być spójna z formatem podpis pliku, parser formatu, ponowne zakodowanie

Test, który ustawia tę sprawę w pół minuty, nie wymaga żadnego narzędzia poza curlem. Pierwsze polecenie wysyła plik tekstowy podpisany jako PNG, drugie prawdziwy PNG pod nazwą z rozszerzeniem skryptowym.

printf '<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"></svg>' > a.svg

curl -F 'file=@a.svg;type=image/png;filename=avatar.png' \
     -H 'Authorization: Bearer ...' https://example.test/api/upload

curl -F 'file=@prawdziwy.png;filename=avatar.png.php' \
     -H 'Authorization: Bearer ...' https://example.test/api/upload

Jeśli oba żądania kończą się kodem 200, walidacja w tym projekcie nie istnieje, niezależnie od tego, ile linii zajmuje. OWASP o listach blokujących rozszerzenia pisze wprost, że „blocking specific extensions is a weak protection method on its own”, i zaleca odwrotną konstrukcję: wymienić rozszerzenia dozwolone dla konkretnej funkcji biznesowej. Ta sama strona każe odrzucać nazwy z dwukropkiem, wiodącą kropką i kropkami następującymi po sobie — pierwsze dla alternatywnych strumieni danych NTFS, drugie dla plików ukrytych, trzecie dla przejścia po katalogach.

Podpis pliku rozstrzyga więcej niż nagłówek, ale nie wszystko

Sprawdzenie pierwszych bajtów jest tanie i odsiewa większość prób przemycenia czegoś innego niż zadeklarowany format.

const PODPISY = {
  'image/png':  [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a],
  'image/jpeg': [0xff, 0xd8, 0xff],
  'image/gif':  [0x47, 0x49, 0x46, 0x38],
};

function rozpoznajTyp(buf) {
  for (const [typ, podpis] of Object.entries(PODPISY)) {
    if (podpis.every((bajt, i) => buf[i] === bajt)) return typ;
  }
  return null;
}

Dwie rzeczy, których ten kod nie robi. Pierwsza: weryfikacja podpisu nie ma być używana samodzielnie, bo obejście jest proste i częste — plik może zaczynać się poprawnym nagłówkiem PNG i dalej zawierać cokolwiek. Druga: SVG nie ma podpisu, bo jest dokumentem XML, czyli tekstem. Żadna tablica magicznych bajtów nie odróżni poprawnego SVG od SVG ze skryptem ani od dowolnego innego tekstu, który zaczyna się od znaku mniejszości. Dlatego formaty tekstowe wymagają parsera albo decyzji, że w tej funkcji ich nie przyjmujesz.

Najmocniejszy wariant kontroli zawartości dla obrazów to ponowne zakodowanie: zdekodować plik i zapisać go z powrotem w dozwolonym formacie, usuwając metadane. OWASP dodaje do tego zastrzeżenie, które warto przeczytać dwa razy: „Rewriting does not guarantee that all malicious content is removed”. Przy ponownym kodowaniu rośnie natomiast inna pewność — wynik jest na pewno obrazem rastrowym, a nie dokumentem, który przeglądarka może potraktować jako stronę.

Dlaczego SVG wykonuje skrypt, a PNG nie

SVG to XML, więc plik może nieść <script>, element <foreignObject> albo atrybut zdarzenia w rodzaju onload. Gdy serwer wydaje taki plik jako image/svg+xml z własnej domeny, a przeglądarka otwiera go jako dokument, skrypt działa w kontekście tej domeny: ma dostęp do sesji i może wykonywać żądania do API jako zalogowany użytkownik.

Advisory Traccara opisuje ten mechanizm dokładniej, niż zwykle robią to opracowania wtórne, i jest w nim jedno rozróżnienie, które decyduje o skali problemu: wyświetlenie SVG jako obrazka przez element img nie wykonuje jego skryptów. Skrypt rusza, gdy plik zostanie otwarty bezpośrednio pod swoim adresem albo osadzony jako dokument w kontekście, który na skrypty pozwala. Interfejs pokazujący avatary w <img> wygląda więc bezpiecznie, dopóki ktoś nie wkleił komuś linku do samego pliku. W Traccarze trasa wydająca media podawała plik jako image/svg+xml i nie ustawiała ani Content-Security-Policy, ani X-Content-Type-Options.

Druga obserwacja z tego dokumentu dotyczy kolejności naprawy. Wersja 6.12.0 usunęła SVG z listy dozwolonych formatów, ale samo uaktualnienie nie neutralizuje plików SVG wgranych wcześniej — trzeba je usunąć albo zablokować ich wydawanie. Przy projekcie, który wisi u klienta od kilku miesięcy, zamknięcie dziury w kodzie jest połową roboty; druga połowa to przegląd katalogu z plikami.

Warto przy tym zauważyć, skąd Traccar brał rozszerzenie zapisywanego pliku: wcześniejsze wersje wyprowadzały je z podtypu przesłanego w żądaniu. To ta sama pomyłka co file.type, tylko o jeden krok głębiej — deklaracja klienta nie przechodzi walidacji, lecz decyduje o nazwie pliku na dysku, a przez to o tym, jak serwer poda go później przeglądarce.

Nagłówki na trasie, która wydaje pliki

Trzy nagłówki robią trzy różne rzeczy i łatwo przypisać im zasługi, których nie mają.

GET /media/avatar/0f9c1e7a-4f1b-4d1e-9a2b-7c5d3e8f1a20.svg

Content-Type: image/svg+xml
X-Content-Type-Options: nosniff
Content-Disposition: attachment; filename="avatar.svg"
Content-Security-Policy: sandbox

Gdzie wygenerowany kod zapisuje plik

Druga połowa problemu nie dotyczy walidacji, tylko miejsca zapisu. W projektach na Next.js model wyjątkowo chętnie zapisuje upload do katalogu public/uploads, bo tam już leżą obrazki z szablonu. Dokumentacja jest w tej sprawie jednoznaczna: pliki wewnątrz public można adresować od bazowego adresu URL, czyli public/avatars/me.png jest widoczny pod ścieżką /avatars/me.png. Każdy przyjęty plik dostaje w tej konstrukcji publiczny adres na domenie aplikacji, bez przejścia przez kod, który mógłby dołożyć nagłówki z poprzedniej sekcji.

Dwie rzeczy dokładane zwykle na końcu, choć kosztują jedną linię konfiguracji: katalog z plikami użytkowników nie potrzebuje uprawnień do wykonywania, a na serwerze Apache sensownie jest wyłączyć w nim nadpisywanie konfiguracji przez pliki katalogowe, czyli AllowOverride None — inaczej wgrany plik konfiguracyjny może zmienić sposób podawania zawartości tego katalogu. Przy Windowsie dochodzi szczegół, o którym łatwo nie wiedzieć: długa nazwa pliku może mieć alias w formacie 8.3, więc sprawdzanie wyłącznie nazwy przysłanej przez użytkownika nie wystarcza do stwierdzenia, że plik o tej nazwie jeszcze nie istnieje.

Kolejność preferencji w dokumentacji OWASP jest odwrotna do tego, co generuje model: najpierw inny serwer, a jeśli to niemożliwe — miejsce poza katalogiem publikowanym przez serwer WWW. Nazwa pliku ma być wygenerowana po stronie serwera: losowy ciąg jako nazwa, na przykład UUID, jest konieczny, a nazwa od użytkownika zostaje najwyżej jako etykieta w bazie, nigdy jako fragment ścieżki. Do tego limit rozmiaru ustawiony tam, gdzie plik wchodzi, a przy archiwach liczony po rozpakowaniu, nie przed.

Co sprawdzić w wygenerowanym endpoincie przed oddaniem

Ta lista ma wartość tylko wtedy, gdy wykona ją ktokolwiek z zespołu, a nie wyłącznie osoba, która pisała ten endpoint. Reguły spisywania takich procedur są te same co poza kodem: krok musi mieć widoczny wynik, po którym wiadomo, że został wykonany, i dlatego poniżej każdy punkt kończy się czymś do zobaczenia, nie oceną.

  • Dwa żądania curlem z podmienionym type i z podwójnym rozszerzeniem w filename. Kod inny niż 400 kończy przegląd w tym miejscu.
  • Lista dozwolonych rozszerzeń i typów w jednym miejscu w kodzie, wymieniona wprost, bez startsWith('image/').
  • Rozpoznanie typu po zawartości, a dla obrazów rastrowych ponowne zakodowanie z usunięciem metadanych.
  • Decyzja o SVG podjęta świadomie i zapisana: albo nie wchodzi, albo wchodzi po sanityzacji XML i jest wydawany z nagłówkami z poprzedniej sekcji.
  • Nazwa pliku z generatora UUID, ścieżka złożona po stronie serwera, nazwa użytkownika tylko w kolumnie opisowej.
  • Katalog docelowy poza drzewem publikowanym statycznie, a wydawanie plików przez trasę aplikacji, która dokłada nagłówki i sprawdza uprawnienia do pliku.
  • Limit rozmiaru i limit liczby żądań na trasie uploadu. Bez tego drugiego otwarty endpoint jest też rachunkiem za transfer i magazyn.
  • Przegląd plików już wgranych w projekcie, który działa od miesięcy — find po rozszerzeniach tekstowych w katalogu uploadu i po plikach z podpisem niezgodnym z rozszerzeniem.

Co z tego wynika dla rozmowy z klientem

Upload plików jest jedną z nielicznych funkcji, w których wygenerowany kod działa poprawnie na ścieżce szczęśliwej i jednocześnie nie ma żadnej z czterech kontroli, których wymaga: typu, nazwy, miejsca zapisu i nagłówków przy wydawaniu. Dla osoby odbierającej projekt wszystko wygląda dobrze, bo avatar się wgrywa i wyświetla. Przegląd tej jednej trasy zajmuje mniej niż godzinę i jest tańszy od każdego scenariusza, w którym ta rozmowa odbywa się po incydencie. Jest to też jedno z tych zadań, które spokojnie schodzi z biurka osoby najbardziej doświadczonej, jeśli tylko przekazanie zawiera kryterium gotowości, a nie samo polecenie.

Decyzja należy do wykonawcy i trzeba ją podjąć przed oddaniem, nie po pierwszym zgłoszeniu: albo formaty wektorowe nie wchodzą do funkcji, w której wystarczy raster, albo wchodzą świadomie, z sanityzacją, osobnym pochodzeniem i nagłówkami, które odbierają im status dokumentu. Trzecia droga, w której SVG wchodzi, bo nikt o nim nie pomyślał, kończy się tak jak w CVE-2026-25648 — z osobnym zadaniem na przegląd plików wgranych przez ostatnie pół roku.