Jump directly to content
English Deutsch

PHP Tips

Ich bin ein begeisterter Teilnehmer der stackoverflow Platform, einer Q & A Seite for Programmierer. Ab und zu stolpere ich über ein interessantes Problem (zumindest für mich), dann versuche ich dieses Problem zu lösen und publiziere einen kleinen Artikel dazu hier auf dieser Seite.

Sollten Sie Probleme, Fragen oder Anregungen zu den nachstehenden Funktionen haben, oder finden Sie sie einfach nützlich, so zögern Sie nicht, mir eine EMail zu schreiben an .

Übersicht


X-Frame-Options und Content-Security-Policy mit PHP benützen

Die meisten heutigen Browser können mithelfen, Ihre Seite vor schädlichen Angriffen zu schützen, nur muss man ihnen dies auch sagen. Eine fast überall unterstützte Methode ist das Setzen der X-Frame-Options. Wird diese Option gesetzt, erlaubt der Browser anderen Seiten nicht, Ihre eigene Seite in einem iframe anzuzeigen. Dies schützt vor Clickjacking Angriffen und sollte bei allen heiklen Seiten wie der Loginseite eingesetzt werden.

// Adds X-Frame-Options to HTTP header, so that page can only be shown in an iframe of the same site.
header('X-Frame-Options: SAMEORIGIN'); // FF 3.6.9+ Chrome 4.1+ IE 8+ Safari 4+ Opera 10.5+

Anwender welche mit einem aktuellen Browser arbeiten, profitieren automatisch davon, wenn eine Webseite eine Content-Security-Policy (CSP) im Header schickt. Mit einer CSP können Sie definieren woher dass JavaScript Code akzeptiert wird, welche Seiten dass Ihren Seite in einem iframe anzeigen dürfen und viele andere Dinge. Falls ein Browser CSP unterstützt, kann dies ein effektiver Schutz vor Cross-Site-Scripting sein. mehr…

Die Implementation in PHP ist sehr einfach, allerdings können sich Probleme mit Inline JavaScript ergeben. Den grössten Schutz erhalten Sie, wenn Sie sämtliches JavaScript in den HTML Files vermeiden und stattdessen in separate *.js Files auslagern. Für den Fall dass dies nicht möglich ist (bestehender Sourcecode), gibt es eine Option um inline-script zu erlauben.

// Adds the Content-Security-Policy to the HTTP header.
// JavaScript will be restricted to the same domain as the page itself.
header("Content-Security-Policy: default-src 'self'; script-src 'self';"); // FF 23+ Chrome 25+ Safari 7+ Opera 19+
header("X-Content-Security-Policy: default-src 'self'; script-src 'self';"); // IE 10+

Wenn Ihre Seite nur über HTTPS ausgeliefert wird (SSL für alle Seiten), dann ist es eine gute Idee den Strict-Transport-Security Header zu senden. Wenn ein Anwender zum ersten mal Ihre Seite besucht, speichert der Browser diesen Header. Sobald der Anwender Ihre Seite später wieder besucht, möglicherweise über eine unsichere WLAN Verbindung, erinnert sich der Browser daran diese Seite ausschliesslich über HTTPS zu verlangen. Dies würde dann vor SSL-strip schützen.

// Adds the HTTP Strict Transport Security (HSTS) (remember it for 1 year)
$isHttps = !empty($_SERVER['HTTPS']) && strtolower($_SERVER['HTTPS']) != 'off';
if ($isHttps)
{
  header('Strict-Transport-Security: max-age=31536000'); // FF 4 Chrome 4.0.211 Opera 12
}

Passwort Hashes mit bcrypt generieren

PHP 5.5 bietet eigene Funktionen password_hash() und password_verify() an, um das Generieren von BCrypt Passwort Hashes zu vereinfachen. Ich empfehle wärmstens dieses exzellente API zu verwenden, oder das dazugehörende Compatibility pack für ältere PHP Versionen. Die Verwendung ist sehr unkompliziert, der Hashwert kann in einem Datenbankfeld vom Typ varchar(255) gespeichert werden:

