Category: code

WordPress mit TLS Client Authentication absichern

27.04.2015 yahe administration code legacy security wordpress

Vor einiger Zeit hatte ich einmal gezeigt, wie man WordPress gegen eine größere Menge von Zero-Day-Exploits absichern kann, indem man verschiedene Datenbanknutzer einführt. Dadurch verwenden normale Webseitenbesucher einen Datenbanknutzer mit geringeren Privilegien und Nutzer des Backends einen Datenbanknutzer mit höheren Privilegien. Bei Softwarefehlern, die schreibenden Zugriff auf die Datenbank ermöglichen, können so Schreibversuche unterbunden werden.

Leider ist den Entwicklern von WordPress nicht sonderlich daran gelegen, die Datenbank so zu strukturieren, dass bestimmte Datenbankzugriffe für gewisse Nutzer nicht notwendig sind. So wurde vor einiger Zeit ein neues Session-Management eingeführt, das es zwar ermöglicht, Sitzungen auf anderen Rechnern zu beenden, dafür jedoch Daten in eine Tabelle schreibt, in der auch wichtige Konfigurationsinformationen abgelegt sind. Das führte zu Problemen bei der Trennung der Datenbanknutzer. Ich überlegte deshalb, wie ich die Trennung der Datenbanknutzer beibehalten und gleichzeitig flexibler bestimmen kann, wann ein höher privilegierter Datenbanknutzer benötigt wird.

Ebenfalls vor einiger Zeit hatte ich einmal gezeigt, wie man in NGINX TLS-Clientzertifikate einsetzen kann. Meine Idee war nun, die Steigerung der Privilegien innerhalb der WordPress-Datenbank an die Nutzung von TLS-Clientzertifikaten zu koppeln. So hat man eine Art Zwei-Faktor-Authentisierung: Mit seinem Zertifikat schaltet man die Nutzerrechte in der Datenbank frei und mit seinem Passwort loggt man sich im Backend ein.

Mein erster Versuch war, mit optionalen Clientzertifikaten zu arbeiten. Der Server fragt die Clientauthentisierung beim Browser an. Wenn der Browser kein Clientzertifikat zurückliefert, wird trotzdem mit der Seitendarstellung mit geringeren Priviliegen fortgefahren. Allerdings stellte sich heraus, dass der Safari-Browser bei jedem einzelnen Seitenaufruf nach dem Clientzertifikat fragt, sobald man eines hinterlegt. Eigentlich sendet der Server das Zertifikat der CA mit, von der das passende Clientzertifikat ausgestellt sein muss. Browser können diese Information nutzen, um die Clientzertifikate herauszufiltern, die nicht verwendet werden können. Doch leider sieht das Apple anders und so fragt der Safari den Nutzer ständig nach dem zu verwendenden Clientzertifikat, selbst, wenn es kein passendes gibt. Also musste eine nutzerverträglichere Lösung her.

In der neuen Lösung ruft man eine separate Seite auf, für die man zwingend eine Clientauthentisierung durchführen muss. Diese überprüft die Authentizität des Zertifikats und leitet dann auf einen Teil der eigentlichen, zu schützenden Seite weiter. Dieser Teil startet die Session, in der hinterlegt wird, dass die höheren Privilegien genutzt werden dürfen. Der Aufruf dieses Teils ist durch ein zeit- und passwortbasiertes Challenge-Response-Verfahren geschützt. Das spannende an diesem Ansatz ist, dass es prizipiell auch dafür geeignet ist, ein Single-Sign-On-Portal für verschiedenste Anwendungen aufzubauen. Kern der Anwendung ist die Datei "auth.php", in der sich die grundlegende Logik befindet:

