RestTemplates, Tomcat, URLEncoding und Slashes: ein überraschend hoffnungsloser Fall

16. April 2013


 

Ich wollte eigentlich nur ein kleines Problem mit unserer REST Anbindung lösen, musste aber feststellen, dass das Problem wegen sich widersprechender Spezifikationen nicht vernünftig lösbar ist … will sagen, wir müssen leider eine unvernünftige Lösung verwenden. Nämlich müssen alle Werte, die als Pfad-variablen übergeben werden, zweimal URL-encodiert (und decodiert) werden, wenn sie eventuell einen Slash (also /, dt: Schrägstrich) enthalten können.

Beispiel: ein REST-artiger/haftiger Dienst (engl. „RESTful“ – gibt es dafür eigentlich einen brauchbaren deutschen Ausdruck?) gibt einem die Nutzerdaten eines Accounts für einen gegebenen Login unter der Anfrage:


   GET  /userProfile/{login}

zurück. Das Problem ist, dass dieser Login Slashes enthalten kann, z.B. „Müller/Schultze“ (Das passiert sogar häufiger, weil sich oft zwei Leute einen Account teilen).
Ja, das ist der tatsächliche Use-case. Wem das zu abstrus ist, mag statt dessen an eine Suche nach Tags (dt. Schlagwörtern) denken, bei dem ein /findByTag/{tag} alle Inhalte gibt, die mit dem gegebenen Tag versehen sind – und z.B. „A/B-Testing“ ist sicher ein sinnvolles Tag. (Und nein, es besteht kein Grund, hier in der Tagcloud nach so etwas zu suchen …)

Problemanfang: Clientseite

Auf der Client-seite wird Spring RestTemplate benutzt, wobei die aufzurufende URL via UriTemplates zusammengebaut wird.
Das Ganze sieht in etwa so aus:


    public UserProfile getUserProfile(String login) {
        final String getUserProfileUrl = "http://localhost:8080/restService"
            + "/users/userProfile/{login}";
        login = UrlUtils.encode(login); // stupid extra encode
        return restTemplate.getForObject(getUserProfileUrl, UserProfile.class, login);
    }

(nur in etwa … natürlich ist der Host nicht hartkodiert im Code. Gerade eben nochmal nachgesehen.) Die UrlUtils sind nur eine kleine Hilfsklasse, um die nervige UnsupportedEncodingException vom java.net.URLEncoder/Decoder abzufangen.)

Auf der Server-seite sieht das dann so aus:


    @RequestMapping(value = "/userProfile/{login}", method = RequestMethod.GET)
    public ModelAndView getUserProfile(@PathVariable String login) {
        login = UrlUtils.decode(login); // stupid extra decode
        UserProfile userProfile = userService.userProfile(login);
        ModelAndView mav = new ModelAndView("xmlView", "userProfile", userProfile);
    }

Hier wird die annotationsbasierte Version von Spring-MVC genommen, und das 'login' via Teil der URL hineingereicht. (Die View benutzt einen Serialisierer vom Typ org.springframework.web.servlet.view.xml.MarshallingView, also ganz altmodisch XML, auch wenn inzwischen jeder weiss, das JSON cooler ist … das tut jetzt hier nix zur Sache.)

Eigentlich wäre es jetzt schön, auf das manuelle encoden/decoden der Pfad-parameter zu verzichten – ich meine, wozu benutzt man den Frameworks, die das ganze abstrahieren, wenn man sich nachher doch um solche technischen Details kümmern muss.

In der Tat vermeldet die Spring-dokumentation:

The String URI variants accept template arguments as a String variable length argument or as a Map<String,String>. They also assume the URL String is not encoded and needs to be encoded. That means if the input URL String is already encoded, it will be encoded twice – i.e. http://example.com/hotel%20list will become http://example.com/hotel%2520list. If this is not the intended effect, use the java.net.URI method variant, which assumes the URL is already encoded is also generally useful if you want to reuse a single (fully expanded) URI multiple times.