// Ein neues Password hashen, um es in der Datenbank zu speichern.
// Die Funktion generiert automatisch ein kryptographisch sicheres Salz.
$hashToStoreInDb = password_hash($_POST['password'], PASSWORD_DEFAULT);

// Prüfen of der Hash des eingegebenen Login Passwortes, mit dem gespeicherten Hash
// übereinstimmt. Das Salz und der Kostenfaktor wird aus $existingHashFromDb extrahiert.
$isPasswordCorrect = password_verify($_POST['password'], $existingHashFromDb);

// So kann ein Kostenfaktor angegeben werden (standardmässig 10). Das Erhöhen
// des Kostenfaktors um 1, verdoppelt die benötigte Zeit um den Hash zu berechnen.
$hashToStoreInDb = password_hash($_POST['password'], PASSWORD_BCRYPT, array("cost" => 11));

Das sollte die Aufgabe ganz gut lösen. Falls Sie mehr über das sichere Speichern von Passwörtern wissen wollen, werfen Sie doch einen Blick auf mein ausführliches Tutorial zum Hashen von Passwörtern.


Sichere Passwort-Reset Funktion

Im obigen Artikel haben wir gesehen, wie Passwörter sicher gespeichert werden können, dies führt aber gleich zum nächsten Problem. Die beste Passwort Hash Funktion ist wertlos, wenn das Zurücksetzen des Passworts nicht mit der gleichen Sorgfalt behandelt wird, wie das Speichern des Passworts.

Der übliche Weg besteht darin, dem registrierten Benutzer eine E-Mail mit einem Einmal-Token zu senden. Dieses Token wird in der Datenbank gespeichert und wenn der Benutzer den Link anklickt, prüfen wir das Token und erlauben dem Benutzer das Passwort neu zu setzen.

Stellen Sie sich nun vor, ein Angreifer kann die Datenbank Tabelle mit den Tokens lesen, zum Beispiel mittels SQL-injection. Er könnte nun für jede beliebige E-Mail Adresse einen Passwort-Reset beantragen, und da er das Token sehen kann, könnte er es benutzen um sein eigenes Passwort zu setzen. Eine ideale Passwort-Reset Funktion sollte all diese Anforderungen erfüllen:

Die folgende Klasse StoPasswordReset unterstützt Sie beim Erstellen solcher Reset-Links. Die generierten Tokens sind sehr stark (im Gegensatz zu schwachen Passwörtern), deshalb ist es sicher, diese als ungesalzene Hashes mit einem schnellen Algorithmus zu speichern.

Download: StoPasswordReset.zip.

https://www.example.com/forgot_password.php

// Zuerst prüfen wir, ob ein Benutzer mit dieser E-Mail registriert ist.
$userId = findUserByEmail($_POST['email']);
if (!is_null($userId))
{
  // Erstelle ein neues Token und den dazugehörigen Hash
  StoPasswordReset::generateToken($tokenForLink, $tokenHashForDatabase);

  // Hash zusammen mit der UserId und dem Erstellungsdatum speichern
  $creationDate = new DateTime();
  savePasswordResetToDatabase($tokenHashForDatabase, $userId, $creationDate);

  // Link mit dem Originaltoken verschicken
  $emailLink = 'https://www.example.com/reset_password.php?tok=' . $tokenForLink;
  sendPasswordResetEmail($emailLink);
}
https://www.example.com/reset_password.php

// Zuerst wird das Token formell validiert.
if (!isset($_GET['tok']) || !StoPasswordReset::isTokenValid($_GET['tok']))
  handleErrorAndExit('The token is invalid.');

// Suche nach dem Token-Hash in der Datenbank, erhalte UserId und Erstellungsdatum
$tokenHashFromLink = StoPasswordReset::calculateTokenHash($_GET['tok']);
if (!loadPasswordResetFromDatabase($tokenHashFromLink, $userId, $creationDate))
  handleErrorAndExit('The token does not exist or has already been used.');

// Prüfen ob das Token abgelaufen ist
if (StoPasswordReset::isTokenExpired($creationDate))
  handleErrorAndExit('The token has expired.');