<?php
  define("SUCCESS_CONTENT", "SUCCESS"); // defines what text should be in TLS_SUCCESS

  define("CHALLENGE_ALGO",   "sha256"); // defines the algorithm used for response HMAC
  define("CHALLENGE_LENGTH", 32);       // defines the length of the random challenge

  define("PARAM_START",   "?"); // should be static
  define("PARAM_VALUE",   "="); // should be static
  define("PARAM_COMBINE", "&"); // should be static

  define("CHALLENGE_PARAM", "challenge"); // name of challenge GET parameter
  define("RESPONSE_PARAM",  "response");  // name of response GET parameter

  define("DN_PARAM",      "TLS_DN");      // name of DN SERVER parameter
  define("SUCCESS_PARAM", "TLS_SUCCESS"); // name of success SERVER parameter

  define("LOGINSESSION_LOGGEDIN", "loginsession_loggedin");

  function getChallenge() {
    return openssl_random_pseudo_bytes(CHALLENGE_LENGTH);
  }

  function getResponse($challenge, $previous = false) {
    // prepare a delta time value for cases where we missed the variance window
    $delta = ($previous) ? CHALLENGE_VARIANCE : 0;

    // use the current time as a replay prevention mechanism
    $time_challenge = dechex((int)((time()-$delta) / CHALLENGE_VARIANCE));

    return hash_hmac(CHALLENGE_ALGO, $challenge.$time_challenge, CHALLENGE_KEY);
  }

  function check_GET() {
    $result = false;

    // check if the parameters are set
    if (isset($_GET[CHALLENGE_PARAM]) && isset($_GET[RESPONSE_PARAM])) {
      // convert challenge parameter to binary
      $challenge = hex2bin($_GET[CHALLENGE_PARAM]);
      // check if the challenge parameter was a hex
      if (false !== $challenge) {
        // calculate response from challenge parameter
        $response = getResponse($challenge, false);
        // check if response calculation was possible
        if (false !== $response) {
          // check if the calculated response matches the response parameter
          $result = (0 === strcasecmp($response, $_GET[RESPONSE_PARAM]));
        }

        // maybe we just passed the time variance window, test the previous one
        if (!$result) {
          // calculate response from challenge parameter
          $response = getResponse($challenge, true);
          // check if response calculation was possible
          if (false !== $response) {
            // check if the calculated response matches the response parameter
            $result = (0 === strcasecmp($response, $_GET[RESPONSE_PARAM]));
          }
        }
      }
    }

    return $result;
  }

  function check_SERVER() {
    $result = false;

    // check if the necessary TLS server values have been set
    if ((isset($_SERVER[DN_PARAM])) && (isset($_SERVER[SUCCESS_PARAM]))) {
      // check if the TLS server values contained the required content
      $result = ((0 === strcasecmp(DN_CONTENT,      $_SERVER[DN_PARAM])) &&
                 (0 === strcasecmp(SUCCESS_CONTENT, $_SERVER[SUCCESS_PARAM])));
    }

    return $result;
  }

  function createSession() {
    // open a session
    session_start();

    // session should exist now
    if (PHP_SESSION_ACTIVE === session_status()) {
      // prevent session fixation
      session_regenerate_id(true);

      $_SESSION[LOGINSESSION_LOGGEDIN] = true;
    }
  }

  function destroySession() {
    // check if there actually is a session to destroy
    if ((isset($_COOKIE[session_name()])) || (isset($_GET[session_name()]))) {
      // open the existing session
      session_start();

      // session should be open now
      if (PHP_SESSION_ACTIVE === session_status()) {
        // remove loggedin value from session
        if (isset($_SESSION[LOGINSESSION_LOGGEDIN])) {
          unset($_SESSION[LOGINSESSION_LOGGEDIN]);
        }

        // check if the session is empty
        if (0 === count($_SESSION)) {
          // destroy the session if it is
          session_unset();
          session_destroy();

          // unset the session cookie
          if (isset($_COOKIE[session_name()])) {
            $params = session_get_cookie_params();
            setcookie(session_name(), "", time()-3600, $params["path"], $params["domain"], $params["secure"], $params["httponly"]);
          }
        } else {
          // prevent session fixation
          session_regenerate_id(true);
        }
      }
    }
  }

  function doRedirect($target) {
    header("HTTP/1.1 302 Moved Temporarily");
    header("Location: ".$target);
  }

  function triggerChallengeResponse() {
    // generate new challenge
    $challenge = getChallenge();
    // check if challenge generation worked
    if (false !== $challenge) {
      // calculate response from challenge
      $response = getResponse($challenge, false);
      // check if response calucation worked
      if (false !== $response) {
        // convert challenge from binary to hex
        $challenge = bin2hex($challenge);

        // redirect to challenge-response
        doRedirect(PARAM_PATH.PARAM_START.CHALLENGE_PARAM.PARAM_VALUE.$challenge.PARAM_COMBINE.RESPONSE_PARAM.PARAM_VALUE.$response);
      }
    }
  }
