Ukládání souborů od uživatele

Školení, která pořádám

Pokud chceme uživateli dát možnost nahrávat na server soubory (např. fotografie do galerie), je potřeba to udělat obzvlášť pečlivě. První dobrou zásadou je používat pro práci s nahranými soubory pole $_FILES a být si vědom různých velikostních limitů.

Další věc, kterou musíme rozhodnout, je umístění nahrávaných souborů. Pokud se rozhodneme je ukládat do databáze (nejspíš do sloupce typu mediumblob), tak mírně zhoršíme výkon a pro stahování těchto souborů budeme muset vytvořit vlastní skript. Pokud však chceme mít soubory k dispozici na více serverech (např. kvůli rozložení výkonu), je toto řešení poměrně vhodné, protože můžeme využít standardní databázovou replikaci.

Pokud se soubory rozhodneme ukládat do adresáře na disku, musíme pamatovat na více věcí. Především musí mít do tohoto adresáře právo zápisu webový server. Pokud naše aplikace běží na sdíleném webhostingu bez správného zabezpečení, musíme počítat s tím, že ostatní uživatelé nám budou moci soubory z tohoto adresáře mazat a přepisovat.

Dalším vhodným krokem je zamezení spouštění skriptů v tomto adresáři direktivou engine. Pokud by totiž uživatel do tohoto adresáře nahrál místo obrázku PHP skript, mohl by nad naší aplikací získat v podstatě neomezenou moc.

Kontrola typu nahrávaného souboru je dalším důležitým bodem na našem seznamu. V proměnné $_FILES['userfile']['type'] sice máme k dispozici typ nahraného souboru, tento typ ale nastavuje klient a může ho mít nastaven špatně, útočník ho navíc může snadno změnit. Lepší je tedy zjistit typ souboru na základě koncovky např. funkcí mime_content_type nebo rovnou na základě obsahu souboru např. funkcí getimagesize nebo mime_content_type.

Dále se musíme rozhodnout, jestli budeme soubory ukládat pod původním názvem (který měl soubor u uživatele) nebo pod nějakým uměle vytvořeným (např. na základě ID přidruženého záznamu). U původního názvu musíme dát pozor na to, aby nekolidoval s nějakým již existujícím souborem (navíc s vědomím rizik plynoucích z paralelního zpracování požadavků), za odměnu ale získáme přirozenější názvy souborů. Vhodným řešením je tedy oba způsoby zkombinovat.

<?php
$koncovky = array('jpg', 'jpeg', 'png', 'gif');

$chyba = "";
if (!$_FILES || $_FILES["obrazek"]["error"] == UPLOAD_ERR_INI_SIZE) {
    $chyba = "Soubor je příliš velký, maximální velikost je " . ini_get('upload_max_filesize') . ".\n";
} elseif ($_FILES["obrazek"]["error"] == UPLOAD_ERR_NO_FILE) {
    $chyba = "Nevybrali jste soubor, který chcete nahrát.\n";
} elseif ($_FILES["obrazek"]["error"]) {
    $chyba = "Soubor se nepodařilo nahrát, kontaktujte prosím správce serveru.\n";
} elseif (!in_array(strtolower(pathinfo($_FILES["obrazek"]["name"], PATHINFO_EXTENSION)), $koncovky)) {
    $chyba = "Koncovka souboru musí být jedna z: " . implode(", ", $koncovky) . ".\n";
} elseif (!($imagesize = getimagesize($_FILES["obrazek"]["tmp_name"])) || $imagesize[2] > 3) {
    $chyba = "Typ obrázku musí být JPG, PNG nebo GIF.\n";
} else {
    move_uploaded_file($_FILES["obrazek"]["tmp_name"], "data/$id-" . $_FILES["obrazek"]["name"]);
}
?>

Při používání globálních proměnných pro práci s nahranými soubory bylo třeba ověřovat, zda proměnná skutečně vznikla z nahraného souboru nebo zda se nám ji útočník snaží podstrčit. K tomu sloužila např. funkce is_uploaded_file. Při použití pole $_FILES není nutné tyto funkce používat, protože PHP za původ dat v tomto poli ručí. Funkce move_uploaded_file je použita proto, že u prvního parametru na rozdíl od rename nekontroluje open_basedir. Na správně zabezpečeném serveru je ale stejně vhodné umístit upload_tmp_dir pro každého uživatele dovnitř jeho open_basedir.

