Kirjautuminen

Haku

Tehtävät

Kilpailu

Ohjelmoi tekoäly!
Aikaa on 30.6. saakka.

Koodivinkit: PHP: Tiedoston lähetys (upload)

Kirjoittaja: Metabolix

Kirjoitettu: 09.04.2011 – 19.03.2019

Tagit: käyttöliittymä, ohjelmointitavat, tietoturva, web

Tiedostojen lähettäminen selaimella palvelimelle on asia, jossa ei ole varaa toheloida. Pienikin virhe tiedoston tarkistuksessa voi aiheuttaa paljon harmia: tärkeitä tietoja voi hävitä, tai krakkeri voi päästä kirjoittamaan palvelimelle haitallista koodia. Tässä vinkissä käydään lyhyesti läpi tärkeimmät kohdat tiedostojen turvallisesta lähettämisestä, ja lisäksi vinkin koodi on suunniteltu niin, että se on myös helppo kopioida omaan käyttöön.

Lomake

Katsotaanpa ensin, miltä tiedoston lähetykseen käytettävä HTML-lomake näyttää. Alussa on tuttu form-tagi, mutta siinä täytyy muistaa oikea lähetystapa POST sekä oikea enkoodaus multipart/form-data, jota ilman tiedostot eivät suostu lähtemään. Itse tiedostoa varten on tavallinen input-tagi, jonka tyyppinä on file. Lomakkeella voi olla myös muita kenttiä sekä useampikin tiedosto, mutta liian suuren datamäärän lähettäminen tyssää PHP:n asetuksiin.

<!-- Huomaa erityisesti POST ja multipart/form-data! -->
<form action="lahetys.php" method="POST" enctype="multipart/form-data">
	<!-- Tiedostot ovat file-tyypin inputeissa. -->
	<p>Tiedosto: <input type="file" name="tiedosto" /></p>
	<p><button type="submit">Anna palaa!</button></p>
</form>

Funktioita

Kun tiedosto lähetetään palvelimelle, se tallentuu johonkin PHP:n asetuksissa määrättyyn paikkaan ja katoaa itsestään, kun PHP-skriptin suoritus päättyy. PHP-skriptissä lähetettyjen tiedostojen tiedot löytyvät $_FILES-taulukosta: siellä kerrotaan tiedoston alkuperäinen nimi, selaimen ilmoittama tyyppi (epäluotettava!), tiedoston koko, väliaikainen sijainti sekä mahdollisen virheen koodinumero.

Tiedostolähetyksen käsittelyssä on siis muutama vaihe: ensin täytyy tarkistaa, että tiedosto on edes lähetetty; sitten täytyy tarkistaa, sattuiko matkalla virhe; ja lopuksi täytyy kopioida tiedosto lopulliselle paikalleen ja vielä tässäkin vaiheessa tarkistaa, että kaikki sujuu hyvin. Seuraavassa koodissa useimmat tarkistukset ovat funktiossa upload_tarkista, paitsi kopioinnin onnistumisen tarkistus, joka on funktiossa upload_tallenna. Lisäksi funktiolla upload_lahetetty voi tarkistaa, onko tiedostoa lähetty ensinkään vai jäikö kenttä tyhjäksi.

Suurin huolenaihe tiedostojen kohdalla ovat tiedostonimet. Käyttäjä voi lähettää minkä tahansa tiedoston, PHP ei pysty automaattisesti tarkistamaan tiedoston sisältöä, eikä PHP myöskään kysele mitään, vaikka tiedosto tallennettaisiin toisen päälle. Tiedostonimen osalta täytyy siis varmistaa muutama asia: tiedostoa ei saa olla ennestään olemassa (ellei ole erikseen tarkoitus ylikirjoittaa vanhaa), ja tiedoston päätteen on oltava turvallinen. Useimmilla palvelimilla esimerkiksi .jpg-päätteinen tiedosto on aina turvallinen, vaikka sisältö olisi PHP-koodia; sen sijaan .php-päätteinen tiedosto on selvästi vaarallinen, ellei se ole ylläpitäjän tarkoituksella lähettämä.

Seuraavassa koodissa on tätä varten erikseen funktio upload_tallenna_suoraan, joka on tarkoitettu ylläpitokäyttöön ja tallentaa tiedoston vanhalla nimellään, ja funktio upload_tallenna_turvallinen, joka tarkistaa ja käsittelee tiedostonimen, lisää alkuun yksilöllisen tunnisteen ja lisää tarvittaessa loppuun turvallisen tiedostopäätteen.