// Das Formular Passwort-ändern anzeigen. Nach dem erfolgreichen Ändern
// des Passworts, das Token als gebraucht kennzeichnen.
letUserChangePassword($userId);

Um es gleich vorweg zu nehmen, jede Webseite die zwischen unsicheren HTTP und verschlüsselten HTTPS Seiten wechselt, ist zwangsläufig anfällig für SSL-Strip. Eine verschüsselte HTTPS Verbindung bleibt bei diesem Angriff zwar sicher, dem unachtsamen Benutzer wird mit SSL-Strip aber nur vorgegaukelt, dass er eine HTTPS Verbindung verwendet, tatsächlich arbeitet er mit einer HTTP Verbindung.

Da man von unerfahrenen Benutzern nicht erwarten kann, dass sie einen SSL-Strip Angriff erkennen können, sollte man unbedingt in Erwägung ziehen, die ganze Webseite auschliesslich mit HTTPS zu betreiben. Dies kann SSL-Strip zwar auch nicht in jedem Fall verhindern, trägt aber wesentlich dazu bei. Da das nachfolgende Verfahren auch für reine HTTPS Seiten noch Vorteile haben kann, habe ich mich entschlossen den Artikel stehen zu lassen.

Das Problem mit der Session-Id

Bei jedem Aufruf einer Seite muss eine Session-Id mitgeschickt werden, damit der Server den Benutzer wiedererkennt. Am besten überträgt man diese Id mit einem Cookie, da sie in der URL einen session-fixation Angriff allzu einfach macht. In der Session auf dem Server ist dann vermerkt, ob dieser Benutzer sich bereits eingeloggt hat oder nicht. Das Problem ist nun, dass ein Angreifer der diese Session-Id in Erfahrung bringen kann (wie auch immer er dies bewerkstelligt), sich als den Benutzer ausgeben kann, und folglich über dieselben Berechtigungen verfügt.

Um sensible Daten auszutauschen, braucht es auf jeden Fall eine verschlüsselte HTTPS Verbindung mit SSL, diese schützt vor man-in-the-middle Lauschangriffen. Webseiten welche zwischen HTTP und HTTPS Seiten hin und her wechseln, müssen sich nun entscheiden ob sie:

  1. Das Session-Cookie an HTTP und HTTPS Seiten schicken, und damit die Session-Id völlig ungeschützt übertragen, sobald eine HTTP Seite angefordert wird (sogar für Anfragen wie Bilder).
  2. Das Session-Cookie so konfigurieren, dass es nur an sichere HTTPS Seiten geschickt wird, und damit die Session verlieren, sobald eine HTTP Seite angezeigt wird.

Bei der Variante 1 können wir uns weitere Überlegungen sparen, sowas wie Sicherheit existiert dann nicht mehr. Der Variante 2 könnte man damit begegnen, dass die ganze Webseite ausschliesslich mit HTTPS betrieben wird. Dies sollte man wie schon erwähnt auch tun, heutige Server haben damit in der Regel keine Probleme mehr. In PHP könnte dann die Funktion session_set_cookie_params(...) aufgerufen, und der Parameter $secure auf true gestellt werden.

Das Authentication Cookie

Die Idee des Authentication Cookie ist, ein zweites Cookie zusätzlich zum Session-Cookie zu erstellen, sobald der Benuter seine Rechte erhöht (login). Dieses zweite Cookie wird so konfiguriert, dass es ausschliesslich an HTTPS Seiten zurückgeschickt wird. Natürlich muss die Login-Seite selber HTTPS benützen.

https://www.example.com/login.php

<?php
  session_start();
  // Regenerieren der Session-Id um session fixation zu erschweren
  session_regenerate_id(true);

  // Zufälligen code für das authentication cookie generieren und in der Session ablegen
  $authCode = md5(uniqid(mt_rand(), true));
  $_SESSION['authentication'] = $authCode;

  // Authentication Cookie erstellen, und ausschliesslich für HTTPS Seiten zulassen
  setcookie('authentication', $authCode, 0, '/', '', true, true);

  print('<h1>login</h1>');
  ...
