├── .travis.yml ├── .gitignore ├── project.clj ├── LICENSE ├── src └── aleph │ └── http │ └── params.clj ├── README.md ├── test └── aleph │ └── http │ └── params_test.clj └── java └── org └── spootnik └── QueryStringDecoder.java /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: trusty 2 | sudo: false 3 | language: clojure 4 | jdk: 5 | - openjdk8 6 | - openjdk11 7 | branches: 8 | except: 9 | - gh-pages 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /classes 3 | /checkouts 4 | profiles.clj 5 | pom.xml 6 | pom.xml.asc 7 | *.jar 8 | *.class 9 | /.lein-* 10 | /.nrepl-port 11 | .hgignore 12 | .hg/ 13 | .cache 14 | .cpcache -------------------------------------------------------------------------------- /project.clj: -------------------------------------------------------------------------------- 1 | (defproject spootnik/aleph-params "0.1.8-SNAPSHOT" 2 | :description "Netty-based query string decoder" 3 | :url "https://github.com/pyr/aleph-params" 4 | :license {:name "MIT/ISC"} 5 | :dependencies [[org.clojure/clojure "1.11.1"]] 6 | :deploy-repositories [["releases" :clojars] ["snapshots" :clojars]] 7 | :java-source-paths ["java"] 8 | :javac-options ["-target" "1.8" "-source" "1.8" "-Xlint:-options"] 9 | :pedantic? :abort 10 | :global-vars {*warn-on-reflection* true} 11 | :profiles {:test {:dependencies [[exoscale/interceptor "0.1.10"] 12 | [aleph "0.4.7"]] 13 | :pedantic? :ignore 14 | :global-vars {*warn-on-reflection* false}}}) 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2019 Pierre-Yves Ritschard 2 | 3 | Permission to use, copy, modify, and distribute this software for any 4 | purpose with or without fee is hereby granted, provided that the above 5 | notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 14 | -------------------------------------------------------------------------------- /src/aleph/http/params.clj: -------------------------------------------------------------------------------- 1 | (ns aleph.http.params 2 | "Netty-inspired query string parameter handling functions" 3 | (:import org.spootnik.QueryStringDecoder)) 4 | 5 | (defn- extract-param 6 | "Transform query argument: keywordize key and extract single 7 | values." 8 | [[k vs]] 9 | [(keyword k) (if (= 1 (count vs)) (first vs) (vec vs))]) 10 | 11 | (defn parse-params 12 | "Given a query-string yield a map of argument name to value. 13 | Multiple occurences of the same argument yield a vector of 14 | values." 15 | [input] 16 | (when (some? input) 17 | (let [decoder (QueryStringDecoder. (str input) false)] 18 | (into {} (map extract-param) (.parameters decoder))))) 19 | 20 | (defn add-params 21 | "Add parsed params to a request at the `:get-params' key. 22 | Empty query strings yield an unmodified request map. 23 | 24 | The key looked up in the request is `:query-string`, as 25 | defined in https://github.com/ring-clojure/ring/blob/master/SPEC" 26 | [{:keys [query-string] :as request}] 27 | (cond-> request 28 | (some? query-string) (assoc :get-params (parse-params query-string)))) 29 | 30 | (defn wrap-params 31 | "A ring wrapper for parameters." 32 | [f] 33 | (comp f add-params)) 34 | 35 | (def interceptor 36 | "An interceptor-style handler for query args." 37 | {:name ::params 38 | :enter #(update % :request add-params)}) 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | aleph-params: Netty-inspired query string parameters decoding 2 | ============================================================== 3 | 4 | [![Build Status](https://secure.travis-ci.org/pyr/aleph-params.png)](http://travis-ci.org/pyr/aleph-params) 5 | [![cljdoc badge](https://cljdoc.org/badge/spootnik/aleph-params)](https://cljdoc.org/d/spootnik/aleph-params/CURRENT) 6 | [![Clojars Project](https://img.shields.io/clojars/v/spootnik/aleph-params.svg)](https://clojars.org/spootnik/aleph-params) 7 | 8 | Provides query string parameter parsing with no additional dependencies, 9 | borrowing the Netty battle tested code. 10 | 11 | ## Usage 12 | 13 | Further docs on https://cljdoc.org/d/spootnik/aleph-params/CURRENT 14 | 15 | ### Ring style 16 | 17 | Wrapped requests will have get-params at the `:get-params` 18 | key when applicable. 19 | 20 | ```clojure 21 | (require '[aleph.http.params :refer [wrap-params]]) 22 | 23 | (def handler 24 | (-> (constantly {:status 200 :body ""}) 25 | (wrap-params))) 26 | ``` 27 | 28 | ### Interceptor 29 | 30 | An interceptor is provided at `aleph.http.params/interceptor`, it provides 31 | a single `:enter` key and expects the request at the `:request` key in the 32 | context. Parameters are added at the `:get-params` key for downstream 33 | interceptors. 34 | 35 | ### Plain parser 36 | 37 | ```clojure 38 | (require '[aleph.http.params :refer [parse-params]]) 39 | 40 | (parse-params "?foo=bar") ;; => {:foo "bar"} 41 | ``` 42 | -------------------------------------------------------------------------------- /test/aleph/http/params_test.clj: -------------------------------------------------------------------------------- 1 | (ns aleph.http.params-test 2 | (:require [clojure.test :refer :all] 3 | [aleph.http.params :refer :all] 4 | [aleph.http :refer [start-server get]] 5 | [exoscale.interceptor.manifold :refer [execute]]) 6 | (:refer-clojure :exclude [get])) 7 | 8 | (defn get-port [] 9 | (let [sock (java.net.ServerSocket. 0) 10 | port (.getLocalPort sock)] 11 | (.close sock) 12 | port)) 13 | 14 | (deftest unit-test 15 | 16 | (is (= {:one "one" :two ["one" "two"]} 17 | (parse-params "?two=one&one=one&two=two"))) 18 | (is (nil? (parse-params nil)))) 19 | 20 | (defn handler [params request] 21 | (swap! params conj (:get-params request)) 22 | {:status 200 :body ""}) 23 | 24 | 25 | 26 | (deftest ring-integration-test 27 | (let [params (atom []) 28 | port (get-port) 29 | srv (start-server (wrap-params (partial handler params)) 30 | {:port port})] 31 | 32 | @(get (format "http://localhost:%s?two=one&one=one&two=two" port)) 33 | @(get (format "http://localhost:%s" port)) 34 | @(get (format "http://localhost:%s?foo=bar" port)) 35 | 36 | (is (= [{:one "one" :two ["one" "two"]} nil {:foo "bar"}] @params)) 37 | (.close srv))) 38 | 39 | (def ix-handler 40 | {:name :handler 41 | :enter (fn [{:keys [params request] :as ctx}] 42 | (swap! params conj (:get-params request)) 43 | (assoc ctx :response {:status 200 :body ""}))}) 44 | 45 | (deftest interceptor-integration-test 46 | (let [port (get-port) 47 | params (atom []) 48 | chain #(execute {:params params :request %} 49 | [{:name :exit :leave :response} 50 | interceptor 51 | ix-handler]) 52 | srv (start-server chain {:port port})] 53 | @(get (format "http://localhost:%d?two=one&one=one&two=two" port)) 54 | @(get (format "http://localhost:%d" port)) 55 | @(get (format "http://localhost:%d?foo=bar" port)) 56 | 57 | (is (= [{:one "one" :two ["one" "two"]} nil {:foo "bar"}] @params)) 58 | (.close srv))) 59 | -------------------------------------------------------------------------------- /java/org/spootnik/QueryStringDecoder.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2012 The Netty Project 3 | * 4 | * The Netty Project licenses this file to you under the Apache License, 5 | * version 2.0 (the "License"); you may not use this file except in compliance 6 | * with the License. You may obtain a copy of the License at: 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | * License for the specific language governing permissions and limitations 14 | * under the License. 15 | */ 16 | package org.spootnik; 17 | 18 | import java.net.URI; 19 | import java.net.URLDecoder; 20 | import java.nio.charset.Charset; 21 | import java.util.Arrays; 22 | import java.util.ArrayList; 23 | import java.util.Collections; 24 | import java.util.LinkedHashMap; 25 | import java.util.List; 26 | import java.util.Map; 27 | 28 | /** 29 | * Splits an HTTP query string into a path string and key-value parameter pairs. 30 | * This decoder is for one time use only. Create a new instance for each URI: 31 | *
 32 |  * {@link QueryStringDecoder} decoder = new {@link QueryStringDecoder}("/hello?recipient=world&x=1;y=2");
 33 |  * assert decoder.path().equals("/hello");
 34 |  * assert decoder.parameters().get("recipient").get(0).equals("world");
 35 |  * assert decoder.parameters().get("x").get(0).equals("1");
 36 |  * assert decoder.parameters().get("y").get(0).equals("2");
 37 |  * 
38 | * 39 | * This decoder can also decode the content of an HTTP POST request whose 40 | * content type is application/x-www-form-urlencoded: 41 | *
 42 |  * {@link QueryStringDecoder} decoder = new {@link QueryStringDecoder}("recipient=world&x=1;y=2", false);
 43 |  * ...
 44 |  * 
45 | * 46 | *

HashDOS vulnerability fix

47 | * 48 | * As a workaround to the HashDOS vulnerability, the decoder 49 | * limits the maximum number of decoded key-value parameter pairs, up to {@literal 1024} by 50 | * default, and you can configure it when you construct the decoder by passing an additional 51 | * integer parameter. 52 | * 53 | * @see QueryStringEncoder 54 | */ 55 | public class QueryStringDecoder { 56 | 57 | private static final String EMPTY_STRING = ""; 58 | private static final int DEFAULT_MAX_PARAMS = 1024; 59 | private static final Charset DEFAULT_CHARSET = Charset.forName("UTF-8"); 60 | 61 | private final Charset charset; 62 | private final String uri; 63 | private final int maxParams; 64 | private final boolean semicolonIsNormalChar; 65 | private int pathEndIdx; 66 | private String path; 67 | private Map> params; 68 | public static final char SPACE = 0x20; 69 | private static final byte[] HEX2B; 70 | 71 | static { 72 | HEX2B = new byte[Character.MAX_VALUE + 1]; 73 | Arrays.fill(HEX2B, (byte) -1); 74 | HEX2B['0'] = (byte) 0; 75 | HEX2B['1'] = (byte) 1; 76 | HEX2B['2'] = (byte) 2; 77 | HEX2B['3'] = (byte) 3; 78 | HEX2B['4'] = (byte) 4; 79 | HEX2B['5'] = (byte) 5; 80 | HEX2B['6'] = (byte) 6; 81 | HEX2B['7'] = (byte) 7; 82 | HEX2B['8'] = (byte) 8; 83 | HEX2B['9'] = (byte) 9; 84 | HEX2B['A'] = (byte) 10; 85 | HEX2B['B'] = (byte) 11; 86 | HEX2B['C'] = (byte) 12; 87 | HEX2B['D'] = (byte) 13; 88 | HEX2B['E'] = (byte) 14; 89 | HEX2B['F'] = (byte) 15; 90 | HEX2B['a'] = (byte) 10; 91 | HEX2B['b'] = (byte) 11; 92 | HEX2B['c'] = (byte) 12; 93 | HEX2B['d'] = (byte) 13; 94 | HEX2B['e'] = (byte) 14; 95 | HEX2B['f'] = (byte) 15; 96 | } 97 | 98 | /** 99 | * Creates a new decoder that decodes the specified URI. The decoder will 100 | * assume that the query string is encoded in UTF-8. 101 | */ 102 | public QueryStringDecoder(String uri) { 103 | this(uri, DEFAULT_CHARSET); 104 | } 105 | 106 | /** 107 | * Creates a new decoder that decodes the specified URI encoded in the 108 | * specified charset. 109 | */ 110 | public QueryStringDecoder(String uri, boolean hasPath) { 111 | this(uri, DEFAULT_CHARSET, hasPath); 112 | } 113 | 114 | /** 115 | * Creates a new decoder that decodes the specified URI encoded in the 116 | * specified charset. 117 | */ 118 | public QueryStringDecoder(String uri, Charset charset) { 119 | this(uri, charset, true); 120 | } 121 | 122 | /** 123 | * Creates a new decoder that decodes the specified URI encoded in the 124 | * specified charset. 125 | */ 126 | public QueryStringDecoder(String uri, Charset charset, boolean hasPath) { 127 | this(uri, charset, hasPath, DEFAULT_MAX_PARAMS); 128 | } 129 | 130 | /** 131 | * Creates a new decoder that decodes the specified URI encoded in the 132 | * specified charset. 133 | */ 134 | public QueryStringDecoder(String uri, Charset charset, boolean hasPath, int maxParams) { 135 | this(uri, charset, hasPath, maxParams, false); 136 | } 137 | 138 | /** 139 | * Creates a new decoder that decodes the specified URI encoded in the 140 | * specified charset. 141 | */ 142 | public QueryStringDecoder(String uri, Charset charset, boolean hasPath, 143 | int maxParams, boolean semicolonIsNormalChar) { 144 | this.uri = uri; 145 | this.charset = charset; 146 | this.maxParams = maxParams; 147 | this.semicolonIsNormalChar = semicolonIsNormalChar; 148 | 149 | // `-1` means that path end index will be initialized lazily 150 | pathEndIdx = hasPath ? -1 : 0; 151 | } 152 | 153 | /** 154 | * Creates a new decoder that decodes the specified URI. The decoder will 155 | * assume that the query string is encoded in UTF-8. 156 | */ 157 | public QueryStringDecoder(URI uri) { 158 | this(uri, DEFAULT_CHARSET); 159 | } 160 | 161 | /** 162 | * Creates a new decoder that decodes the specified URI encoded in the 163 | * specified charset. 164 | */ 165 | public QueryStringDecoder(URI uri, Charset charset) { 166 | this(uri, charset, DEFAULT_MAX_PARAMS); 167 | } 168 | 169 | /** 170 | * Creates a new decoder that decodes the specified URI encoded in the 171 | * specified charset. 172 | */ 173 | public QueryStringDecoder(URI uri, Charset charset, int maxParams) { 174 | this(uri, charset, maxParams, false); 175 | } 176 | 177 | /** 178 | * Creates a new decoder that decodes the specified URI encoded in the 179 | * specified charset. 180 | */ 181 | public QueryStringDecoder(URI uri, Charset charset, int maxParams, boolean semicolonIsNormalChar) { 182 | String rawPath = uri.getRawPath(); 183 | if (rawPath == null) { 184 | rawPath = EMPTY_STRING; 185 | } 186 | String rawQuery = uri.getRawQuery(); 187 | // Also take care of cut of things like "http://localhost" 188 | this.uri = rawQuery == null? rawPath : rawPath + '?' + rawQuery; 189 | this.charset = charset; 190 | this.maxParams = maxParams; 191 | this.semicolonIsNormalChar = semicolonIsNormalChar; 192 | pathEndIdx = rawPath.length(); 193 | } 194 | 195 | @Override 196 | public String toString() { 197 | return uri(); 198 | } 199 | 200 | /** 201 | * Returns the uri used to initialize this {@link QueryStringDecoder}. 202 | */ 203 | public String uri() { 204 | return uri; 205 | } 206 | 207 | /** 208 | * Returns the decoded path string of the URI. 209 | */ 210 | public String path() { 211 | if (path == null) { 212 | path = decodeComponent(uri, 0, pathEndIdx(), charset, true); 213 | } 214 | return path; 215 | } 216 | 217 | /** 218 | * Returns the decoded key-value parameter pairs of the URI. 219 | */ 220 | public Map> parameters() { 221 | if (params == null) { 222 | params = decodeParams(uri, pathEndIdx(), charset, maxParams, semicolonIsNormalChar); 223 | } 224 | return params; 225 | } 226 | 227 | /** 228 | * Returns the raw path string of the URI. 229 | */ 230 | public String rawPath() { 231 | return uri.substring(0, pathEndIdx()); 232 | } 233 | 234 | /** 235 | * Returns raw query string of the URI. 236 | */ 237 | public String rawQuery() { 238 | int start = pathEndIdx() + 1; 239 | return start < uri.length() ? uri.substring(start) : EMPTY_STRING; 240 | } 241 | 242 | private int pathEndIdx() { 243 | if (pathEndIdx == -1) { 244 | pathEndIdx = findPathEndIndex(uri); 245 | } 246 | return pathEndIdx; 247 | } 248 | 249 | private static Map> decodeParams(String s, int from, Charset charset, int paramsLimit, 250 | boolean semicolonIsNormalChar) { 251 | int len = s.length(); 252 | if (from >= len) { 253 | return Collections.emptyMap(); 254 | } 255 | if (s.charAt(from) == '?') { 256 | from++; 257 | } 258 | Map> params = new LinkedHashMap>(); 259 | int nameStart = from; 260 | int valueStart = -1; 261 | int i; 262 | loop: 263 | for (i = from; i < len; i++) { 264 | switch (s.charAt(i)) { 265 | case '=': 266 | if (nameStart == i) { 267 | nameStart = i + 1; 268 | } else if (valueStart < nameStart) { 269 | valueStart = i + 1; 270 | } 271 | break; 272 | case ';': 273 | if (semicolonIsNormalChar) { 274 | continue; 275 | } 276 | // fall-through 277 | case '&': 278 | if (addParam(s, nameStart, valueStart, i, params, charset)) { 279 | paramsLimit--; 280 | if (paramsLimit == 0) { 281 | return params; 282 | } 283 | } 284 | nameStart = i + 1; 285 | break; 286 | case '#': 287 | break loop; 288 | default: 289 | // continue 290 | } 291 | } 292 | addParam(s, nameStart, valueStart, i, params, charset); 293 | return params; 294 | } 295 | 296 | private static boolean addParam(String s, int nameStart, int valueStart, int valueEnd, 297 | Map> params, Charset charset) { 298 | if (nameStart >= valueEnd) { 299 | return false; 300 | } 301 | if (valueStart <= nameStart) { 302 | valueStart = valueEnd + 1; 303 | } 304 | String name = decodeComponent(s, nameStart, valueStart - 1, charset, false); 305 | String value = decodeComponent(s, valueStart, valueEnd, charset, false); 306 | List values = params.get(name); 307 | if (values == null) { 308 | values = new ArrayList(1); // Often there's only 1 value. 309 | params.put(name, values); 310 | } 311 | values.add(value); 312 | return true; 313 | } 314 | 315 | /** 316 | * Decodes a bit of a URL encoded by a browser. 317 | *

318 | * This is equivalent to calling {@link #decodeComponent(String, Charset)} 319 | * with the UTF-8 charset (recommended to comply with RFC 3986, Section 2). 320 | * @param s The string to decode (can be empty). 321 | * @return The decoded string, or {@code s} if there's nothing to decode. 322 | * If the string to decode is {@code null}, returns an empty string. 323 | * @throws IllegalArgumentException if the string contains a malformed 324 | * escape sequence. 325 | */ 326 | public static String decodeComponent(final String s) { 327 | return decodeComponent(s, DEFAULT_CHARSET); 328 | } 329 | 330 | /** 331 | * Decodes a bit of a URL encoded by a browser. 332 | *

333 | * The string is expected to be encoded as per RFC 3986, Section 2. 334 | * This is the encoding used by JavaScript functions {@code encodeURI} 335 | * and {@code encodeURIComponent}, but not {@code escape}. For example 336 | * in this encoding, é (in Unicode {@code U+00E9} or in UTF-8 337 | * {@code 0xC3 0xA9}) is encoded as {@code %C3%A9} or {@code %c3%a9}. 338 | *

339 | * This is essentially equivalent to calling 340 | * {@link URLDecoder#decode(String, String)} 341 | * except that it's over 2x faster and generates less garbage for the GC. 342 | * Actually this function doesn't allocate any memory if there's nothing 343 | * to decode, the argument itself is returned. 344 | * @param s The string to decode (can be empty). 345 | * @param charset The charset to use to decode the string (should really 346 | * be {@link CharsetUtil#UTF_8}. 347 | * @return The decoded string, or {@code s} if there's nothing to decode. 348 | * If the string to decode is {@code null}, returns an empty string. 349 | * @throws IllegalArgumentException if the string contains a malformed 350 | * escape sequence. 351 | */ 352 | public static String decodeComponent(final String s, final Charset charset) { 353 | if (s == null) { 354 | return EMPTY_STRING; 355 | } 356 | return decodeComponent(s, 0, s.length(), charset, false); 357 | } 358 | 359 | private static String decodeComponent(String s, int from, int toExcluded, Charset charset, boolean isPath) { 360 | int len = toExcluded - from; 361 | if (len <= 0) { 362 | return EMPTY_STRING; 363 | } 364 | int firstEscaped = -1; 365 | for (int i = from; i < toExcluded; i++) { 366 | char c = s.charAt(i); 367 | if (c == '%' || c == '+' && !isPath) { 368 | firstEscaped = i; 369 | break; 370 | } 371 | } 372 | if (firstEscaped == -1) { 373 | return s.substring(from, toExcluded); 374 | } 375 | 376 | // Each encoded byte takes 3 characters (e.g. "%20") 377 | int decodedCapacity = (toExcluded - firstEscaped) / 3; 378 | byte[] buf = new byte[decodedCapacity]; 379 | int bufIdx; 380 | 381 | StringBuilder strBuf = new StringBuilder(len); 382 | strBuf.append(s, from, firstEscaped); 383 | 384 | for (int i = firstEscaped; i < toExcluded; i++) { 385 | char c = s.charAt(i); 386 | if (c != '%') { 387 | strBuf.append(c != '+' || isPath? c : SPACE); 388 | continue; 389 | } 390 | 391 | bufIdx = 0; 392 | do { 393 | if (i + 3 > toExcluded) { 394 | throw new IllegalArgumentException("unterminated escape sequence at index " + i + " of: " + s); 395 | } 396 | buf[bufIdx++] = decodeHexByte(s, i + 1); 397 | i += 3; 398 | } while (i < toExcluded && s.charAt(i) == '%'); 399 | i--; 400 | 401 | strBuf.append(new String(buf, 0, bufIdx, charset)); 402 | } 403 | return strBuf.toString(); 404 | } 405 | 406 | private static int findPathEndIndex(String uri) { 407 | int len = uri.length(); 408 | for (int i = 0; i < len; i++) { 409 | char c = uri.charAt(i); 410 | if (c == '?' || c == '#') { 411 | return i; 412 | } 413 | } 414 | return len; 415 | } 416 | 417 | public static int decodeHexNibble(final char c) { 418 | // Character.digit() is not used here, as it addresses a larger 419 | // set of characters (both ASCII and full-width latin letters). 420 | return HEX2B[c]; 421 | } 422 | 423 | public static byte decodeHexByte(CharSequence s, int pos) { 424 | int hi = decodeHexNibble(s.charAt(pos)); 425 | int lo = decodeHexNibble(s.charAt(pos + 1)); 426 | if (hi == -1 || lo == -1) { 427 | throw new IllegalArgumentException(String.format( 428 | "invalid hex byte '%s' at index %d of '%s'", s.subSequence(pos, pos + 2), pos, s)); 429 | } 430 | return (byte) ((hi << 4) + lo); 431 | } 432 | } 433 | --------------------------------------------------------------------------------