View Javadoc
1   /*
2    * Licensed to the Apache Software Foundation (ASF) under one
3    * or more contributor license agreements.  See the NOTICE file
4    * distributed with this work for additional information
5    * regarding copyright ownership.  The ASF licenses this file
6    * to you under the Apache License, Version 2.0 (the
7    * "License"); you may not use this file except in compliance
8    * with the License.  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,
13   * software distributed under the License is distributed on an
14   * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15   * KIND, either express or implied.  See the License for the
16   * specific language governing permissions and limitations
17   * under the License.
18   */
19  package org.apache.shiro.web.servlet;
20  
21  import org.apache.shiro.lang.util.StringUtils;
22  import org.owasp.encoder.Encode;
23  import org.slf4j.Logger;
24  import org.slf4j.LoggerFactory;
25  
26  import javax.servlet.http.HttpServletRequest;
27  import javax.servlet.http.HttpServletResponse;
28  import java.text.DateFormat;
29  import java.text.SimpleDateFormat;
30  import java.util.Calendar;
31  import java.util.Date;
32  import java.util.Locale;
33  import java.util.TimeZone;
34  
35  /**
36   * Default {@link Cookie Cookie} implementation.  'HttpOnly' is supported out of the box, even on
37   * Servlet {@code 2.4} and {@code 2.5} container implementations, using raw header writing logic and not
38   * {@link javax.servlet.http.Cookie javax.servlet.http.Cookie} objects (which only has 'HttpOnly' support in Servlet
39   * {@code 2.6} specifications and above).
40   *
41   * @since 1.0
42   */
43  @SuppressWarnings({"checkstyle:MethodCount", "checkstyle:ParameterNumber"})
44  public class SimpleCookie implements Cookie {
45  
46      /**
47       * {@code -1}, indicating the cookie should expire when the browser closes.
48       */
49      public static final int DEFAULT_MAX_AGE = -1;
50  
51      /**
52       * {@code -1} indicating that no version property should be set on the cookie.
53       */
54      public static final int DEFAULT_VERSION = -1;
55  
56      //These constants are protected on purpose so that the test case can use them
57      protected static final String NAME_VALUE_DELIMITER = "=";
58      protected static final String ATTRIBUTE_DELIMITER = "; ";
59      //1 day = 86,400,000 milliseconds
60      protected static final long DAY_MILLIS = 86400000;
61      protected static final String GMT_TIME_ZONE_ID = "GMT";
62      protected static final String COOKIE_DATE_FORMAT_STRING = "EEE, dd-MMM-yyyy HH:mm:ss z";
63  
64      protected static final String COOKIE_HEADER_NAME = "Set-Cookie";
65      protected static final String PATH_ATTRIBUTE_NAME = "Path";
66      protected static final String EXPIRES_ATTRIBUTE_NAME = "Expires";
67      protected static final String MAXAGE_ATTRIBUTE_NAME = "Max-Age";
68      protected static final String DOMAIN_ATTRIBUTE_NAME = "Domain";
69      protected static final String VERSION_ATTRIBUTE_NAME = "Version";
70      protected static final String COMMENT_ATTRIBUTE_NAME = "Comment";
71      protected static final String SECURE_ATTRIBUTE_NAME = "Secure";
72      protected static final String HTTP_ONLY_ATTRIBUTE_NAME = "HttpOnly";
73      protected static final String SAME_SITE_ATTRIBUTE_NAME = "SameSite";
74  
75      private static final Logger LOGGER = LoggerFactory.getLogger(SimpleCookie.class);
76  
77      private String name;
78      private String value;
79      private String comment;
80      private String domain;
81      private String path;
82      private int maxAge;
83      private int version;
84      private boolean secure;
85      private boolean httpOnly;
86      private SameSiteOptions sameSite;
87  
88      public SimpleCookie() {
89          this.maxAge = DEFAULT_MAX_AGE;
90          this.version = DEFAULT_VERSION;
91          //most of the cookies ever used by Shiro should be as secure as possible.
92          this.httpOnly = true;
93          this.sameSite = SameSiteOptions.LAX;
94      }
95  
96      public SimpleCookie(String name) {
97          this();
98          this.name = name;
99      }
100 
101     public SimpleCookie(Cookie cookie) {
102         this.name = cookie.getName();
103         this.value = cookie.getValue();
104         this.comment = cookie.getComment();
105         this.domain = cookie.getDomain();
106         this.path = cookie.getPath();
107         this.maxAge = Math.max(DEFAULT_MAX_AGE, cookie.getMaxAge());
108         this.version = Math.max(DEFAULT_VERSION, cookie.getVersion());
109         this.secure = cookie.isSecure();
110         this.httpOnly = cookie.isHttpOnly();
111         this.sameSite = cookie.getSameSite();
112     }
113 
114     @Override
115     public String getName() {
116         return name;
117     }
118 
119     @Override
120     public void setName(String name) {
121         if (!StringUtils.hasText(name)) {
122             throw new IllegalArgumentException("Name cannot be null/empty.");
123         }
124         this.name = name;
125     }
126 
127     @Override
128     public String getValue() {
129         return value;
130     }
131 
132     @Override
133     public void setValue(String value) {
134         this.value = value;
135     }
136 
137     @Override
138     public String getComment() {
139         return comment;
140     }
141 
142     @Override
143     public void setComment(String comment) {
144         this.comment = comment;
145     }
146 
147     @Override
148     public String getDomain() {
149         return domain;
150     }
151 
152     @Override
153     public void setDomain(String domain) {
154         this.domain = domain;
155     }
156 
157     @Override
158     public String getPath() {
159         return path;
160     }
161 
162     @Override
163     public void setPath(String path) {
164         this.path = path;
165     }
166 
167     @Override
168     public int getMaxAge() {
169         return maxAge;
170     }
171 
172     @Override
173     public void setMaxAge(int maxAge) {
174         this.maxAge = Math.max(DEFAULT_MAX_AGE, maxAge);
175     }
176 
177     @Override
178     public int getVersion() {
179         return version;
180     }
181 
182     @Override
183     public void setVersion(int version) {
184         this.version = Math.max(DEFAULT_VERSION, version);
185     }
186 
187     @Override
188     public boolean isSecure() {
189         return secure;
190     }
191 
192     @Override
193     public void setSecure(boolean secure) {
194         this.secure = secure;
195     }
196 
197     @Override
198     public boolean isHttpOnly() {
199         return httpOnly;
200     }
201 
202     @Override
203     public void setHttpOnly(boolean httpOnly) {
204         this.httpOnly = httpOnly;
205     }
206 
207     @Override
208     public SameSiteOptions getSameSite() {
209         return sameSite;
210     }
211 
212     @Override
213     public void setSameSite(SameSiteOptions sameSite) {
214         this.sameSite = sameSite;
215         if (this.sameSite == SameSiteOptions.NONE) {
216             // do not allow invalid cookies. Only secure cookies are allowed if SameSite is set to NONE.
217             setSecure(true);
218         }
219     }
220 
221     /**
222      * Returns the Cookie's calculated path setting.  If the {@link javax.servlet.http.Cookie#getPath() path} is {@code null},
223      * then the {@code request}'s {@link javax.servlet.http.HttpServletRequest#getContextPath() context path}
224      * will be returned. If getContextPath() is the empty string or null then the ROOT_PATH constant is returned.
225      *
226      * @param request the incoming HttpServletRequest
227      * @return the path to be used as the path when the cookie is created or removed
228      */
229     private String calculatePath(HttpServletRequest request) {
230         String path = StringUtils.clean(getPath());
231         if (!StringUtils.hasText(path)) {
232             path = StringUtils.clean(request.getContextPath());
233         }
234 
235         //fix for http://issues.apache.org/jira/browse/SHIRO-9:
236         if (path == null) {
237             path = ROOT_PATH;
238         }
239         LOGGER.trace("calculated path: {}", path);
240         return path;
241     }
242 
243     @Override
244     public void saveTo(HttpServletRequest request, HttpServletResponse response) {
245 
246         String name = getName();
247         String value = getValue();
248         String comment = getComment();
249         String domain = getDomain();
250         String path = calculatePath(request);
251         int maxAge = getMaxAge();
252         int version = getVersion();
253         boolean secure = isSecure();
254         boolean httpOnly = isHttpOnly();
255         SameSiteOptions sameSite = getSameSite();
256 
257         addCookieHeader(response, name, value, comment, domain, path, maxAge, version, secure, httpOnly, sameSite);
258     }
259 
260     private void addCookieHeader(HttpServletResponse response, String name, String value, String comment,
261                                  String domain, String path, int maxAge, int version,
262                                  boolean secure, boolean httpOnly, SameSiteOptions sameSite) {
263 
264         String headerValue = buildHeaderValue(name, value, comment, domain, path, maxAge, version, secure, httpOnly, sameSite);
265         response.addHeader(COOKIE_HEADER_NAME, headerValue);
266 
267         if (LOGGER.isDebugEnabled()) {
268             LOGGER.debug("Added HttpServletResponse Cookie [{}]", headerValue);
269         }
270     }
271 
272     /*
273      * This implementation followed the grammar defined here for convenience:
274      * <a href="http://github.com/abarth/http-state/blob/master/notes/2009-11-07-Yui-Naruse.txt">Cookie grammar</a>.
275      *
276      * @return the 'Set-Cookie' header value for this cookie instance.
277      */
278 
279     protected String buildHeaderValue(String name, String value, String comment,
280                                       String domain, String path, int maxAge, int version,
281                                       boolean secure, boolean httpOnly) {
282 
283         return buildHeaderValue(name, value, comment, domain, path, maxAge, version, secure, httpOnly, getSameSite());
284     }
285 
286     protected String buildHeaderValue(String name, String value, String comment,
287                                       String domain, String path, int maxAge, int version,
288                                       boolean secure, boolean httpOnly, SameSiteOptions sameSite) {
289 
290         if (!StringUtils.hasText(name)) {
291             throw new IllegalStateException("Cookie name cannot be null/empty.");
292         }
293 
294         StringBuilder sb = new StringBuilder(name).append(NAME_VALUE_DELIMITER);
295 
296         if (StringUtils.hasText(value)) {
297             sb.append(value);
298         }
299 
300         appendComment(sb, comment);
301         appendDomain(sb, domain);
302         appendPath(sb, path);
303         appendExpires(sb, maxAge);
304         appendVersion(sb, version);
305         appendSecure(sb, secure);
306         appendHttpOnly(sb, httpOnly);
307         appendSameSite(sb, sameSite);
308 
309         return sb.toString();
310 
311     }
312 
313     private void appendComment(StringBuilder sb, String comment) {
314         if (StringUtils.hasText(comment)) {
315             sb.append(ATTRIBUTE_DELIMITER);
316             sb.append(COMMENT_ATTRIBUTE_NAME).append(NAME_VALUE_DELIMITER).append(comment);
317         }
318     }
319 
320     private void appendDomain(StringBuilder sb, String domain) {
321         if (StringUtils.hasText(domain)) {
322             sb.append(ATTRIBUTE_DELIMITER);
323             sb.append(DOMAIN_ATTRIBUTE_NAME).append(NAME_VALUE_DELIMITER).append(domain);
324         }
325     }
326 
327     private void appendPath(StringBuilder sb, String path) {
328         if (StringUtils.hasText(path)) {
329             sb.append(ATTRIBUTE_DELIMITER);
330             sb.append(PATH_ATTRIBUTE_NAME).append(NAME_VALUE_DELIMITER).append(path);
331         }
332     }
333 
334     private void appendExpires(StringBuilder sb, int maxAge) {
335         // if maxAge is negative, cookie should should expire when browser closes
336         // Don't write the maxAge cookie value if it's negative - at least on Firefox it'll cause the
337         // cookie to be deleted immediately
338         // Write the expires header used by older browsers, but may be unnecessary
339         // and it is not by the spec, see http://www.faqs.org/rfcs/rfc2965.html
340         // TODO consider completely removing the following
341         if (maxAge >= 0) {
342             sb.append(ATTRIBUTE_DELIMITER);
343             sb.append(MAXAGE_ATTRIBUTE_NAME).append(NAME_VALUE_DELIMITER).append(maxAge);
344             sb.append(ATTRIBUTE_DELIMITER);
345             Date expires;
346             if (maxAge == 0) {
347                 //delete the cookie by specifying a time in the past (1 day ago):
348                 expires = new Date(System.currentTimeMillis() - DAY_MILLIS);
349             } else {
350                 //Value is in seconds.  So take 'now' and add that many seconds, and that's our expiration date:
351                 Calendar cal = Calendar.getInstance();
352                 cal.add(Calendar.SECOND, maxAge);
353                 expires = cal.getTime();
354             }
355             String formatted = toCookieDate(expires);
356             sb.append(EXPIRES_ATTRIBUTE_NAME).append(NAME_VALUE_DELIMITER).append(formatted);
357         }
358     }
359 
360     private void appendVersion(StringBuilder sb, int version) {
361         if (version > DEFAULT_VERSION) {
362             sb.append(ATTRIBUTE_DELIMITER);
363             sb.append(VERSION_ATTRIBUTE_NAME).append(NAME_VALUE_DELIMITER).append(version);
364         }
365     }
366 
367     private void appendSecure(StringBuilder sb, boolean secure) {
368         if (secure) {
369             sb.append(ATTRIBUTE_DELIMITER);
370             //No value for this attribute
371             sb.append(SECURE_ATTRIBUTE_NAME);
372         }
373     }
374 
375     private void appendHttpOnly(StringBuilder sb, boolean httpOnly) {
376         if (httpOnly) {
377             sb.append(ATTRIBUTE_DELIMITER);
378             //No value for this attribute
379             sb.append(HTTP_ONLY_ATTRIBUTE_NAME);
380         }
381     }
382 
383     private void appendSameSite(StringBuilder sb, SameSiteOptions sameSite) {
384         if (sameSite != null) {
385             sb.append(ATTRIBUTE_DELIMITER);
386             sb.append(SAME_SITE_ATTRIBUTE_NAME)
387                     .append(NAME_VALUE_DELIMITER)
388                     .append(sameSite.toString().toLowerCase(Locale.ENGLISH));
389         }
390     }
391 
392     /**
393      * Check whether the given {@code cookiePath} matches the {@code requestPath}
394      *
395      * @param cookiePath  cookiePath
396      * @param requestPath requestPath
397      * @return boolean
398      * @see <a href="https://tools.ietf.org/html/rfc6265#section-5.1.4">RFC 6265, Section 5.1.4 "Paths and Path-Match"</a>
399      */
400     private boolean pathMatches(String cookiePath, String requestPath) {
401         if (!requestPath.startsWith(cookiePath)) {
402             return false;
403         }
404 
405         return requestPath.length() == cookiePath.length()
406                 || cookiePath.charAt(cookiePath.length() - 1) == '/'
407                 || requestPath.charAt(cookiePath.length()) == '/';
408     }
409 
410     /**
411      * Formats a date into a cookie date compatible string (Netscape's specification).
412      *
413      * @param date the date to format
414      * @return an HTTP 1.0/1.1 Cookie compatible date string (GMT-based).
415      */
416     private static String toCookieDate(Date date) {
417         TimeZone tz = TimeZone.getTimeZone(GMT_TIME_ZONE_ID);
418         DateFormat fmt = new SimpleDateFormat(COOKIE_DATE_FORMAT_STRING, Locale.US);
419         fmt.setTimeZone(tz);
420         return fmt.format(date);
421     }
422 
423     @Override
424     public void removeFrom(HttpServletRequest request, HttpServletResponse response) {
425         String name = getName();
426         String value = DELETED_COOKIE_VALUE;
427         //don't need to add extra size to the response - comments are irrelevant for deletions
428         String comment = null;
429         String domain = getDomain();
430         String path = calculatePath(request);
431         //always zero for deletion
432         int maxAge = 0;
433         int version = getVersion();
434         boolean secure = isSecure();
435         //no need to add the extra text, plus the value 'deleteMe' is not sensitive at all
436         boolean httpOnly = false;
437         SameSiteOptions sameSite = getSameSite();
438 
439         addCookieHeader(response, name, value, null, domain, path, maxAge, version, secure, httpOnly, sameSite);
440 
441         LOGGER.trace("Removed '{}' cookie by setting maxAge=0", name);
442     }
443 
444     @Override
445     public String readValue(HttpServletRequest request, HttpServletResponse ignored) {
446         String name = getName();
447         String value = null;
448         javax.servlet.http.Cookie cookie = getCookie(request, name);
449         if (cookie != null) {
450             // Validate that the cookie is used at the correct place.
451             String path = StringUtils.clean(getPath());
452             if (path != null && !pathMatches(path, request.getRequestURI())) {
453                 LOGGER.warn("Found '{}' cookie at path '{}', but should be only used for '{}'",
454                         name, Encode.forHtml(request.getRequestURI()), path);
455             } else {
456                 value = cookie.getValue();
457                 LOGGER.debug("Found '{}' cookie value [{}]", name, Encode.forHtml(value));
458             }
459         } else {
460             LOGGER.trace("No '{}' cookie value", name);
461         }
462 
463         return value;
464     }
465 
466     /**
467      * Returns the cookie with the given name from the request or {@code null} if no cookie
468      * with that name could be found.
469      *
470      * @param request    the current executing http request.
471      * @param cookieName the name of the cookie to find and return.
472      * @return the cookie with the given name from the request or {@code null} if no cookie
473      * with that name could be found.
474      */
475     private static javax.servlet.http.Cookie getCookie(HttpServletRequest request, String cookieName) {
476         javax.servlet.http.Cookie[] cookies = request.getCookies();
477         if (cookies != null) {
478             for (javax.servlet.http.Cookie cookie : cookies) {
479                 if (cookie.getName().equals(cookieName)) {
480                     return cookie;
481                 }
482             }
483         }
484         return null;
485     }
486 }