?>

Nun können alle Seiten (HTTPS und HTTP) das unsichere Session-Cookie lesen, es dient nur noch zur Erhaltung der Session. Sämtliche Seiten mit sensiblen Daten können aber das sichere Authentication Cookie verlangen.

https://www.example.com/secret.php

<?php
  session_start();

  // Prüfen dass das Authentication Cookie existiert, und
  // dass es den gleichen Code enthält der in der Session abgelegt ist.
  $pageIsSecure = (!empty($_COOKIE['authentication']))
    && ($_COOKIE['authentication'] === $_SESSION['authentication']);

  if (!$pageIsSecure)
  {
    // Seite nicht anzeigen, redirect zur Login-Seite
  }

  ...
?>

Ein Angreifer könnte zwar das Session-Cookie manipulieren, er hat aber niemals Zugriff auf das Authentication Cookie, welches die Authentifikation übernimmt. Nur der Benutzer, welcher das Passwort eingegeben hat, kann das Authentication Cookie besitzen, es wird ausschliesslich über verschlüsselte HTTPS Verbindungen geschickt.

Indem wir die zwei Verantwortlichkeiten "Erhalten der Session" und "Authentifikation" trennen, können wir das System etwas robuster machen. Es gibt viele Wege das Session-Cookie anzugreifen (php.ini, .htaccess, PHP Code, Browsereinstellungen, Id in der URL, ...), mit der Trennung laufen solche Angriffe ins leere.


UTF-8 für PHP und MySQL

Different character encodings can cause headaches, that's something every developer who needs to make localized software knows for sure. Maybe your page shows UTF-8, where as the database delivers iso-8859-1, then you get these odd hieroglyphics, or even worse the user can possibly not even login anymore.

That's why Unicode was developed. I can't go into the details of Unicode here, but the goal is to represent the characters of all known languages, and other symbols as well (see this font character map). One of the most commonly used encodings for Unicode is UTF-8, because it is very compact (only 1 byte for common characters) and is understood by all todays web browsers.

UTF-8 in a PHP page

First the HTML/PHP page itself should be stored in the UTF-8 file format. That means you need an editor which supports Unicode, fortunately most IDE's are able to do this. Normal characters are then stored with 1 byte, special characters need 3-4 bytes, but the editor displays the typed-in character. That means, no HTML-entities like &Auml; anymore(!), what you see is what you typed.

You should care that the editor does not store the BOM header, this header is sometimes stored at the begin of the file with 3 bytes . The editor will hide them, so if you are not sure if your file contains these characters, you can either use a non interpreting editor (hex editor), or this wonderful online W3C checker. The BOM header is treated as output by PHP, and this can cause nasty Cannot modify header information - headers already sent errors.

Then you should add the encoding declaration to the top of the head element of your HTML/PHP page, right after the opening <head> tag.

HTML 4:  <meta http-equiv="Content-type" content="text/html;charset=UTF-8">
XHTML:   <meta http-equiv="Content-type" content="text/html;charset=UTF-8" />
HTML 5:  <meta charset="UTF-8">
see more…

UTF-8 in MySQL

There is a simple way to tell the database it should deliver UTF-8 encoded strings, so they can be used in an UTF-8 web page. Instead of fiddling with the configurations of MySQL, just tell your connection object, which character-set you expect, the database does the rest for you.

Queries will automatically return UTF-8 encoded strings, ajax results can be used without cumbersome conversions, and other applications can request different encodings if necessary.

// tells the mysqli connection to deliver UTF-8 encoded strings.
$db = new mysqli($dbHost, $dbUser, $dbPassword, $dbName);
$db->set_charset('utf8mb4');

// tells the pdo connection to deliver UTF-8 encoded strings.
$dsn = "mysql:host=$dbHost;dbname=$dbName;charset=utf8mb4";
$db = new PDO($dsn, $dbUser, $dbPassword);

