001/* 002 * Licensed to the Apache Software Foundation (ASF) under one 003 * or more contributor license agreements. See the NOTICE file 004 * distributed with this work for additional information 005 * regarding copyright ownership. The ASF licenses this file 006 * to you under the Apache License, Version 2.0 (the 007 * "License"); you may not use this file except in compliance 008 * with the License. You may obtain a copy of the License at 009 * 010 * http://www.apache.org/licenses/LICENSE-2.0 011 * 012 * Unless required by applicable law or agreed to in writing, 013 * software distributed under the License is distributed on an 014 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 015 * KIND, either express or implied. See the License for the 016 * specific language governing permissions and limitations 017 * under the License. 018 */ 019package org.apache.shiro.web.servlet; 020 021import org.apache.shiro.util.StringUtils; 022import org.owasp.encoder.Encode; 023import org.slf4j.Logger; 024import org.slf4j.LoggerFactory; 025 026import javax.servlet.http.HttpServletRequest; 027import javax.servlet.http.HttpServletResponse; 028import java.text.DateFormat; 029import java.text.SimpleDateFormat; 030import java.util.Calendar; 031import java.util.Date; 032import java.util.Locale; 033import java.util.TimeZone; 034 035/** 036 * Default {@link Cookie Cookie} implementation. 'HttpOnly' is supported out of the box, even on 037 * Servlet {@code 2.4} and {@code 2.5} container implementations, using raw header writing logic and not 038 * {@link javax.servlet.http.Cookie javax.servlet.http.Cookie} objects (which only has 'HttpOnly' support in Servlet 039 * {@code 2.6} specifications and above). 040 * 041 * @since 1.0 042 */ 043public class SimpleCookie implements Cookie { 044 045 /** 046 * {@code -1}, indicating the cookie should expire when the browser closes. 047 */ 048 public static final int DEFAULT_MAX_AGE = -1; 049 050 /** 051 * {@code -1} indicating that no version property should be set on the cookie. 052 */ 053 public static final int DEFAULT_VERSION = -1; 054 055 //These constants are protected on purpose so that the test case can use them 056 protected static final String NAME_VALUE_DELIMITER = "="; 057 protected static final String ATTRIBUTE_DELIMITER = "; "; 058 protected static final long DAY_MILLIS = 86400000; //1 day = 86,400,000 milliseconds 059 protected static final String GMT_TIME_ZONE_ID = "GMT"; 060 protected static final String COOKIE_DATE_FORMAT_STRING = "EEE, dd-MMM-yyyy HH:mm:ss z"; 061 062 protected static final String COOKIE_HEADER_NAME = "Set-Cookie"; 063 protected static final String PATH_ATTRIBUTE_NAME = "Path"; 064 protected static final String EXPIRES_ATTRIBUTE_NAME = "Expires"; 065 protected static final String MAXAGE_ATTRIBUTE_NAME = "Max-Age"; 066 protected static final String DOMAIN_ATTRIBUTE_NAME = "Domain"; 067 protected static final String VERSION_ATTRIBUTE_NAME = "Version"; 068 protected static final String COMMENT_ATTRIBUTE_NAME = "Comment"; 069 protected static final String SECURE_ATTRIBUTE_NAME = "Secure"; 070 protected static final String HTTP_ONLY_ATTRIBUTE_NAME = "HttpOnly"; 071 protected static final String SAME_SITE_ATTRIBUTE_NAME = "SameSite"; 072 073 private static final transient Logger log = LoggerFactory.getLogger(SimpleCookie.class); 074 075 private String name; 076 private String value; 077 private String comment; 078 private String domain; 079 private String path; 080 private int maxAge; 081 private int version; 082 private boolean secure; 083 private boolean httpOnly; 084 private SameSiteOptions sameSite; 085 086 public SimpleCookie() { 087 this.maxAge = DEFAULT_MAX_AGE; 088 this.version = DEFAULT_VERSION; 089 this.httpOnly = true; //most of the cookies ever used by Shiro should be as secure as possible. 090 this.sameSite = SameSiteOptions.LAX; 091 } 092 093 public SimpleCookie(String name) { 094 this(); 095 this.name = name; 096 } 097 098 public SimpleCookie(Cookie cookie) { 099 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}