1 /*
2  * JBoss, Home of Professional Open Source.
3  * Copyright 2014 Red Hat, Inc., and individual contributors
4  * as indicated by the @author tags.
5  *
6  * Licensed under the Apache License, Version 2.0 (the "License");
7  * you may not use this file except in compliance with the License.
8  * You may obtain a copy of the License at
9  *
10  *     http://www.apache.org/licenses/LICENSE-2.0
11  *
12  *  Unless required by applicable law or agreed to in writing, software
13  *  distributed under the License is distributed on an "AS IS" BASIS,
14  *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15  *  See the License for the specific language governing permissions and
16  *  limitations under the License.
17  */

18
19 package io.undertow.util;
20
21 import io.undertow.UndertowLogger;
22 import io.undertow.UndertowMessages;
23 import io.undertow.server.handlers.Cookie;
24 import io.undertow.server.handlers.CookieImpl;
25
26 import java.util.HashMap;
27 import java.util.List;
28 import java.util.Map;
29 import java.util.TreeMap;
30
31 /**
32  * Class that contains utility methods for dealing with cookies.
33  *
34  * @author Stuart Douglas
35  * @author Andre Dietisheim
36  */

37 public class Cookies {
38
39     public static final String DOMAIN = "$Domain";
40     public static final String VERSION = "$Version";
41     public static final String PATH = "$Path";
42
43
44     /**
45      * Parses a "Set-Cookie:" response header value into its cookie representation. The header value is parsed according to the
46      * syntax that's defined in RFC2109:
47      *
48      * <pre>
49      * <code>
50      *  set-cookie      =       "Set-Cookie:" cookies
51      *   cookies         =       1#cookie
52      *   cookie          =       NAME "=" VALUE *(";" cookie-av)
53      *   NAME            =       attr
54      *   VALUE           =       value
55      *   cookie-av       =       "Comment" "=" value
56      *                   |       "Domain" "=" value
57      *                   |       "Max-Age" "=" value
58      *                   |       "Path" "=" value
59      *                   |       "Secure"
60      *                   |       "Version" "=" 1*DIGIT
61      *
62      * </code>
63      * </pre>
64      *
65      * @param headerValue The header value
66      * @return The cookie
67      *
68      * @see Cookie
69      * @see <a href="http://tools.ietf.org/search/rfc2109">rfc2109</a>
70      */

71     public static Cookie parseSetCookieHeader(final String headerValue) {
72
73         String key = null;
74         CookieImpl cookie = null;
75         int state = 0;
76         int current = 0;
77         for (int i = 0; i < headerValue.length(); ++i) {
78             char c = headerValue.charAt(i);
79             switch (state) {
80                 case 0: {
81                     //reading key
82                     if (c == '=') {
83                         key = headerValue.substring(current, i);
84                         current = i + 1;
85                         state = 1;
86                     } else if ((c == ';' || c == ' ') && current == i) {
87                         current++;
88                     } else if (c == ';') {
89                         if (cookie == null) {
90                             throw UndertowMessages.MESSAGES.couldNotParseCookie(headerValue);
91                         } else {
92                             handleValue(cookie, headerValue.substring(current, i), null);
93                         }
94                         current = i + 1;
95                     }
96                     break;
97                 }
98                 case 1: {
99                     if (c == ';') {
100                         if (cookie == null) {
101                             cookie = new CookieImpl(key, headerValue.substring(current, i));
102                         } else {
103                             handleValue(cookie, key, headerValue.substring(current, i));
104                         }
105                         state = 0;
106                         current = i + 1;
107                         key = null;
108                     } else if (c == '"' && current == i) {
109                         current++;
110                         state = 2;
111                     }
112                     break;
113                 }
114                 case 2: {
115                     if (c == '"') {
116                         if (cookie == null) {
117                             cookie = new CookieImpl(key, headerValue.substring(current, i));
118                         } else {
119                             handleValue(cookie, key, headerValue.substring(current, i));
120                         }
121                         state = 0;
122                         current = i + 1;
123                         key = null;
124                     }
125                     break;
126                 }
127             }
128         }
129         if (key == null) {
130             if (current != headerValue.length()) {
131                 handleValue(cookie, headerValue.substring(current, headerValue.length()), null);
132             }
133         } else {
134             if (current != headerValue.length()) {
135                 if(cookie == null) {
136                     cookie = new CookieImpl(key, headerValue.substring(current, headerValue.length()));
137                 } else {
138                     handleValue(cookie, key, headerValue.substring(current, headerValue.length()));
139                 }
140             } else {
141                 handleValue(cookie, key, null);
142             }
143         }
144
145         return cookie;
146     }
147
148     private static void handleValue(CookieImpl cookie, String key, String value) {
149         if (key.equalsIgnoreCase("path")) {
150             cookie.setPath(value);
151         } else if (key.equalsIgnoreCase("domain")) {
152             cookie.setDomain(value);
153         } else if (key.equalsIgnoreCase("max-age")) {
154             cookie.setMaxAge(Integer.parseInt(value));
155         } else if (key.equalsIgnoreCase("expires")) {
156             cookie.setExpires(DateUtils.parseDate(value));
157         } else if (key.equalsIgnoreCase("discard")) {
158             cookie.setDiscard(true);
159         } else if (key.equalsIgnoreCase("secure")) {
160             cookie.setSecure(true);
161         } else if (key.equalsIgnoreCase("httpOnly")) {
162             cookie.setHttpOnly(true);
163         } else if (key.equalsIgnoreCase("version")) {
164             cookie.setVersion(Integer.parseInt(value));
165         } else if (key.equalsIgnoreCase("comment")) {
166             cookie.setComment(value);
167         } else if (key.equalsIgnoreCase("samesite")) {
168             cookie.setSameSite(true);
169             cookie.setSameSiteMode(value);
170         }
171         //otherwise ignore this key-value pair
172     }
173
174     /**
175     /**
176      * Parses the cookies from a list of "Cookie:" header values. The cookie header values are parsed according to RFC2109 that
177      * defines the following syntax:
178      *
179      * <pre>
180      * <code>
181      * cookie          =  "Cookie:" cookie-version
182      *                    1*((";" | ",") cookie-value)
183      * cookie-value    =  NAME "=" VALUE [";" path] [";" domain]
184      * cookie-version  =  "$Version" "=" value
185      * NAME            =  attr
186      * VALUE           =  value
187      * path            =  "$Path" "=" value
188      * domain          =  "$Domain" "=" value
189      * </code>
190      * </pre>
191      *
192      * @param maxCookies The maximum number of cookies. Used to prevent hash collision attacks
193      * @param allowEqualInValue if true equal characters are allowed in cookie values
194      * @param cookies The cookie values to parse
195      * @return A pared cookie map
196      *
197      * @see Cookie
198      * @see <a href="http://tools.ietf.org/search/rfc2109">rfc2109</a>
199      */

200     public static Map<String, Cookie> parseRequestCookies(int maxCookies, boolean allowEqualInValue, List<String> cookies) {
201         return parseRequestCookies(maxCookies, allowEqualInValue, cookies, LegacyCookieSupport.COMMA_IS_SEPARATOR);
202     }
203
204     static Map<String, Cookie> parseRequestCookies(int maxCookies, boolean allowEqualInValue, List<String> cookies, boolean commaIsSeperator) {
205         return parseRequestCookies(maxCookies, allowEqualInValue, cookies, commaIsSeperator, LegacyCookieSupport.ALLOW_HTTP_SEPARATORS_IN_V0);
206     }
207
208     static Map<String, Cookie> parseRequestCookies(int maxCookies, boolean allowEqualInValue, List<String> cookies, boolean commaIsSeperator, boolean allowHttpSepartorsV0) {
209         if (cookies == null) {
210             return new TreeMap<>();
211         }
212         final Map<String, Cookie> parsedCookies = new TreeMap<>();
213
214         for (String cookie : cookies) {
215             parseCookie(cookie, parsedCookies, maxCookies, allowEqualInValue, commaIsSeperator, allowHttpSepartorsV0);
216         }
217         return parsedCookies;
218     }
219
220     private static void parseCookie(final String cookie, final Map<String, Cookie> parsedCookies, int maxCookies, boolean allowEqualInValue, boolean commaIsSeperator, boolean allowHttpSepartorsV0) {
221         int state = 0;
222         String name = null;
223         int start = 0;
224         boolean containsEscapedQuotes = false;
225         int cookieCount = parsedCookies.size();
226         final Map<String, String> cookies = new HashMap<>();
227         final Map<String, String> additional = new HashMap<>();
228         for (int i = 0; i < cookie.length(); ++i) {
229             char c = cookie.charAt(i);
230             switch (state) {
231                 case 0: {
232                     //eat leading whitespace
233                     if (c == ' ' || c == '\t' || c == ';') {
234                         start = i + 1;
235                         break;
236                     }
237                     state = 1;
238                     //fall through
239                 }
240                 case 1: {
241                     //extract key
242                     if (c == '=') {
243                         name = cookie.substring(start, i);
244                         start = i + 1;
245                         state = 2;
246                     } else if (c == ';' || (commaIsSeperator && c == ',')) {
247                         if(name != null) {
248                             cookieCount = createCookie(name, cookie.substring(start, i), maxCookies, cookieCount, cookies, additional);
249                         } else if(UndertowLogger.REQUEST_LOGGER.isTraceEnabled()) {
250                             UndertowLogger.REQUEST_LOGGER.trace("Ignoring invalid cookies in header " + cookie);
251                         }
252                         state = 0;
253                         start = i + 1;
254                     }
255                     break;
256                 }
257                 case 2: {
258                     //extract value
259                     if (c == ';' || (commaIsSeperator && c == ',')) {
260                         cookieCount = createCookie(name, cookie.substring(start, i), maxCookies, cookieCount, cookies, additional);
261                         state = 0;
262                         start = i + 1;
263                     } else if (c == '"' && start == i) { //only process the " if it is the first character
264                         containsEscapedQuotes = false;
265                         state = 3;
266                         start = i + 1;
267                     } else if (c == '=') {
268                         if (!allowEqualInValue && !allowHttpSepartorsV0) {
269                             cookieCount = createCookie(name, cookie.substring(start, i), maxCookies, cookieCount, cookies, additional);
270                             state = 4;
271                             start = i + 1;
272                         }
273                     } else if (c != ':' && !allowHttpSepartorsV0 && LegacyCookieSupport.isHttpSeparator(c)) {
274                         // http separators are not allowed in V0 cookie value unless io.undertow.legacy.cookie.ALLOW_HTTP_SEPARATORS_IN_V0 is set to true.
275                         // However, "<hostcontroller-name>:<server-name>" (e.g. master:node1) is added as jvmRoute (instance-id) by default in WildFly domain mode.
276                         // Though ":" is http separator, we allow it by default. Because, when Undertow runs as a proxy server (mod_cluster),
277                         // we need to handle jvmRoute containing ":" in the request cookie value correctly to maintain the sticky session.
278                         cookieCount = createCookie(name, cookie.substring(start, i), maxCookies, cookieCount, cookies, additional);
279                         state = 4;
280                         start = i + 1;
281                     }
282                     break;
283                 }
284                 case 3: {
285                     //extract quoted value
286                     if (c == '"') {
287                         cookieCount = createCookie(name, containsEscapedQuotes ? unescapeDoubleQuotes(cookie.substring(start, i)) : cookie.substring(start, i), maxCookies, cookieCount, cookies, additional);
288                         state = 0;
289                         start = i + 1;
290                     }
291                     // Skip the next double quote char '"' when it is escaped by backslash '\' (i.e. \") inside the quoted value
292                     if (c == '\\' && (i + 1 < cookie.length()) && cookie.charAt(i + 1) == '"') {
293                         // But..., do not skip at the following conditions
294                         if (i + 2 == cookie.length()) { // Cookie: key="\" or Cookie: key="...\"
295                             break;
296                         }
297                         if (i + 2 < cookie.length() && (cookie.charAt(i + 2) == ';'      // Cookie: key="\"; key2=...
298                                 || (commaIsSeperator && cookie.charAt(i + 2) == ','))) { // Cookie: key="\", key2=...
299                             break;
300                         }
301                         // Skip the next double quote char ('"' behind '\') in the cookie value
302                         i++;
303                         containsEscapedQuotes = true;
304                     }
305                     break;
306                 }
307                 case 4: {
308                     //skip value portion behind '='
309                     if (c == ';' || (commaIsSeperator && c == ',')) {
310                         state = 0;
311                     }
312                     start = i + 1;
313                     break;
314                 }
315             }
316         }
317         if (state == 2) {
318             createCookie(name, cookie.substring(start), maxCookies, cookieCount, cookies, additional);
319         }
320
321         for (final Map.Entry<String, String> entry : cookies.entrySet()) {
322             Cookie c = new CookieImpl(entry.getKey(), entry.getValue());
323             String domain = additional.get(DOMAIN);
324             if (domain != null) {
325                 c.setDomain(domain);
326             }
327             String version = additional.get(VERSION);
328             if (version != null) {
329                 c.setVersion(Integer.parseInt(version));
330             }
331             String path = additional.get(PATH);
332             if (path != null) {
333                 c.setPath(path);
334             }
335             parsedCookies.put(c.getName(), c);
336         }
337     }
338
339     private static int createCookie(final String name, final String value, int maxCookies, int cookieCount,
340             final Map<String, String> cookies, final Map<String, String> additional) {
341         if (!name.isEmpty() && name.charAt(0) == '$') {
342             if(additional.containsKey(name)) {
343                 return cookieCount;
344             }
345             additional.put(name, value);
346             return cookieCount;
347         } else {
348             if (cookieCount == maxCookies) {
349                 throw UndertowMessages.MESSAGES.tooManyCookies(maxCookies);
350             }
351             if(cookies.containsKey(name)) {
352                 return cookieCount;
353             }
354             cookies.put(name, value);
355             return ++cookieCount;
356         }
357     }
358
359     private static String unescapeDoubleQuotes(final String value) {
360         if (value == null || value.isEmpty()) {
361             return value;
362         }
363
364         // Replace all escaped double quote (\") to double quote (")
365         char[] tmp = new char[value.length()];
366         int dest = 0;
367         for(int i = 0; i < value.length(); i++) {
368             if (value.charAt(i) == '\\' && (i + 1 < value.length()) && value.charAt(i + 1) == '"') {
369                 i++;
370             }
371             tmp[dest] = value.charAt(i);
372             dest++;
373         }
374         return new String(tmp, 0, dest);
375     }
376
377     private Cookies() {
378
379     }
380 }
381