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.

Eines meiner Ziele ist es, andere zu ermutigen sauberen Code zu schreiben, und dazu muss man das Problem verstehen. Im Gegensatz zur weit verbreiteten Meinung, dass man das Rad nicht neu erfinden solle, sage ich immer erfinde das Rad neu, und sobald man das Problem gut genug kennt, kann man nach einer etablierten Library ausschau halten.

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


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

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 Ä 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 one needs to look at the file with a non interpreting editor (hex editor) to see them. The BOM header is treated as output by PHP, and this can cause nasty Cannot modify header information - headers already sent errors.

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.

$db = new mysqli($dbHost, $dbUser, $dbPw, $dbName);

// tell the db to deliver UTF-8 encoded strings.
$db->set_charset("utf8");

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

Most browsers today will help protecting your site from malicious attacks, but you have to tell them they should. A widely supported method is setting the X-Frame-Options. Setting this option, the browser will not allow other sites to display your page inside an iframe. This protects against Clickjacking attacks and should be used on all sensitive pages like the login page.

/**
 * Adds the X-Frame-Options to the HTTP header.
 */
header('X-Frame-Options: DENY'); // or SAMEORIGIN

People using Firefox 4 and later, will benefit automatically, when a website sends a Content-Security-Policy (CSP) within the HTTP header. With a CSP you can specify from which locations you accept javascript, which sites are allowed to show your page inside an iframe and many other things. If a browser supports CSP, this can be an effective protection against Cross-Site-Scripting.

The implementation in PHP is incredible easy, though some problems may arise from inline JavaScript. But let's see an example:

/**
 * Adds the Content-Security-Policy to the HTTP header. JavaScript will
 * be restricted to the same domain as the page itself.
 * @param bool $allowInlineScript Set parameter to true, if you want to
 *   allow javascript contained in the page itself (no separate *.js file).
 */
function sto_setContentSecurityPolicy($allowInlineScript)
{
  if ($allowInlineScript)
    header("X-Content-Security-Policy: allow 'self'; options inline-script");
  else
    header("X-Content-Security-Policy: allow 'self'");
}

Passwort Hashes mit bcrypt generieren

Es gibt allgemein bekannte best-practices um Passwörter (nicht) in einer Datenbank zu speichern. Ein sehr guter und ausführlicher Artikel ist hier zu finden, eine kurze Übersicht könnte wie folgt aussehen:

Der bcrypt Hash Algorithmus wurde dazu entwickelt, eben diese Kriterien zu erfüllen. Er verfügt über einen Kostenparameter, welcher die benötigte Rechenzeit steuert. Der Kostenparameter wird zusammen mit dem Salt im resultierenden Hashwert gespeichert.

/**
 * Generiert einen bcrypt Hashwert eines Passworts, welcher in einer Datenbank
 * gespeichert werden kann. Die Funktion benutzt die PHP Funktion crypt() und
 * benötigt den CRYPT_BLOWFISH Hash-Algorithmus (PHP 5.3).
 * @param string $password Zu hashendes Passwort.
 * @param int $cost Logarithmus (base-2) der Anzahl Iterationen. Dieser Wert muss
 *   im Bereich von 4-31 liegen. Wird der Wert um 1 erhöht, verdoppelt dies die
 *   benötigte Rechenzeit.
 * @return string Hashwert des Passworts, mit einer Länge von 60 Zeichen.
 *   Ein zufälliges Salt und der Kostenparameter sind enthalten.
 */
function sto_hashBcrypt($password, $cost=8)
{
  if (CRYPT_BLOWFISH != 1) die('The CRYPT_BLOWFISH algorithm is required (PHP 5.3).');
  $password = trim($password);

  // Bereich des Kostenparameters sicherstellen
  if ($cost < 4 || $cost > 31)
    $cost = 8;

  // Ein zufälliges 22 Zeichen Salt erstellen
  $saltAlphabet = './0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
  $salt = sto_generateRandomString(22, $saltAlphabet);

  // Parameter für crypt zusammenstellen: $algorithm$cost$salt
  $algorithm = '2a'; // Konstante des Blowfish hashing Algorithmus
  $cryptParams = sprintf('$%s$%02d$%s', $algorithm, $cost, $salt);

  return crypt($password, $cryptParams);
}

/**
 * Prüft, ob das Password einem gegebenen Hashwert entspricht. Damit kann ein
 * vom Benutzer eingegebenes Passwort, mit dem in der Datenbank gespeicherten
 * Hashwert verglichen werden.
 * @param string $password Zu prüfendes Passwort.
 * @param string $hash Zuvor mit sto_hashBcrypt() generierter Hashwert.
 * @return bool Gibt true zurück, wenn das Passwort mit dem Hashwert
 *   übereinstimmt, sonst false.
 */