Das hört sich eigentlich gut an, stimmt aber nur eingeschränkt. Die derzeitige Implementierung ersetzt nämlich erst die Variablen (macht also aus "/users/userProfile/{login}" und "login=Müller/Schulze" den String "/users/userProfile/Müller/Schultze" und versucht sich dann am URL-encoding – wobei dann schon „vergessen“ ist, das der Slash zwischen Müller und Schulze zur Variable gehört. Das Ergebnis ist dann: "/users/userProfile/M%C3%BCller/Schultze" anstatt "/users/userProfile/M%C3%BCller%2FSchultze"

Das wird von dem REST-Server mit einer 404 quittiert – völlig korrekt, schliesslich haben wir nur ein "/userProfile/{login}" in dem Controller für "/users" definiert, und kein "/userProfile/{login1}/{login2}"

Das Problem ist den Spring-entwicklern inzwischen auch schon aufgefallen, und wird z.B. unter https://jira.springsource.org/browse/SPR-10306 diskutiert. (Interessanter Weise gibt es sogar eine formale Spezifikation von URI-Templates, die sich u.a. Gedanken über das URL-Enoding macht: http://tools.ietf.org/html/draft-gregorio-uritemplate-06#section-3.2.3 Ob die komplette Spezifikation jemals irgendwo implementiert wird, bleibt noch abzuwarten.)

Wer nicht auf die Fehlerbehebung dort warten will, kann ja statt dessen der Empfehlung der Dokumentation folgen, und die Methode verwenden, die eine komplette URI bekommt. Natürlich müssen dann die Platzhalter von Hand ersetzt und vorher korrekt URL-encoded werden … aber das lässt sich mit einer zentralen Hilfsmethode veranstalten.

Soweit schon mal nicht so schön, aber scheinbar brauchbar: es wird jetzt eine URL generiert, deren Pfad aussieht wie gewünscht: /users/userProfile/M%C3%BCller%2FSchultze

Serverseitig: Lösungsende

Leider quittiert der REST-Server das nicht mehr mit einer 404, sondern gleich mit einer 400.
Die erste Hürde liegt daran, dass Tomcat aus Sicherheitsgründen keine %2F in der URL unterstützt: http://tomcat.apache.org/security-6.html#Fixed_in_Apache_Tomcat_6.0.10. Wer jetzt keine Probleme damit hat, dass man mit '/../'-escapes an andere als die freigegebenen Webapps kommen kann, kann ja den Tomcat mit -Dorg.apache.tomcat.util.buf.UDecoder.ALLOW_ENCODED_SLASH=true laufen lassen, aber das behebt das Problem leider immer noch nicht; anstatt einer 400 kommt dann wieder eine 404.

Das liegt leider daran, dass Tomcat sich an Definitionen in der CGI-Spec hält; denn die Java Servlet Spezifikation baut an dieser Stelle auf die CGI-Spec auf, indem sie definert: request.getPathInfo(): "Same as the value of the CGI variable PATH_INFO." Und die CGI-Spezifikation besagt: http://tools.ietf.org/html/rfc3875#section-4.1.5

4.1.5. PATH_INFO
The PATH_INFO variable specifies a path to be interpreted by the CGI
script. It identifies the resource or sub-resource to be returned by
the CGI script, and is derived from the portion of the URI path
hierarchy following the part that identifies the script itself.
Unlike a URI path, the PATH_INFO is not URL-encoded, and cannot
contain path-segment parameters.

Oder anders ausgedrückt: Der Wert in PATH_INFO ist komplett URL-Decoded. Wenn von aussen /restServlet/users/userProfile/M%C3%BCller%2FSchultze reinkommt, dann kann durch die Brille der Servlet-Spezifikation nur der Wert /users/userProfile/Müller/Schultze entnommen werden, und was den letzten Slash betrifft, lässt sich das nicht mehr unterscheiden von der Variante ohne URLEncoding.

Fazit

Also bleibt es dabei: Die einzige brauchbare Lösung, einen Slash als Pfad-variable zu transportieren, besteht darin, dies entsprechende Variable ein zusätzliches Mal zu encoden. (Eventuell wäre es irgendwie möglich, dieses explizite URLDecoding auf der Server-seite irgendwo in Spring zu verstecken, etwa an der Stelle, wo die Pfad-variablen extrahiert werden, aber die Mühe habe ich mir nicht gemacht.)
Im Ergebnis ist das jetzt die stille Freude der Code-reviewer – jedes mal, wenn es eine neue REST-methode gibt, oder diese aufgerufen wird, lässt sich immer auf „vergessene“ URL-Encodings bei den Pfadvariablen prüfen. Inzwischen lässt sich im Code eine gewisse Tendenz erkennen, Parameter, die keine Datenbank-ID’s sind, lieber als „gewöhnliche“ Request-parameter durchzureichen. Das ist zwar nicht immer voll im guten REST-Geiste, aber zumindest mit dem Decoding auf der Serverseite klappt es da besser. (Auf der Client-seite ist es für der Query-part wegen des analogen Problems mit dem ‚&‘ allerdings kein Deut einfacher …)

Es ist schon ärgerlich, wenn eine Frameworks an so einer scheinbar elementaren Sache im Stich lassen, aber wenn die Spezifikation einen baden gehen lässt, dann wird es richtig haarig mit den Workarounds. Vielleicht fällt uns hier irgendwann etwas brauchbares ein – bis dahin fällt dieses Problem unter das Thema „Programmieren, wie es nicht sein sollte“.