Uwaga! Trwają prace nad nową wersją serwisu. Mogą występować przejściowe problemy z jego funkcjonowaniem. Przepraszam za niedogodności!

⛔ Potrzebujesz wsparcia? Oceny CV? A może Code Review? ✅ Dołącz do naszej społeczności na Discordzie!

Upload plików przez użytkownika – jak zabezpieczyć aplikację

Poradnik dla początkujących programistów

Jako front end developer też możesz przyczynić się do tworzenia bezpiecznych aplikacji internetowych. Walidacja danych wprowadzanych przez użytkownika obejmuje nie tylko dane z formularzy, ale też wgrywane pliki. W tym artykule znajdziesz kilka kroków, które pomogą Ci zabezpieczyć aplikację przed potencjalnymi zagrożeniami.

Spis treści

 Wgrywanie plików przez użytkownika

Odczytywanie zawartości wybranych przez użytkownika plików umożliwia FileReader. Jest to rozwiązanie wbudowane w JavaScript i działa po stronie klienta (w przeglądarce).

Jednak dane pliku takie jak nazwa czy wielkość są dostępne wcześniej – w obiekcie przekazywanym do naszej funkcji (callbacku) po uruchomieniu zdarzenia change (czyli wgraniu pliku). Właśnie te informacje pozwolą nam wstępnie zwalidować plik po stronie front endu zanim jeszcze zrobimy coś z jego zawartością za pomocą FileReadera lub prześlemy na serwer.

Poniżej widzisz przykład prostego interfejsu, gdzie umożliwiamy użytkownikowi wgranie pliku CSV.

<form class="uploader">
    <label class="uploader__label">
    Wybierz plik CSV:
    <input
        class="uploader__input"
        type="file"
        accept="text/csv, .csv"
    />
    </label>
</form>

Zwróć uwagę, że w inpucie zastosowaliśmy atrybut accept, który determinuje typ ( MIME type) wgrywanego pliku oraz jego rozszerzenie. Dlaczego nie jest to wystarczające zabezpieczenie – o tym już za chwilę.

document.querySelector('.uploader__input').addEventListener('change', validateFiles);

function validateFiles(e) {
    const [file] = e.target.files;
    console.log(file)
}

W pliku JavaScript zapisujemy informacje o przesłanym pliku w zmiennej file (używamy destrukturyzacji, stąd nawiasy kwadratowe wokół nazwy) i wyświetlamy je w konsoli – będzie to obiekt m.in. z takimi właściwościami jak: name, size czy type. Nie tworzymy na razie instancji FileReadera – nie jest nam ona potrzebna do walidacji przedstawionej poniżej.

 Sprawdzenie rozszerzenia i typu pliku

Podstawowym zabezpieczeniem jest sprawdzenie zarówno rozszerzenia pliku, jak i jego typu zawartości ( MIME type). Pamiętaj, że sama nazwa pliku może zostać sfałszowana, więc sprawdzenie typu jest bezpieczniejsze.

Dlaczego atrybut accept w HTML-u jest niewystarczający?

<input
    class="uploader__input"
    type="file"
    accept="text/csv, .csv"
/>

Na poniższej grafice widzisz, że użycie tego atrybutu nie zablokowało możliwości wgrywania innych plików. Eksplorator jedynie automatycznie wyświetlił pliki o odpowiednim rozszerzeniu, nadal jednak możemy wgrać inny plik albo przez wprowadzenie jego nazwy, albo zmianę filtrowania na wszystkie typy plików.

Obejście atrybutu accept w eksploratorze plików

Dodatkowo w DevTools w przeglądarce możemy wyszukać atrybut accept w DOM-ie, usunąć go lub nadpisać i w ten sposób utorować sobie drogę do wgrania innych plików.

Sprawdzajmy więc nazwę i typ również w JavaScripcie. Możemy to osiągnąć, korzystając z informacji dostępnych po wybraniu pliku przez użytkownika:

function validateFiles(e) {
  const [file] = e.target.files;

  if (file && file.name.includes('.csv') && file.type === "text/csv") {
    // możemy zająć się obsługą pliku
    const reader = new FileReader();
    reader.onload = function(event) {
      console.log(event.target.result)
    }
    reader.readAsText(file)
  } else {
    console.log("Nieprawidłowy plik, wgraj plik CSV.")
  }
}

Miej na uwadze, że typy MIME mogą się różnić np. w zależności od wersji programu, z którego pochodzą pliki ( przykładem jest Excel i rozszerzenia .xls oraz .xlsx), a czasem nawet systemu operacyjnego. Chodzi o to, by przypadkiem nie zablokować użytkownikowi możliwości wgrywania plików tylko dlatego, że nie obsłużyliśmy odpowiedniego typu MIME.

 Ograniczenie liczby przesyłanych plików oraz limit wielkości pliku

Ogranicz liczbę i wielkość przesyłanych plików, aby zmniejszyć ryzyko m.in. ataku poprzez zapełnienie serwera ( denial of service, DoS).

Przykładowo jeśli tworzysz narzędzie do przetwarzania ikon SVG, to użytkownicy nie będą wgrywać plików wielkości kilkunastu megabajtów i więcej. Możesz więc ustawić limit, by nie dopuścić do przesyłania dużych, potencjalnie szkodliwych plików.

 Limit liczby plików

W pliku HTML dodajemy atrybut multiple, który umożliwi przesłanie większej liczby plików.

<input
    class="uploader__input"
    type="file"
    accept="text/csv, .csv"
    multiple
