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í Bezpečnost PHP aplikací.

Jakub Vrána, Dobře míněné rady, 28.12.2005, diskuse: 42 (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..

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... :(

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)

Petr:

jj, taky sem to řešil dlouho, na Opeře to přitom běhalo jak má

migon:

a v com je problem  pridat jednu polozku do toho pole?
navic image/pjpeg je dost znama vec.... ale mlcim

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 ? ;-)

Ditto:

Javascript sa da desne lahko obist...

ikona Jakub Vrána OpenID:

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.

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.

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.

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

ikona Jakub Vrána OpenID:

Díky za postřeh, opravil jsem to.

Vojtěch Bašta:

Nevypadá to

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.

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" :)

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

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.

xpfighter:

jeho vymazanie bolo myslene tak ze sa zmaze ak je to iny typ ako povoleny. bavime sa len o obrazkoch

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";
}
?>

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)

liska11:

muzu se zeptat je potom jedno jak se jmenuje promenna toho ve formulari kde se nahrava soubor ?

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.

ikona Jakub Vrána OpenID:

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.

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.

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");

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;
}
?>

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); ?>

Ted:

a neměl byste prosim vás někdo radu co dělat, když mi tahle funkce mění znaky s interpunkcí na otazníky "?" ?
před nedávnem mi z ničeho nic přestalo fungovat i strtr, a teď ať hledám, jak hledám, nic mi nefunguje..

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

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.

ikona Jakub Vrána OpenID:

Bude to nejspíš narážet na velikost intu na dané platformě. Doporučil bych vyšší hodnoty vyzkoušet na 64bitové platformě.

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...

ikona Jakub Vrána OpenID:

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.

samueltos:

Jestli umíš v PHP, tak si do toho vlož CAPTCHU, ne?

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\'>

Rat:

super, ale matoucí je ta cesta jak tam zůstalo to $id :)) nemohl jsem přijít na to že to mám prostě a jednoduše vymrsknout :-D

Bendík:

Jakube,
opět děkuji za inspiraci!

Jirka:

V XML mi je poslána url adresa na obrázek, můžete mi poradit jak ošetřit a bezpečně stáhnout (cizí server) ?

Kuba:

Pěkný den, mám jeden problém:
Lze nastavit u input type file hodnotu value? při opětovném načtení formuláře po kontrole, kde proběhla chyba? Jde mi o to, aby se po kontrole nemusel soubor vybírat znovu, ale byl vybrán, respektive v poli bylo totéž co při prvním výběru (třeba i nic). Jinde jsem se dočetl, že to nejde. Není ale nějaký fígl?

Děkuji předem za odpověď. Kuba

ikona Jakub Vrána OpenID:

Nejde to, protože by se tím daly krást soubory uživateli. Stačilo by podstrčit libovolnou hodnotu do schovaného pole a neštěstí by bylo na světě.

Dá se to vyřešit tak, že se soubor uloží už při prvním odeslání formuláře a uživatel se o tom informuje. Pokud druhý krok nedokončí, je vhodné takto uložený soubor časem smazat.

Martin Lonský:

No, leda vyvolat kliknutí automaticky $(ele).click() a přinutit uživatele rovnou vybrat soubor znovu...trochu otravné

Patrik:

s dovolenim mensia uprava :), kedze cez getimagesize prejde aj fejkovy IMG + exif_imagetype je rychlejsie nez getimagesize
navyse neviem preco je tam premenna $id ked nikde nebola definovana :)

<?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 (!
in_array(substr(image_type_to_extension(exif_imagetype($_FILES["obrazek"]["tmp_name"])), 1), $koncovky)) {
   
$chyba = "Typ obrázku musí být jeden z: " . implode(", ", $koncovky) . ".\n"
} else {
   
move_uploaded_file($_FILES["obrazek"]["tmp_name"], "data/" . $_FILES["obrazek"]["name"]);
}
?>

Diskuse je zrušena z důvodu spamu.

avatar © 2005-2024 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.