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