upload.php:

<?php
// Paikkaillaan PHP:n vanhempien versioiden puutteita; nykyversioilla nämä eivät ole tarpeen.
defined("UPLOAD_ERR_OK")         || define("UPLOAD_ERR_OK",         0);
defined("UPLOAD_ERR_INI_SIZE")   || define("UPLOAD_ERR_INI_SIZE",   1);
defined("UPLOAD_ERR_FORM_SIZE")  || define("UPLOAD_ERR_FORM_SIZE",  2);
defined("UPLOAD_ERR_PARTIAL")    || define("UPLOAD_ERR_PARTIAL",    3);
defined("UPLOAD_ERR_NO_FILE")    || define("UPLOAD_ERR_NO_FILE",    4);
defined("UPLOAD_ERR_NO_TMP_DIR") || define("UPLOAD_ERR_NO_TMP_DIR", 5);
defined("UPLOAD_ERR_CANT_WRITE") || define("UPLOAD_ERR_CANT_WRITE", 6);
defined("UPLOAD_ERR_EXTENSION")  || define("UPLOAD_ERR_EXTENSION",  7);
if (!isset($_FILES) && isset($HTTP_POST_FILES)) {
	$_FILES =& $HTTP_POST_FILES;
}

// Tehdään vielä oma poikkeustyyppi virheitä varten.
class UploadException extends Exception {
	// Luokan vanha sisältö kelpaa meille.
}

/**
 * Tarkistaa, että tiedosto on edes yritetty lähettää.
 *
 * @param $input    input-tagin nimi
 * @return boolean  kertoo, onko tiedostoa lähetetty
 */
function upload_lahetetty($input) {
	if (empty($_FILES[$input]) || $_FILES[$input]["error"] == UPLOAD_ERR_NO_FILE) {
		return false;
	}
	return true;
}

/**
 * Tarkistaa, että tiedosto on lähetetty onnistuneesti.
 * Virhetilanteissa heitetään poikkeus (UploadException).
 *
 * @param $input        input-tagin nimi
 * @param $maksimikoko  suurin sallittu koko tavuina
 * @return string       palauttaa tiedoston alkuperäisen nimen
 */
function upload_tarkista($input, $maksimikoko = null) {
	// Tarkistetaan, että tiedosto on edes yritetty lähettää.
	if (empty($_FILES[$input])) {
		throw new UploadException("Lomakkeella ei ole tiedostoa '{$input}'!");
	}

	// Tarkistetaan tiedoston koko.
	if ($maksimikoko !== null) {
		if ($_FILES[$input]["size"] > $maksimikoko) {
			$_FILES[$input]["error"] = UPLOAD_ERR_FORM_SIZE;
		}
	}

	// Tarkistetaan lähetyksen virhetilanteet.
	// HUOMIO: oikeassa käytössä erilaiset ilmoitukset kannattaisi välittää
	// eri luokissa, jotta esim. käyttäjän virhe (liian suuri tiedosto)
	// olisi mahdollista erottaa palvelimen virheestä (tila lopussa).
	switch ($_FILES[$input]["error"]) {
		case UPLOAD_ERR_OK:
			break;
		case UPLOAD_ERR_INI_SIZE:
		case UPLOAD_ERR_FORM_SIZE:
			throw new UploadException("Tiedosto '{$input}' on sallittua suurempi!");
		case UPLOAD_ERR_PARTIAL:
			throw new UploadException("Tiedoston '{$input}' lataus keskeytyi!");
		case UPLOAD_ERR_NO_FILE:
			throw new UploadException("Tiedosto '{$input}' puuttuu!");
		case UPLOAD_ERR_NO_TMP_DIR:
			throw new UploadException("Palvelimella ei ole paikkaa tiedostoille!");
		case UPLOAD_ERR_CANT_WRITE:
			throw new UploadException("Tiedoston '{$input}' tallentaminen palvelimelle ei onnistunut!");
		case UPLOAD_ERR_EXTENSION:
			throw new UploadException("Jokin PHP:n laajennos esti tiedoston latauksen!");
		default:
			throw new UploadException("Tuntematon virhe tiedoston '{$input}' latauksessa!");
	}
	if (!is_uploaded_file($_FILES[$input]["tmp_name"])) {
		throw new UploadException("PHP:n mukaan tiedoston tmp_name on viallinen!");
	}
	return basename($_FILES[$input]["name"]);
}