Přijďte si o tomto tématu popovídat na školení o bezpečnosti PHP aplikací.

Jakub Vrána, Dobře míněné rady, 28.12.2005, diskuse: 31 (nové: 0)

Diskuse

tracy:

Na proměnnou $_FILES['file']['type'] určitě nespoléhat! Kdysi jsem si s ní taky užil své.. V bláhovém domění, že typ souboru stanoví PHP jsem upload fotek otestoval v mozille a hotovo. Jenže jsem nevěděl, že IE bude místo image/jpeg posílat image/pjpeg..
# 28.12.2005 02:59:18 reagovat

Tomik:

Jj, strávil jsem na tím 2 večery a není to tak dávno (cca 3 měsíce). Nechápu co k tomu IE vede... :(
# 28.12.2005 03:29:23 reagovat

StratoS:

ciste teoreticky nebude "pjpeg" znamenat Progressive JPEG ;)
tudiz bych chybu nehledal na strane IE, ale spis nekde mezi zidli a stolem.. (nemyslim to zle)
# 21.2.2006 13:16:03 reagovat

migon:

a v com je problem  pridat jednu polozku do toho pole?
navic image/pjpeg je dost znama vec.... ale mlcim
# 28.12.2005 03:40:18 reagovat

Tomik2:

Kontrolu velikosti souboru bych nejraději dělal navíc přímo u klienta pomocí Javascriptu (potom samozřejmě také pomocí PHP, ale aby se zbytečně nepřenášely data ...).
Zkoušel jsem na to hledat nějaký příklad, ale nic jsem nenašel, nemáte někdo něco ? ;-)
# 28.12.2005 14:14:15 reagovat

Ditto:

Javascript sa da desne lahko obist...
# 20.6.2007 10:44:27 reagovat

ikona Jakub Vrána:

Proto Tomik2 píše, že se to musí potom samozřejmě zkontrolovat i na serveru. U klienta se to kontroluje proto, aby se zbytečně nepřenášela data - je to tedy ochrana uživatele, ne aplikace.
# 20.6.2007 12:43:45 reagovat

Tomik:

> migon
Problém je v tom, že celé dva večery mě nenapadlo to testnout v IE, prostě jsem si říkal, že může IE rohodit layout, podělat pozicování, ale nenapadlo mě, že bude posílat jiný MIME-TYP, ano, když člověk ví, že IE posílá jiný mime-tyo, není problém přidat jednu podmínku a aplikace je funkční i pro IEčkaře.
# 28.12.2005 14:48:04 reagovat

ikona spaze:

"Lepší je tedy zjistit typ souboru na základě koncovky např. funkcí mime_content_type"

mime_content_type() nezjišťuje typ souboru podle koncovky (což bych stejně jako *jedinnou* ochranu nedoporučoval), ale podle obsahu, alespoň podle manuálu, hrabat ve zdrojáku se mi teď nechce:

"Returns the MIME content type for a file as determined by using information from the magic.mime file."

Mimochodem, mnohem zajimavější (alespoň pro mě) je skrytá informace o PATHINFO_EXTENSION, už žádný substr() a strpos() nebo regexpy na zjištění přípony souboru, díky.
# 28.12.2005 16:26:26 reagovat

anode:

<?php ($imagesize[0] > 3) ?>
má být asi
<?php ($imagesize[2] > 3) ?>
pro ty co copy&pastují ;-)
ale jinak díky za článek
# 30.12.2005 01:48:37 reagovat

ikona Jakub Vrána:

Díky za postřeh, opravil jsem to.
# 2.1.2006 22:59:26 reagovat

Buchtak:

Hm, mozna trapny dotaz co sem nepatri, ale - pokud chci nahrat soubor swf, tak mi nejak nestaci pridat dalsi polozku application/x-shockwave-flash, kterou se takovy soubor hlasi v IE i v Mozile... jde to nejak obejit nebo na to existuje jiny trik? Diky za radu.
# 25.1.2006 02:17:24 reagovat

racky:

Ve škole jsem dostal za úkol vymyslet, jak nahrát z klienta na server soubor (např. obrázek) když jde o aplikaci typu Client-Server.. takže nemají společní temp atd.. Prostě jsou to 2 různé počítače kdekoli na světě. Nemohu tudíš serveru předat odkaz na soubor, ale musím soubor fyzicky odeslat, například převedením na řetězec.. To je podmínkou nejen zadání ale celé technologie Client-Server.
Bohužel si s tím zatím nevím moc rady..
Poradíte někdo?