// tells the mysql connection to deliver UTF-8 encoded strings.
$db = mysql_connect($dbHost, $dbUser, $dbPassword);
mysql_set_charset('utf8mb4', $db);

To get more information about the charset of your database, you can make a query like that:

SHOW VARIABLES LIKE "character%"

Gleich oder nicht gleich

Was ich in PHP am meisten vermisse, sind die Vorteile einer streng typisierten Sprache. Dynamic typing mag seine Vorteile haben, aber hätten Sie gedacht, dass folgende Vergleiche immer true zurückliefern? PHP macht's möglich...

Natürlich kann man den === Operator benutzen, um Werte und ihren Typ zu prüfen. Da PHP keine grosse Unterstützung bietet, die Typen zu kontrollieren, hab ich dies nicht besonders hilfreich gefunden. Das war der Moment, an dem ich angefangen habe eine Library zu bauen, die das abdeckt, was ich mir in PHP sowieso gewünscht hätte.

/**
 * Prüft ob zwei Werte gleich sind. Im Gegensatz zum == Operator,
 * werden die Werte als ungleich behandelt, wenn:
 * - ein Wert null ist und der andere nicht, oder
 * - ein Wert ein leerer String ist und der andere nicht
 * Dies hilft das seltsame Verhalten von PHP's type juggling zu vermeiden,
 * alle diese Ausdrücke würden true zurückliefern:
 * 'abc' == 0; 0 == null; '' == null; 1 == '1y?z';
 * @param mixed $value1
 * @param mixed $value2
 * @return boolean True wenn Werte gleich sind, sonst false.
 */
function sto_equals($value1, $value2)
{
  // Identisch in Wert und Typ
  if ($value1 === $value2)
    $result = true;
  // Einer ist null, der andere nicht
  else if (is_null($value1) || is_null($value2))
    $result = false;
  // Einer ist ein leerer String, der andere nicht
  else if (($value1 === '') || ($value2 === ''))
    $result = false;
  // Identisch in Wert, unterschiedlich im Typ
  else
  {
    $result = ($value1 == $value2);
    // Auf falsche implizite string Konvertierung prüfen, beim Vergleich eines
    // strings mit einem numerischen Typ. Nur gültige numerische Werte erlauben.
    if ($result)
    {
      $isNumericType1 = is_int($value1) || is_float($value1);
      $isNumericType2 = is_int($value2) || is_float($value2);
      $isStringType1 = is_string($value1);
      $isStringType2 = is_string($value2);
      if ($isNumericType1 && $isStringType2)
        $result = is_numeric($value2);
      else if ($isNumericType2 && $isStringType1)
        $result = is_numeric($value1);
    }
  }
  return $result;
}

Funktionen mit gemischtem Rückgabetyp vermeiden

Leider ist es in PHP eine verbreitete Praxis, dass Funktionen unterschiedliche Typen zurückgeben, je nachdem, of eine Funktion erfolgreich war oder nicht.

// Diese Art von gemischten Rückgbetypen (boolean oder string),
// kann zu unzuverlässigem Code führen!
function precariousCheckEmail($input)
{
  $isValid = filter_var($input, FILTER_VALIDATE_EMAIL);
  if ($isValid)
    return true;
  else
    return 'E-Mail address is invalid.';
}

Auf den ersten Blick sieht dies ganz praktisch aus, aber es ist einfacher hier einen hässlichen Fehler zu machen, als die Funktion korrekt aufzurufen:

$result = precariousCheckEmail('nonsense');
if ($result === true)
  print('OK');
else
  print($result); // -> Meldung wird ausgegeben

Also wo liegt das Problem? Jeder der diese Funktion benutzen will, braucht Vorwissen, das er nur erhält indem er den Code, oder die (gute) Dokumentation studiert.

// Alle diese Prüfungen akzeptieren die E-Mail fälschlicherweise als gültig!
$result = precariousCheckEmail('nonsense');
if ($result == true)
  print('OK'); // -> OK wird ausgegeben

if ($result)
  print('OK'); // -> OK wird ausgegeben

if ($result === false)
  print($result);
else
  print('OK'); // -> OK wird ausgegeben