/**
 * Hakee lähetetyn tiedoston muistiin.
 *
 * @param $input  input-tagin nimi
 * @return array  palauttaa taulukossa tiedoston nimen ja sisällön
 */
function upload_hae($input) {
	$nimi = upload_tarkista($input);

	$data = @file_get_contents($_FILES[$input]["tmp_name"]);
	if ($data === false) {
		$virhe = error_get_last();
		throw new UploadException("Virhe tiedoston '{$input}' lukemisessa: {$virhe["message"]}!");
	}
	return array($nimi, $data);
}

/**
 * Tallentaa lähetetyn tiedoston haluttuun paikkaan.
 *
 * @param $input   input-tagin nimi
 * @param $kohde   uusi tiedostonimi
 * @return string  palauttaa tiedoston alkuperäisen nimen
 */
function upload_tallenna($input, $kohde) {
	$nimi = upload_tarkista($input);

	// Tarkistetaan kirjoitusoikeus.
	if (!is_writeable(dirname($kohde)) || (file_exists($kohde) && !is_writeable($kohde))) {
		throw new UploadException("Virhe tiedoston '{$input}' kopioinnissa paikkaan '{$kohde}', ei kirjoitusoikeutta!");
	}

	// Yritetään kopioida tiedosto paikalleen.
	if (!@move_uploaded_file($_FILES[$input]["tmp_name"], $kohde)) {
		$virhe = error_get_last();
		throw new UploadException("Virhe tiedoston '{$input}' kopioinnissa paikkaan '{$kohde}': {$virhe["message"]}!");
	}
	return $nimi;
}

/**
 * Tämä funktio tallentaa lähetetyn tiedoston sillä nimellä, jolla se lähetettiin.
 * TÄMÄ ON VAARALLISTA, koska tiedosto voi sisältää vaikka haitallista PHP-koodia.
 * ÄLÄ KOSKAAN käytä tätä toimintoa muualla kuin ehkä ylläpitäjän työkaluissa!
 *
 * @param $input      input-tagin nimi
 * @param $hakemisto  hakemisto, joka merkitään uuden nimen alkuun
 * @return string     palauttaa tiedoston alkuperäisen nimen
 */
function upload_tallenna_suoraan($input, $hakemisto = ".") {
	$nimi = upload_tarkista($input);
	return upload_tallenna($input, $hakemisto. "/". $nimi);
}

/**
 * Tallentaa lähetetyn tiedoston hallitusti uudella nimellä,
 * joka muodostetaan alkuperäisestä nimestä ja satunnaisosasta.
 * Myös tiedostonimen pääte tarkistetaan.
 *
 * @param $input       input-tagin nimi
 * @param $hakemisto   hakemisto, joka merkitään uuden nimen alkuun
 * @param $paatteet    tiedoston sallitut päätteet
 * @param $turvapaate  tiedostolle laitettava pääte, jos vanha pääte ei ole sallittu
 * @return array       palauttaa taulukossa tiedoston vanhan ja uuden nimen
 */
function upload_tallenna_turvallinen($input, $hakemisto = ".", $paatteet = array(".jpg", ".gif", ".png", ".txt", ".dat"), $turvapaate = false) {
	$nimi = upload_tarkista($input);

	// Katsotaan, onko annetussa taulukossa tiedoston pääte.
	// Jos ei ole, käytetään annettua päätettä ($turvapaate).
	if (is_array($paatteet)) foreach ($paatteet as $paate) {
		if (substr($nimi, -strlen($paate)) == $paate) {
			$turvapaate = $paate;
			break;
		}
	}

	// Jos $turvapaate puuttuu (eikä muuta löytynyt taulukosta), hylätään tiedosto.
	if ($turvapaate === false) {
		throw new UploadException("Tiedoston '{$input}' nimi ({$nimi}) ei kelpaa!");
	}

	// Luodaan tiedostolle turvallinen nimi ja tallennetaan tiedosto.
	$nimi2 = substr(preg_replace("/[^-_.0-9A-Za-z]/", "", $nimi), 0, 32);
	if (strlen($turvapaate) && substr($nimi2, -strlen($turvapaate)) !== $paate) {
		$nimi2 .= $paate;
	}
	while (true) {
		$kohde = $hakemisto. "/upload_". uniqid("", true). "_". $nimi2;
		if (!file_exists($kohde)) {
			upload_tallenna($input, $kohde);
			return array($nimi, $kohde);
		}
	}
}