function sto_checkBcrypt($password, $hash)
{
  if (CRYPT_BLOWFISH != 1) die('The CRYPT_BLOWFISH algorithm is required (PHP 5.3).');
  $password = trim($password);

  // Die Parameter, die ursprünglich zum Erstellen von $hash verwendet wurden,
  // werden automatisch extrahiert, um den neuen Hashwert zu generieren.
  // Sie sind in den ersten 22 Zeichen von $hash enthalten.
  $passwordHash = crypt($password, $hash);
  return $passwordHash === $hash;
}

/**
 * Erstellt einen zufälligen string einer bestimmten Länge, mit Zeichen
 * eines bestimmten Alphabets.
 * @param int $length Anzahl Zeichen die der string aufweisen soll.
 * @param string $alphabet Ein string der alle erlaubten Zeichen enthält.
 * @return string Der zufällige string.
 */
function sto_generateRandomString($length, $alphabet)
{
  if (MCRYPT_DEV_URANDOM != 1) die('The MCRYPT_DEV_URANDOM source is required (PHP 5.3).');
  $result = '';

  // Erstelle eine zufällige Bytefolge, mit Hilfe der Zufallsquelle des
  // Betriebssystems. Seit PHP 5.3 wird diese Quelle auch auf Windows benutzt.
  // Im Gegensatz zu /dev/random, wird /dev/urandom den Server nicht blocken,
  // falls nicht genug Entropie vorhanden ist.
  $randomBinaryString = mcrypt_create_iv($length, MCRYPT_DEV_URANDOM);

  // Konvertiere Bytes zu Zeichen aus dem Alphabet
  $alphabetLength = strlen($alphabet);
  for ($i = 0; $i < $length; $i++)
  {
    $alphabetIndex = Ord($randomBinaryString[$i]) % $alphabetLength;
    $result .= $alphabet[$alphabetIndex];
  }
  return $result;
}

Nach dem Lesen mehrerer Artikel über sicheres Sessionhandling, welche vor allem beschreiben was man nicht tun sollte, war ich ein bisschen konsterniert, weil es scheinbar unmöglich ist, es richtig zu machen. Irgendwann ist mir dann aufgefallen, dass die meisten Probleme erst dadurch entstehen, dass das Session Cookie gleichzeitig auch für die Authentifikation des Benutzers verantwortlich ist. Indem wir die zwei Verantwortlichkeiten "Erhalten der Session" und "Authentifikation" trennen, wird das Session Handling viel einfacher und sicherer, aber schauen wir doch Schritt für Schritt…

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 auch unbedingt in Erwägung ziehen, viele Risiken können so vermieden werden, und 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. Weiterlesen lohnt sich aber trotzdem…

Das noMim Cookie

Die Idee des noMim (no-man-in-the-middle) Cookie ist, dass ein zweites Cookie zusätzlich zum Session-Cookie erstellt wird, 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 noMim cookie generieren und in der Session ablegen
  $noMimCode = md5(uniqid(mt_rand(), true));
  $_SESSION['noMim'] = $noMimCode;

  // noMim Cookie erstellen, und ausschliesslich für HTTPS Seiten konfigurieren
  setcookie('noMim', $noMimCode, 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 noMim Cookie verlangen.

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

<?php
  session_start();

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

  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 noMim Cookie, welches die Authentifikation übernimmt. Nur der Benutzer, welcher das Passwort eingegeben hat, kann das noMim Cookie besitzen, es wird ausschliesslich über verschlüsselte HTTPS Verbindungen geschickt.

Weitere Vorteile

Das Session-Cookie nur zur Erhaltung der Session zu Verwenden, und damit diese Verantwortlichkeit von der Authentifikation zu trennen ist eine gute Sache, sogar wenn die ganze Webseite auschliesslich mit HTTPS betrieben wird.

  1. Es gibt viele Faktoren, die das Session-Handling von PHP beeinflussen (Servereinstellungen, php.ini, .htaccess, PHP Code, Browsereinstellungen, Id in der URL, ...). Es ist deshalb sehr schwierig, das System wasserdicht abzusichern. Dies ist auch ein Grund, warum das Session-Cookie ein so beliebtes Ziel für Angriffe ist.
  2. Die Idee des noMim Cookies ist erweiterbar. Man könnte mehrere Cookies benutzen, um mehrere Berechtigungs-Stufen einzurichten, oder um eine private Session zu starten, bevor sich der Benutzer eingeloggt hat.
  3. Man könnte PHP sogar erlauben die Session-Id in der URL zu übergeben (session.use_trans_sid), um Systeme zu unterstützen, die keine Cookie speichern können. Dies ist normalerweise verpönt, es könnte aber trotzdem nützlich sein, um zum Beispiel ein Post-Redirect-Get Pattern zu implementieren.

Die Distanz zweier Punkte auf der Erde berechnen

To calculate the spheric distance between two points on the earth (great-circle distance), one can use the Haversine formula. This formula is more stable for calculating small distances, regarding rounding errors.

/**
 * 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;
}

www.martinstoeckli.ch