?>

Die eigentliche Aufgabe, das Clientzertifikat zu prüfen, zur eigentlichen Webseite zu redirecten und dort die Session zu starten, übernimmt die Datei "logon.php". Diese gibt es zwei Mal, einmal auf dem Server, der die TLS-Clientauthentisierung übernimmt und einmal auf dem Server, auf dem WordPress installiert ist. Die in der Datei enthaltene Konfiguration sollte auf beiden Servern identisch sein:

<?php
  define("DN_CONTENT", "/cn=example.com"); // defines what text should be in TLS_DN

  define("CHALLENGE_VARIANCE", 300); // defines how long the challenge will be usable
  define("CHALLENGE_KEY",      "V3RY 5TR0NG P455W0RD"); // defines the password used for the response HMAC

  define("PARAM_PATH", "https://example.com/auth/logon.php"); // redirect path

  define("FINAL_PATH", "https://example.com/wp-login.php");

  require_once(dirname(__FILE__)."/auth.php");

  if (check_GET()) {
    createSession();
    print("<a href=\"".FINAL_PATH."\">Logon</a>");
  } else {
    if (check_SERVER()) {
      triggerChallengeResponse();
      exit();
    }
  }
?>

Es kann auch sinnvoll sein, die erhaltenen Privilegien wieder abzugeben, indem man die Session beendet. Diese Aufgabe übernimmt die Datei "logoff.php", die ebenfalls auf beiden Servern vorhanden und dieselbe Konfiguration enthalten sollte:

<?php
  define("DN_CONTENT", "/cn=example.com"); // defines what text should be in TLS_DN

  define("CHALLENGE_VARIANCE", 300); // defines how long the challenge will be usable
  define("CHALLENGE_KEY",      "V3RY 5TR0NG P455W0RD"); // defines the password used for the response HMAC

  define("PARAM_PATH", "https://example.com/auth/logoff.php"); // redirect path

  require_once(dirname(__FILE__)."/auth.php");

  if (check_GET()) {
    destroySession();
    print("logoff");
  } else {
    if (check_SERVER()) {
      triggerChallengeResponse();
      exit();
    }
  }
?>

In der eigentlichen Anwendung kann dann anhand der Session zwischen den Datenbanknutzern unterschieden werden. Im Falle von WordPress habe ich das in der Konfigurationsdatei "wp-config.php" getan:

$loginsession_evaluated = false;
// open the session if there is one
if ((isset($_COOKIE[session_name()])) || (isset($_GET[session_name()]))) {
  session_start();

  if ((isset($_SESSION["loginsession_loggedin"])) && ($_SESSION["loginsession_loggedin"])) {
    $loginsession_evaluated = true;
  }
}

// select user dependent on loginsession
if (($loginsession_evaluated) || (defined("DOING_CRON"))) {
  define("DB_USER",     "<ADMIN USER>");
  define("DB_PASSWORD", "<ADMIN PASSWORD>");
} else {
  define("DB_USER",     "<GUEST USER>");
  define("DB_PASSWORD", "<GUEST PASSWORD>");
}

Da das von der Theorie her vielleicht alles ein bisschen trocken ist, habe ich den Ablauf eines vollständigen Logins als Screencapture aufgenommen und bei Youtube hochgeladenen. Vielleicht wird dadurch klarer, wie die einzelnen Schritte ineinander greifen.

Ich hoffe, dieser kleiner Exkurs bietet dem ein oder anderen eine neue Lösungsmöglichkeit für die Absicherung von Nutzerzugängen. Die gezeigte Implementation habe ich, soweit wie möglich, generisch gehalten. Durch das Challenge-Reponse-Verfahren ist sie beispielsweise auch als Grundlage für Inter-Domain-Single-Sign-On geeignet und durch die Verwendung von einfachen Sessions sollte sie zudem softwareunabhängig einsetzbar sein.


PushOver.net: Scriptgesteuerte Push-Notifications für iOS und Android

30.10.2014 yahe administration code legacy linux