if ($result == false)
  print($result);
else
  print('OK'); // -> OK wird ausgegeben

Statt nur zu zeigen was falsch ist, möchte ich auch eine bessere Alternative aufzeigen. Das nachfolgende Beispiel übergibt einen zusätzlichen Parameter by-reference. Der aufrufende Code ist sehr gut lesbar und es ist fast unmöglich die Funktion falsch zu verwenden.

// Diese Funktion mit einem Rückgabewert (boolean) und einem
// by-reference übergebenen Parameter (string) ist robust.
function robustCheckEmail($input, &$errorMessage)
{
  $isValid = filter_var($input, FILTER_VALIDATE_EMAIL);
  $errorMessage = '';
  if (!$isValid)
    $errorMessage = 'E-Mail address is invalid.';
  return $isValid;
}

if (robustCheckEmail('nonsense', $error))
  print('OK');
else
  print($error);

Die Distanz zweier Punkte auf der Erde berechnen

Um die Distanz zweier Punkte auf der Erdoberfläche zu berechnen, kann die Haversine Formel verwendet werden. Diese Formel ist auch für kleine Distanzen stabil, im Hinblick auf Rundungsfehler.

/**
 * Calculates the great-circle distance between two points, with
 * the Haversine formula.
 * @param float $latitudeFrom Latitude of start point in [deg decimal]
 * @param float $longitudeFrom Longitude of start point in [deg decimal]
 * @param float $latitudeTo Latitude of target point in [deg decimal]
 * @param float $longitudeTo Longitude of target point in [deg decimal]
 * @param float $earthRadius Mean earth radius in [m]
 * @return float Distance between points in [m] (same as earthRadius)
 */
function haversineGreatCircleDistance(
  $latitudeFrom, $longitudeFrom, $latitudeTo, $longitudeTo, $earthRadius = 6371000)
{
  // convert from degrees to radians
  $latFrom = deg2rad($latitudeFrom);
  $lonFrom = deg2rad($longitudeFrom);
  $latTo = deg2rad($latitudeTo);
  $lonTo = deg2rad($longitudeTo);

  $latDelta = $latTo - $latFrom;
  $lonDelta = $lonTo - $lonFrom;

  $angle = 2 * asin(sqrt(pow(sin($latDelta / 2), 2) +
    cos($latFrom) * cos($latTo) * pow(sin($lonDelta / 2), 2)));
  return $angle * $earthRadius;
}

Eine Alternative zur Haversine Formel ist die Vincenty Formel, sie ist ein kleines bisschen komplexer, leidet aber nicht unter der Schwäche von Rundungsfehlern bei sich genau gegenüberliegenden Punkten.

/**
 * Calculates the great-circle distance between two points, with
 * the Vincenty formula.
 * @param float $latitudeFrom Latitude of start point in [deg decimal]
 * @param float $longitudeFrom Longitude of start point in [deg decimal]
 * @param float $latitudeTo Latitude of target point in [deg decimal]
 * @param float $longitudeTo Longitude of target point in [deg decimal]
 * @param float $earthRadius Mean earth radius in [m]
 * @return float Distance between points in [m] (same as earthRadius)
 */
function vincentyGreatCircleDistance(
  $latitudeFrom, $longitudeFrom, $latitudeTo, $longitudeTo, $earthRadius = 6371000)
{
  // convert from degrees to radians
  $latFrom = deg2rad($latitudeFrom);
  $lonFrom = deg2rad($longitudeFrom);
  $latTo = deg2rad($latitudeTo);
  $lonTo = deg2rad($longitudeTo);

  $lonDelta = $lonTo - $lonFrom;
  $a = pow(cos($latTo) * sin($lonDelta), 2) +
    pow(cos($latFrom) * sin($latTo) - sin($latFrom) * cos($latTo) * cos($lonDelta), 2);
  $b = sin($latFrom) * sin($latTo) + cos($latFrom) * cos($latTo) * cos($lonDelta);

  $angle = atan2(sqrt($a), $b);
  return $angle * $earthRadius;
}