BenBE's humble thoughts Thoughts the world doesn't need yet …

21.01.2010

Cross-Domain XmlHttpRequest-Foo mit Custom HTTP Methods

Filed under: Software — Schlagwörter: , , , , , , , , , , — BenBE @ 04:36:32

Kleine Vorwarnung vorweg: Es wird technisch! Wem die Überschrift nichts sagt, empfehle ich vor dem Fortsetzen in diesem Post das Studium diverser Standards. Zudem sei bereits hier angemerkt: Die hier vorgestellte Technik funktioniert auf Grund der Same Origin Policy nicht mit allen Browsern. Getestet (und als Funktionierend bekannt) ist aber mindestens alles, was FF 3.5.X oder besser heißt. Aber genug der Vorrede, fangen wir an.

Vor etwas längerer Zeit bin ich auf einen Blogpost gestoßen, in dem erklärt wurde, wie man Custom HTTP Methods mit PHP implementieren kann. Nun gibt es dafür eine Breite Verwendung, z.B. um eine RESTful API zu implementieren, aber so ganz allein auf dem eigenen Server macht dies i.d.R. wenig Sinn. Wohl aber, wenn man sich anschaut, dass GeSHi nur darauf wartet, als Webservice nutzbar zu sein – es gab sogar bereits Leute, die haben GeSHi in ASP und Java eingebunden. Also warum nicht auch GeSHi als REST-API?

Also das Demo-Skript aus dem Blog-Eintrag herangezogen und auf dem Server eingerichtet. Funktionierte wie erwartet auf Anhieb, solange es unter Beachtung der Same-Origin-Policy seine Anfragen an das eigene Skript schickte. Für einen so richtigen Webservice ist das aber eher weniger geeignet, wenn auch es auf Seiten von PHP mit relativ wenig Aufwand umsetzbar ist, eine Custom HTTP Method abzufangen (sofern der Server dies zulässt):

//Check for Custom HTTP Method ...
if (strtoupper($_SERVER['REQUEST_METHOD']) == 'MYMETHOD') {
    //Read RAW POST data ...
    $raw_data = file_get_contents('php://input');

    //Split variables according to & signs ...
    $array = explode('&', $raw_data);

    //Read POST Variables ...
    foreach($array as $item) {
        list($key, $value) = explode('=', $item, 2);
        $_MYMETHOD[$key] = $value;
    }

    //Do output generation here ...
    echo "Hello World!";
}

Wer bis hierhin durchgehalten hat, hat die Hälfte eigentlich schon geschafft, denn mit diesen wenigen Zeilen kann das Script bereits korrekt auf MYMETHOD-Anfragen reagieren. Doch nun zum interessanten Teil: XD-XHR.

Normalerweise verhindert im Browser die Same-Origin-Policy die Ausführung von Abfragen, insbesondere bei Custom HTTP Methods über Domaingrenzen hinweg. Seit dem Release von Firefox 3, gibt es aber, basierend auf einer Draft des W3C, die Möglichkeit, diese Policy zu lockern, wenn der empfangende Webserver sich bereiterklärt, den Request entgegen zu nehmen.

Um diese Genehmigung zu erhalten sendet der Browser vor einem XHR einen sog. Preflight Request, in dem basierend auf einer Reihe von Headern dem Zielserver mitgeteilt wird, von welcher Quelldomain ausgehend, welche HTTP-Methode UND welche Resource verwendet werden soll. Basierend auf diesen Angaben muss der Zielserver nun eine Reihe von Headern generieren, um diesen Request zu akzeptieren. Im einfachsten Fall reicht hierffür genau ein Header:

Access-Control: allow <*>

Was aber aus Sicherheitstechnischen Gründen (World-Wide Accessible) nicht unbedingt zu empfehlen ist. Etwas vernünftiger ist es da schon eher, basierend auf der Origin die im W3C-Draft vorgeschlagenen Header halbwegs vollständig zu interpretieren UND zu generieren. Mit etwas Feingefühl kann man dabei allein anhand der Access-Control-Header bereits Request-Limiting und andere Funktionalität vom Client erfragen. Ein i.d.R. guter Anfang ist mit folgenden Headern gegeben:

Access-Control: allow <$origin>
Access-Control-Allow-Origin: $origin
Access-Control-Max-Age: 30
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: MYMETHOD, STATUS

Hierbei gibt $origin die ausgewertete UND geprüfte Origin der Seite an. Diesen Wert holt man sich aus dem sowohl im OPTIONS als auch im richtigen Request mitgesendeten Origin-Header, der wie folgt ausschaut:

Origin: http://benny-baumann.de

Der Response-Header Access-Control-Allow-Methods gibt hierbei eine (vollständige) Liste aller Request-Methoden an, die neben GET und POST zusätzlich erlaubt sein sollen. Nur Methoden, die in dieser Liste auftauchen, werden später vom Browser ausgeführt. Wichtig ist ferner, dass diese generierten Header sowohl im OPTIONS wie auch in allen ausgeführten Methoden mitgesendet werden müssen. Vergisst man dies und sendet sie nur im Preflight-Response, wird zwar die eigentliche Anfrage an den eigenen Server ausgeführt, der Response an den Browser jedoch verworfen und nicht weiter beachtet.