Lyhyitä esimerkkejä

Alla on lomake ja sen käsittelevä PHP-koodi, joka esittelee lyhyesti ylläolevien funktioiden käyttöä.

lomake.html:

<!DOCTYPE html>
<html>
<head>
	<title>Upload-testisivu</title>
</head>
<body>
	<!-- Huomaa erityisesti POST ja multipart/form-data! -->
	<form action="lahetys.php" method="POST" enctype="multipart/form-data">
		<!-- Tiedostot ovat file-tyypin inputeissa. -->
		<p>Tiedosto: <input type="file" name="tiedosto" /></p>
		<p>
			Esimerkki:
			<select name="esimerkki">
				<option value="tarkistus">Tarkistus</option>
				<option value="kuva">Kuvan tallennus</option>
				<option value="kuva-tark">Kuvan tunnistus ja tallennus</option>
				<option value="yllapito">Vaarallinen yll&auml;pitotoiminto</option>
			</select>
		</p>
		<p><button type="submit">Anna palaa!</button></p>
	</form>
</body>
</html>

lahetys.php:

<?php
// Käyttöesimerkkejä.
// Tämä käsittelysivu palauttaa virheilmoitukset käyttäjälle tekstinä.
// Laitetaan kaikki mahdolliset ilmoitukset näkyviin.
header("Content-Type: text/plain");
ini_set("error_reporting", E_ALL | E_STRICT);
ini_set("display_errors", 1);

// Otetaan funktiot mukaan.
require_once("upload.php");


switch (@$_POST["esimerkki"]) {
	case "tarkistus":
		// Esimerkki: Tarkistetaan, että tiedosto on lähetetty ja että se on kooltaan
		// enintään 1,7 megatavua (1782579 tavua). Käsitellään myös virheilmoitus;
		// oikeasti virheen sattuessa pitäisi tulostaa hieno virhesivu.
		try {
			$nimi = upload_tarkista("tiedosto", 1.7 * 1024 * 1024);
		} catch (UploadException $e) {
			die($e->getMessage());
		}
		die("Tarkistus onnistui. Tiedoston nimeksi ilmoitettiin {$nimi}.\n");


	case "kuva":
		// Esimerkki: Tarkistetaan tiedoston pääte ja yritetään tallentaa se palvelimelle.
		// Funktion toinen parametri on tallennushakemisto, "." tarkoittaa tätä hakemistoa.
		// HUOMIO! Virhettä ei tässä erikseen käsitellä, joten tulos voi olla ruma!
		list($vanha, $nimi) = upload_tallenna_turvallinen("tiedosto", ".", array(".png", ".jpg", ".jpeg", ".gif"));
		die("Tallennettiin kuva, nimi on {$nimi}, vanha nimi oli {$vanha}.\n");


	case "kuva-tark":
		// Esimerkki: Yritetään tunnistaa kuvan tyyppi ennen tallentamista.
		function upload_tallenna_kuva($input, $hakemisto) {
			list($nimi, $data) = upload_hae($input);
			if (substr($data, 0, 3) == "GIF") {
				return upload_tallenna_turvallinen($input, $hakemisto, null, ".gif");
			}
			if (substr($data, 0, 8) == "\x89PNG\r\n\x1A\x0A") {
				return upload_tallenna_turvallinen($input, $hakemisto, null, ".png");
			}
			if (substr($data, 0, 2) == "\xff\d8") {
				return upload_tallenna_turvallinen($input, $hakemisto, array(".jpeg", ".jpg"), ".jpg");
			}
			throw new UploadException("Kuvan {$nimi} tyyppi ei kelpaa!");
		}
		list($nimi, $kohde) = upload_tallenna_kuva("tiedosto", ".");
		die("Tallennettiin kuva, nimi on {$kohde}, vanha nimi oli {$nimi}.\n");


	case "yllapito":
		// Esimerkki: Tallennetaan tiedosto omalla nimellään.
		// TÄMÄ ON VAARALLISTA! Käytä tätä vain ylläpitäjän hallintapaneelissa!
		$nimi = upload_tallenna_suoraan("tiedosto");
		die("Tallennettiin tiedosto {$nimi}.\n");


	default:
		die("Toiminto puuttuu tai on virheellinen!");
}

Kirjoita kommentti

Muista lukea kirjoitusohjeet.
Tietoa sivustosta