/>
function validateFiles(e) {
  const [file] = e.target.files;
  const filesCount = e.target.files.length

  if (filesCount > 5) {
    console.log("Przekroczono limit plików. Wgraj maksymalnie 5 plików.")
    return
  }
  //...
}

Choć e.target.files nie przechowuje tablicy (to tzw. obiekt FileList), to możemy sprawdzić jego długość (length), czyli liczbę zwartych w nim elementów. W ten sposób, jeśli liczba plików zostanie przekroczona, zakończymy działanie funkcji alertem dla użytkownika i wczesnym zwrotem (ang. early return).

 Limit wielkości plików

Wielkość pliku przechowywana jest we właściwości size i podana w bajtach. W przypadku ograniczenia liczby wgrywanych plików do jednego wystarczy prosty warunek:

const [file] = e.target.files;
//...

if (file.size > 100000) {
  console.log("Przekroczono limit wielkości pliku.")
  return
}

Natomiast przy większej liczbie plików najpierw musimy zsumować ich rozmiar. Możemy to zrobić za pomocą metody reduce, lecz najpierw trzeba zamienić wartość przechowywaną przez e.target.files na tablicę (poniżej zamiana następuje przez spread):

function validateFiles(e) {
  const [file] = e.target.files;
  const filesCount = e.target.files.length
  const filesSize = [...e.target.files].reduce((totalSize, file) => {
      return totalSize + file.size    
    }, 0)
  //...

  if (filesSize > 100000) {
    console.log("Przekroczono łączny limit wielkości plików.")
    return
  }

  //...
}

 Limit długości nazwy i ograniczenie dozwolonych znaków oraz sekwencji znaków

Bardzo długa nazwa pliku potencjalnie prowadzi do tzw. buffer overflow, co może zostać wykorzystane do zaatakowania programu. Warto więc walidować liczbę znaków nazwy pliku pod kątem rozsądnej długości.

const [file] = e.target.files;
//...

if (file.name.length > 45) {
  console.log("Plik ma za długą nazwę. Dozwolona liczba znaków: 45.")
  return
}

Jak wykonać sprawdzenie wielu plików w pętli, przedstawiam na przykładzie poniżej.

Prócz długości nazwy, należałoby ograniczyć dozwolone znaki w nazwie pliku do liter, liczb i paru znaków specjalnych (jak np. minus, podkreślnik, spacja czy kropka). Wykluczone powinny zostać znaki umożliwiające wstrzyknięcie kodu (code injection) lub modyfikację ścieżki do pliku, czyli np.:

  • znaki specjalne: < > & % # { }
  • sekwencje znaków: &lt, &gt, &amp, ./ lub ../

Do tego zabiegu posłużą nam wyrażenia regularne. Do sprawdzenia nazw kilku wgranych plików wykorzystamy pętlę. Nazwy nieprawidłowych plików zapiszemy w tablicy wrongFileNames, aby móc wyświetlić je użytkownikowi:

function validateFiles(e) {
 //...
  const permittedCharsRegex = /^[a-zA-Z0-9-._s]+$/;
  const wrongFileNames = [];

  for (const file of e.target.files) {
    if (!permittedCharsRegex.test(file.name)) {
      wrongFileNames.push(file.name)
    }
  }
  if (wrongFileNames.length > 0) {
    console.log(`Następujące pliki zawierają niedozwolone znaki: ${wrongFileNames.join(', ')}. Dozwolone: litery, liczby, kropka, minus, podkreślnik i spacja.`)
    return
  }
  //...
}

Jeśli zapisujesz też pliki na serwerze, możesz zmienić ich nazwy na unikalne i niedające się przewidzieć, aby uniemożliwić atakującym wykonywanie skryptów poprzez dotarcie do nazw plików oraz manipulowanie nimi. Jeżeli jednak pracujesz tylko po stronie front endu, nie będziesz się tym zajmować.

Kod po wstępnej refaktoryzacji znajdziesz w repozytorium na GitHubie, a jego działanie podejrzysz dzięki GitHub Pages.

Możesz dokonać własnej refaktoryzacji, na przykład gromadząc ograniczenia oraz komunikaty dla użytkownika w tablicy obiektów. Wyrażenie regularne sprawdzające dozwolone znaki możesz też rozszerzyć np. o alfabet łaciński.

 

Walidacja, której dokonaliśmy, to ułamek działań, jakie trzeba podjąć, by zabezpieczyć program czy serwer. Mimo to warto podejmować działania nie tylko po stronie back endu. Gdy zastosujesz nawet prostą walidację w swoich projektach do portfolio, pokażesz, że jesteś programistą świadomym zagrożeń i podejmujesz kroki, by im przeciwdziałać.

Udostępnij ten artykuł:

Mentoring to efektywna nauka pod okiem doświadczonej osoby, która:

  • przekazuje Ci swoją wiedzę i nadzoruje Twoje postępy w zdobywaniu umiejętności,
  • uczy Cię dobrych praktyk i wyłapuje złe nawyki,
  • wspiera Twój rozwój i zwiększa zaangażowanie w naukę.

Mam coś dla Ciebie!

W każdy piątek rozsyłam motywujący do nauki programowania newsletter!

Dodatkowo od razu otrzymasz ode mnie e-book o wartości 39 zł. To ponad 40 stron konkretów o nauce programowania i pracy w IT.

PS Zazwyczaj wysyłam 1-2 wiadomości na tydzień. Nikomu nie będę udostępniał Twojego adresu e-mail.