Nach dem die Details bzgl. der Browser-Implementierung klar sein dürften, müssen diese Header auch generiert werden. Auch hier kann man die meiste Arbeit mit relativ wenig Aufwand umsetzen:

function doHeaders() {
    //Sanitizing the Origin Header ...
    $origin = preg_replace('/[^\w-:.\/*]/', '', empty($_SERVER['HTTP_ORIGIN']) ? '*' : $_SERVER['HTTP_ORIGIN']);

    //Allow access from anywhere ...
    //This will actually always return the origin domain for now, but might get limited later
    header('X-GeSHi-API: v0.1');
    header('Access-Control: allow <'.$origin.'>');
    header('Access-Control-Allow-Origin: ' . $origin);
    header('Access-Control-Max-Age: 30');
    header('Access-Control-Allow-Credentials: true');
    header('Access-Control-Allow-Methods: MYMETHOD, STATUS');

    //Limit allowed HTTP headers for requests; unused for now.
    //header('Access-Control-Allow-Headers: Accept-Encoding');
}

//Method names are case-insensitive as per RFC 2616 ...
$method = strtolower($_SERVER['REQUEST_METHOD']);

switch ($method) {
    case 'options':
        // Perform validation of preflight requests
        doHeaders();
        exit; //No output for OPTION requests
    case 'get': //Fall Thru to POST
    case 'post':
        // Just send the headers and behave like normal ...
        doHeaders();
        break;
    case 'mymethod':
        doHeaders();
        //More headers for this method ...
        break;
    default:
        //Throw an error and send the list of accepted methods
        header('HTTP/1.1 405 Method Not Allowed');
        doHeaders();
        exit;
}

//Final processing ...

Und das war’s eigentlich auch schon … Bliebe nur noch ein kleines Beispiel für die Kommunikation bei einer Anfrage zu geben, um einen kurzen Eindruck für den Preflight-Request zu geben:

Preflight Request: http://api.geshi.org/?HelloWorld=23&foo=bar

OPTIONS /?HelloWorld=23&foo=bar HTTP/1.1
Host: api.geshi.org
User-Agent: Mozilla/5.0 (Windows; U; Windows NT 5.0; de; rv:1.9.1.7) Gecko/20091221 Firefox/3.5.7
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: de-de,de;q=0.8,en-us;q=0.5,en;q=0.3
Accept-Encoding: gzip,deflate
Accept-Charset: ISO-8859-1,utf-8;q=0.7,*;q=0.7
Keep-Alive: 300
Connection: keep-alive
Origin: http://benny-baumann.de
Access-Control-Request-Method: MYMETHOD

HTTP/1.x 200 OK
Date: Thu, 21 Jan 2010 03:27:45 GMT
Server: Apache/2.2.14 (Debian)
X-GeSHi-API: v0.1
Access-Control: allow <http://benny-baumann.de>
Access-Control-Allow-Origin: http://benny-baumann.de
Access-Control-Max-Age: 30
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: MYMETHOD, LIST, LISTVERSIONS, LISTLANGUAGES, LISTTHEMES, HIGHLIGHT, CSS, STATUS
Vary: Accept-Encoding
Content-Encoding: gzip
Content-Length: 20
Keep-Alive: timeout=15, max=100
Connection: Keep-Alive
Content-Type: text/html

Actual Request: http://api.geshi.org/?HelloWorld=23&foo=bar

MYMETHOD /?HelloWorld=23&foo=bar HTTP/1.1
Host: api.geshi.org
User-Agent: Mozilla/5.0 (Windows; U; Windows NT 5.0; de; rv:1.9.1.7) Gecko/20091221 Firefox/3.5.7
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: de-de,de;q=0.8,en-us;q=0.5,en;q=0.3
Accept-Encoding: gzip,deflate
Accept-Charset: ISO-8859-1,utf-8;q=0.7,*;q=0.7
Keep-Alive: 300
Connection: keep-alive
Content-Type: application/x-javascript; charset=UTF-8;
Referer: http://benny-baumann.de/index.php
Content-Length: 27
Origin: http://benny-baumann.de
FirstName=John&LastName=Doe

HTTP/1.x 200 OK
Date: Thu, 21 Jan 2010 03:27:46 GMT
Server: Apache/2.2.14 (Debian)
X-GeSHi-API: v0.1
Access-Control: allow <http://benny-baumann.de>
Access-Control-Allow-Origin: http://benny-baumann.de
Access-Control-Max-Age: 30
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: MYMETHOD, LIST, LISTVERSIONS, LISTLANGUAGES, LISTTHEMES, HIGHLIGHT, CSS, STATUS
Vary: Accept-Encoding
Content-Encoding: gzip
Content-Length: 1155
Keep-Alive: timeout=15, max=99
Connection: Keep-Alive
Content-Type: text/html

Bliebe nur noch viel Spaß beim Spielen zu wünschen.

P.S.: Auf den Preflight-Request nicht verlassen, da der gecachet wird. Steht aber auch in den W3C-Dokumenten noch mal genau drin 😉

Flattr this!

Ein Kommentar »

  1. Hast du das schon als Test irgend wo am laufen?

    Kommentar by neo — 22.01.2010 @ 23:47:25

RSS feed for comments on this post. TrackBack URL

Leave a comment

Powered by WordPress