Letztens durfte ich einen relativ langwierigen Download via SFTP vornehmen. Da ich allerdings nicht vor dem Rechner sitzen bleiben und auf die Beendigung warten wollte, bin ich immer wieder an den PC gegangen, um den aktuellen Status zu sehen. Dabei kam mir die Idee, dass es doch toll wäre, einen Dienst zu haben, den man per einfacher REST-API ansprechen könnte, um einen Status mitzuteilen. Der Dienst könnte es dann ermöglichen, diesen Status z.B. über einen Browser abzurufen. @servicecase18 hatte jedoch einen viel besseren Tipp: Er verwendet für soetwas den Dienst PushOver.net, der im Grunde das macht, was ich möchte, nur besser. Sie bieten ebenfalls eine einfache REST-API an, versenden den Status dann jedoch als Push-Notification an ihre iOS- und Android-App. So hat man den Status direkt auf dem Smartphone.

Wenn man sich registriert, erhält der neue Account einen "User Key". Nachrichten, die an den User Key geschickt werden, landen auf jedem Gerät, bei dem man mit dem zugehörigen Account in der App eingeloggt ist. Zudem kann man mit seinem Account auch "Applications" registrieren, wobei jede Application ein "Application Token" erhält. Um nun eine Push-Notification zu senden, übergibt man der REST-API mindestens den User Key, den Application Token und die eigentliche Nachricht. Optional gibt es noch weitere Werte, die man setzen kann.

PushOver-App PushOver-App

Das ganze ist so praktisch, dass im Grunde jeder seiner Anwendungen eine Anbindung an den Dienst spendieren möchte.

Push-Notifications Push-Notifications

Da ich allerdings nicht die Absicht habe, allen Skripten ein eigenes Application Token zu besorgen (obwohl das möglich wäre), habe ich mir etwas anderes überlegt. Ich hole mir pro Server nur ein Application Token und verwende ein Skript, das sich zentral um das Versenden der Push Notifications kümmert. Das hat den zusätzlichen Vorteil, dass nicht jedes kleine Skript wissen muss, wie der Versand funktioniert. Stattdessen gibt es einen Ordner, in das jedes Skript einfach Dateien ablegen kann. Der Name der Datei ist der Titel des Push Notification und der Inhalt der Datei ist auch gleichzeitig der Inhalt der Push Notification. Um das Versandskript Pushinfo nicht als Root laufen lassen zu müssen, habe ich mich dazu entschieden, dass versandte Nachrichten nicht gelöscht werden. Stattdessen wird in einer Statusdatei das letzte Bearbeitungsdatum der Dateien notiert. Sollte sich die Datei ändern (und damit das entsprechende Dateidatum), so wird der Inhalt erneut versendet. Das ganze sollte man nun natürlich regelmäßig ausführen lassen, z.B. per Cron.


PHP Scripte: Chrooten und Privileges droppen

28.10.2014 yahe code legacy linux security

Derzeit bin ich dabei, ein Synchronisationsscript zu schreiben, das, je nach synchronisiertem Ordner mit unterschiedlichsten Dateirechten arbeiten können. Die Synchronisation selbst habe ich in PHP geschrieben. Allerdings war es mir zu heikel, die Synchronisation durchgehend als Root auszuführen. Deshalb habe ich mir mal angesehen, ob PHP in der Lage ist, innerhalb eines Skripts Privilegien zu droppen und sich eventuell sogar Chrooten zu lassen. Und in der Tat, beides ist möglich!

Da ich diese Fähigkeit für sehr sinnvoll halte, habe ich mir das ganze direkt in der kleinen Bibliothek unchroot zusammen gesammelt. Diese enthält die zwei wichtigen Funktionen "force_chroot()" und "force_unroot()". Sollte ein entsprechender Aufruf fehlschlagen, wird das gesamte Skript direkt mit einer Fehlermeldung beendet. Lieber nichts tun, als mit zu vielen Rechten unterwegs zu sein.


Behandlung depublizierter Artikel in WordPress

03.09.2014 yahe administration code legacy wordpress

Vor kurzen wurde ich von einem Auftraggeber vor eine spannende Aufgabe gestellt: Eine Seite musste eine ganze Menge alter Artikel depublizieren. Die Besucher dieser Artikel sollten jedoch nicht einfach von einer 404-Fehlerseite abgefangen werden, sondern ihnen sollte mitgeteilt werden, weshalb der Fehler aufgetreten ist und sie sollten möglichst automatisch auf die Startseite umgelenkt werden.