Co třeba nabídnout serveru soubor ke stažení z klientského pc?.. ale to by u serveru někdo musel sedět a odklikonut "uložit" :)
# 3.4.2006 14:10:23 reagovat

Rusak:

Dobry den !
Mam takovy dotaz: klient nahrava soubor na server a najednou rozmysli cekat na svou pomalou pevnou linku a ztlaci Esc. Nahravani se zastavi, ale php sikovne ulozi prispevek do databazi, zkontroluje obrazek, overi vse dle vaseho navodu a ulozi cerny prazdny obrazek. Fizicky na serveru ulozen nejaky kousek fotky (pulka je seda).
A jsem nucen rucne mazattakove prispevky.

Da se tomu nejak zabranit ?

Zkousel jsem kontrolovat i filesize, ale nepovedlo se to.

Dekuji moc
# 23.5.2006 21:15:02 reagovat

xpfighter:

tento upload je vcelku dobry pretoze ma osetrenych vela veci, avsak pri overovani funkciou getimagesize nemozno otvorit touto funkciou obrazok z adresara /temp/* pri nastavenom openbasedir na docroot uzivatela pretoze /temp... je mimo adresara daneho skriptu pod uid weboveho servera.

skusal som riesit overovanie cez getimagesize() az po uloade na server jeho vymazom ak sa jedna o iny typ. Myslim si vsak ze taketo rieseni moze mat nedostatok pri preruseni uploadu sa subor nezmaze.
# 25.7.2006 15:26:35 reagovat

xpfighter:

jeho vymazanie bolo myslene tak ze sa zmaze ak je to iny typ ako povoleny. bavime sa len o obrazkoch
# 25.7.2006 15:27:42 reagovat

kozozvon:

ještě bych rozšířil poslední větev if...endif stuktu na:

<?php
} elseif (!@move_uploaded_file($_FILES["obrazek"]["tmp_name"], "data/$id-" . $_FILES["obrazek"]["name"])) {
 
$chyba="Nepodařilo se přesunout uploadovaný soubor.\n";
}
?>
# 27.6.2007 12:39:48 reagovat

kozozvon:

...a soubory záměrně matoucí koncovky bych rovnou přejmenovával (některé prohlížeče by si s touhle záludností nemusely poradit)
# 27.6.2007 12:43:45 reagovat

liska11:

muzu se zeptat je potom jedno jak se jmenuje promenna toho ve formulari kde se nahrava soubor ?
# 31.10.2007 18:50:45 reagovat

Tomáš N.:

Proč je před if tento kód:

<?php
$chyba
= "";
?>

Přemýšlel jsem, ale nepřišel jsem na to. Dík.
# 15.9.2008 10:21:44 reagovat

ikona Jakub Vrána:

Jde o inicializaci proměnné. Pokud by tam tento kód nebyl, dala by se hodnota této proměnné mj. při zapnutém register_globals podstrčit zvenku.
# 15.9.2008 10:25:12 reagovat

Petr:

Ahojte, zajímalo by mě, jak by vypadal zmíněný kod, kdyby v sobě obsahoval ještě odstranění diakritky z názvu souboru(pokud by to na  $_FILES["obrazek"]["name"] vůbec šlo).

A nebo kdybych chtěl přímo změnit název souboru? Třeba zachovat pouze (např.) $id.jpg

Díky moc za radu, P.
# 9.1.2009 17:02:20 reagovat

Mch81:

zkus se podivat na tento prikaz ten prevede velka pismena na mala, kdyz tam das prvni diakritiku a druhy na co se ma prepsat, tak mas hotovo. Mozna existuje lepsi reseni.
$name = StrTr($name, "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz");
# 9.2.2009 14:46:24 reagovat

keeehi:

Problém však je v tom, že se nedají postihnout všechny znaky. Takže bych spíš doporučil něco takovédleho.
<?php
function simptext($retezec="")
{
$prevodni_tabulka = Array(
'á'=>'a','Á'=>'A','č'=>'c','Č'=>'C','ď'=>'d','Ď'=>'D','ě'=>'e','Ě'=>'E','é'=>'e','É'=>'E','í'=>'i','Í'=>'I','ň'=>'n','Ň'=>'N','ó'=>'o','Ó'=>'O','ř'=>'r','Ř'=>'R','š'=>'s','Š'=>'S','ť'=>'t','Ť'=>'T','ú'=>'u','Ú'=>'U','ů'=>'u','Ů'=>'U','ý'=>'y','Ý'=>'Y','ž'=>'z','Ž'=>'Z',' '=>'-');
$retezec = strtr($retezec, $prevodni_tabulka);
$povoleneznaky = array("a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u","v","w","x","y","z","A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P","Q","R","S","T","U","V","W","X","Y","Z","0","1","2","3","4","5","6","7","8","9","-");
for(
$i=0;$i<strlen($retezec);$i++){
if(
in_array($retezec[$i],$povoleneznaky))
$novyretezec.=$retezec[$i];}
return
$novyretezec;
}
?>
# 15.3.2009 10:52:00 reagovat

pojízdná kočka:

mám za to, že ani tvoje řešení "nepostihuje všechny znaky".
Nabízí se <?php iconv('utf-8','us-ascii//TRANSLIT',$retezec); ?>
# 1.8.2009 15:11:12 reagovat

pojízdná kočka:

oprava: sice postihuje, ale diakritiku maže (např: simptext("šťovíček") vrátí "ovek", z čehož nejde poznat ani zárodek původního slova)
takže stejně bych iconv tak jako tak doporučovala
# 1.8.2009 15:15:57 reagovat

Zdeněk:

Dobrý den,
chtěl jsem se zeptat, jestli někdo řešil problém nahrávání objemných dat na server. Když jsem pomocí .htaccess nastavil velikost 2GB (php_value upload_max_filesize 2000M, php_value post_max_size 2000M, php_value max_execution_time 2000, php_value max_input_time 2000), tak se soubory v pohodě nahrály. Ale když jsem nastavil víc, tak se nenahrály ani soubory např.10MB. Neví někdo, jak na to nebo neznáte lepší způsob nahrávání dat? díkec Zd.
# 12.4.2009 12:28:00 reagovat

ikona Jakub Vrána:

Bude to nejspíš narážet na velikost intu na dané platformě. Doporučil bych vyšší hodnoty vyzkoušet na 64bitové platformě.
# 14.4.2009 04:06:41 reagovat

Radim24:

Co takhle ošetřit kód proti spouštění scriptu mimo doménu? Útočník by mohl vygenerovat velké množství formulářů (z jiné domény, jiných domén) a spouštět ve stejnou dobu. Ikdyž odesílaný soubor nebude velký deset tisíc pokusů o odeslání ve stejnou dobu by již mohlo způsobit serveru problém...
# 4.7.2009 14:16:22 reagovat

ikona Jakub Vrána:

Jedná se o útok DoS, který s odesíláním souborů nijak nesouvisí. Velké množství požadavků může server zahltit, i když vedou na libovolnou stránku.
# 11.7.2009 17:39:29 reagovat

Tomáš:

Dobrý den, rád bych poprosil o radu.
Nevím proč, ale při pokusu o upload souboru mi server odmítá akceptovat soubory jiného formátu ne gif. Nevím o žádném omezení v php.ini, ale ani nikde.
Děkuji za radu.

Přidávám kousek kódu.

<form name=\'priloha\' action=\'$PHP_SELF\' method=\'post\' enctype=\'multipart/form-data\'>
<input type=\'hidden\' name=\'jmeno_souboru\' id=\'jmeno_souboru\'> <input name=\'x_soubor\' type=\'file\' size=\'50\' style=\'font-size:9pt\' onChange=\'javascript:top.document.priloha.jmeno_souboru.value=top.priloha.x_soubor.value\'>

# 14.7.2009 06:56:04 reagovat

Vložit příspěvek

Používejte diakritiku. Nelze používat HTML značky, ale URL budou převedeny na odkazy a PHP kód uzavřený do <?php ?> bude zvýrazněn. Pokud máte dotaz, který nesouvisí s článkem, zkuste raději diskusi o PHP, zde se odpovědi pravděpodobně nedočkáte.

Jméno: URL:

© 2005-2008 Jakub Vrána. Publikované texty můžete přetiskovat pouze se svolením autora. Ukázky kódu smíte používat s uvedením autora a URL tohoto webu bez dalších omezení Creative Commons. Můžeme si tykat. Skripty předpokládají nastavení: magic_quotes_gpc=Off, magic_quotes_runtime=Off, error_reporting=E_ALL & ~E_NOTICE a očekávají předchozí zavolání mysql_set_charset. Skripty by měly být funkční v PHP >= 4.3 a PHP >= 5.0.