Dabei gab es mehrere Hindernisse. Zum einen musste eine Datenbasis geschaffen werden, welche URLs nicht mehr verfügbar sind und es musste bei deren Aufruf der entsprechend angepasste Inhalt angezeigt werden. Anstatt jedoch anzufangen, die gesamte Seite abzugrasen, um an die URLs zu gelangen, hatte ich eine bessere Idee. Die Artikeldatenbank selbst würde diese Datenbasis darstellen.

Um die Artikel offline zu nehmen, sollten diese nicht gelöscht, sondern lediglich deren Sichtbarkeit auf "privat" gestellt werden. Damit könnte man eine generalisierte Lösung erstellen, die für alle privaten Artikelseiten funktioniert. Das gute an privaten Artikeln ist, dass sie bei externen Besuchern zur 404-Seite führen, so, als würde sie nicht existieren. Der Inhalt der 404-Seite wird durch die Datei "404.php" des Themes repräsentiert. Dort kann man sich einklinken und vor dem Anzeigen des Fehlers prüfen, ob der Fehler aufgetreten ist, weil die angeforderte Seite privat ist. Je nach Ergebnis der Prüfung kann man dann den einen oder den anderen Inhalt anzeigen.

<?php
  $private_404_args    = wp_parse_args($wp->matched_query);
  $private_404_content = "";
  $private_404_done    = false;
  $private_404_result  = 0;

  if (isset($private_404_args["p"]) || isset($private_404_args["name"])) {
    if ((!$private_404_done) && isset($private_404_args["p"])) {
      $private_404_result = $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM $wpdb->posts WHERE id = %d AND LOWER(post_status) = 'private' AND LOWER(post_type) = 'post';"), intval($private_404_args["p"]));

      $private_404_done = (intval($private_404_result) === 1);
    }

    if ((!$private_404_done) && isset($private_404_args["name"])) {
      $private_404_result = $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM $wpdb->posts WHERE LOWER(post_name) = LOWER(%s) AND LOWER(post_status) = 'private' AND LOWER(post_type) = 'post';", $private_404_args["name"]));

      $private_404_done = (intval($private_404_result) === 1);
    }
  }

  if ($private_404_done) {
    $private_404_content = $wpdb->get_var("SELECT post_content FROM $wpdb->posts WHERE LOWER(post_title) = '[410]' AND LOWER(post_status) = 'private' AND LOWER(post_type) = 'page';");

    if ($private_404_content != null) {
      print(apply_filters('the_content', $private_404_content));
    }
  } else {
    $private_404_content = $wpdb->get_var("SELECT post_content FROM $wpdb->posts WHERE LOWER(post_title) = '[404]' AND LOWER(post_status) = 'private' AND LOWER(post_type) = 'page';");

    if ($private_404_content != null) {
      print(apply_filters('the_content', $private_404_content));
    }
  }
?>

Die schlussendliche Lösung ist etwas komplizierter geworden. Eigentlich hatte ich vor, einfach per "url_to_postid()" herauszufinden, ob unter der URL, die aufgerufen wurde, ein Artikel existiert. Leider funktioniert die Funktion offenbar nicht bei privaten Artikeln, wenn sie von einem Besucher aufgerufen wurden.

Im Feld "$wp->matched_query" stehen jedoch glücklicherweise die Teile der erfolgreich erkannten URL-Teile, wie sie die Bildungsregeln in der WordPress-Konfiguration vorgeben. In meinen Fall wäre das z.B. "/%year%/%postname%/". Der Inhalt von "matched_query" wäre demnach für den aktuellen Artikel hier "year=2014&name=behandlung-depublizierter-artikel-in-wordpress". Dieser Wert kann mithilfe der Funktion "wp_parse_args()" dann weiter aufgedröselt werden. Anhand des Namens, der in WordPress eindeutig ist, kann wiederum der zugehörige Artikel in der "wp_posts"-Tabelle gefunden werden.

Da ich sowieso gerade dabei war, habe ich auch noch eine weitere Vereinfachung vorgenommen, nämlich die, den angezeigten Inhalt der entsprechenden Fehlerseiten über das WordPress-Backend bestimmen zu können. Dazu müssen zwei Seiten (nicht Artikel!) erstellt werden, deren Sichtbarkeit ebenfalls auf "privat" gesetzt wird. Die Seite, die für generelle 404-Fehlermeldungen genutzt werden soll, muss den Titel "[404]" erhalten (ohne Anführungszeichen). Die Seite, die für depublizierte Fehlermeldungen genutzt werden soll, muss hingegen den Titel "[410]" erhalten (ebenfalls ohne Anführungszeichen).


Wikipad: Etherpad-Lite mit DokuWiki synchronisieren

17.04.2014 yahe code legacy linux

Vor einiger Zeit habe ich mir ein Etherpad-Lite installiert, um verteilt und auch unterwegs Texte verfassen zu können, die ich anschließend veröffentliche oder anderweitig nutze. Das ist soweit toll, doch die Veröffentlichung war in meinen Augen unpraktischer, als sie sein müsste, da ich den Text kopieren, irgendwo anders einfügen und neu formatieren musste. Meine Idee war es deshalb, die Veröffentlichung direkt aus dem Etherpad-Lite heraus erledigen zu können, zum Beispiel bei kleineren Texten, die ich anderen zur Verfügung stellen möchte. Die Frage war nur, über welche Plattform die Veröffentlichung stattfinden sollte.

Anfangs dachte ich daran, eine eigene Oberfläche zu entwickeln, allerdings wäre die nicht sonderlich hübsch geworden. Vor kurzem bin ich nun auf DokuWiki gestoßen, das perfekt zu sein scheint. Das besondere an DokuWiki ist, dass keine Datenbank verwendet wird, sondern alle Inhalte in einfachen *.txt-Dateien abgelegt werden. Ich könnte also einfach Pads exportieren, als Textdatei ablegen und wäre fertig. Die Idee zu Wikipad war geboren.

Texterstellung in Etherpad-Lite

Implementiert habe ich die Synchronisation in PHP. Es gibt einen Pad-Präfix und einen Wiki-Präfix. Alle Pads, deren Name den Pad-Präfix ("<Pad-Präfix>!<Name>") beinhalten, werden in einen Namespace des Wikis überführt ("<Wiki-Präfix>:<Name>"). Man kann die Präfixe auch weglassen, dann werden diese ignoriert und ohne Präfixe gearbeitet. In meinem Fall verwende ich z.B. den Pad-Präfix "wiki!" und einen leeren Wiki-Präfix.

Als erstes lasse ich aus der Etherpad-Lite Datenbank auslesen, welche Pads es mit dem Pad-Präfix gibt. Anschließend wird geprüft, wieviele Revisionen es von diesem Pad gibt. Hat sich seit der letzten Synchronisation die Anzahl der Revisionen geändert, so wird das Pad exportiert und in den data-Ordner des DokuWikis geschrieben.

Veröffentlichung in DokuWiki

Neben den Präfixen habe ich noch ein paar weitere Steuerungsmechanismen implementiert. So werden Pads nicht synchronisiert, wenn sie direkt nach dem Präfix einen Punkt im Namen haben (z.B. "wiki!.beispiel"). Zudem ist es möglich, inline (das heißt im Content des jeweiligen Pads) die Synchronisation eines Pads zu beeinflussen. Damit ein Pad aus dem Wiki gelöscht wird, muss der Padinhalt mit dem Text "[.remove]" beginnen. Möchte man stattdessen ein Pad bearbeiten, ohne, dass die aktuell gültige Fassung im Wiki gleich ersetzt wird, so muss der Padinhalt mit dem Text "[.ignore]" beginnen. In diesem Fall bleibt die aktuelle Seite im Wiki erhalten, wird jedoch nicht mit der neusten Revision des Pads überschrieben.

Update

In Etherpad Lite Version 1.5.0 wurde der DokuWiki-Export entfernt. Wikipad wurde nun so angepasst, dass es trotzdem noch verwendet werden kann. Leider kann man nun nicht mehr die WYSIWYG-Formatierung von Etherpad Lite nutzen, sondern muss die DokuWiki-Stylingsyntax verwenden.


Search

Links

RSS feed

Categories

administration (40)
arduino (12)
calcpw (2)
code (33)
hardware (16)
java (2)
legacy (113)
linux (27)
publicity (6)
review (2)
security (58)
thoughts (21)
windows (17)
wordpress (19)