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.lang.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 */ 043@SuppressWarnings({"checkstyle:MethodCount", "checkstyle:ParameterNumber"}) 044public class SimpleCookie implements Cookie { 045 046 /** 047 * {@code -1}, indicating the cookie should expire when the browser closes. 048 */ 049 public static final int DEFAULT_MAX_AGE = -1; 050 051 /** 052 * {@code -1} indicating that no version property should be set on the cookie. 053 */ 054 public static final int DEFAULT_VERSION = -1; 055 056 //These constants are protected on purpose so that the test case can use them 057 protected static final String NAME_VALUE_DELIMITER = "="; 058 protected static final String ATTRIBUTE_DELIMITER = "; "; 059 //1 day = 86,400,000 milliseconds 060 protected static final long DAY_MILLIS = 86400000; 061 protected static final String GMT_TIME_ZONE_ID = "GMT"; 062 protected static final String COOKIE_DATE_FORMAT_STRING = "EEE, dd-MMM-yyyy HH:mm:ss z"; 063 064 protected static final String COOKIE_HEADER_NAME = "Set-Cookie"; 065 protected static final String PATH_ATTRIBUTE_NAME = "Path"; 066 protected static final String EXPIRES_ATTRIBUTE_NAME = "Expires"; 067 protected static final String MAXAGE_ATTRIBUTE_NAME = "Max-Age"; 068 protected static final String DOMAIN_ATTRIBUTE_NAME = "Domain"; 069 protected static final String VERSION_ATTRIBUTE_NAME = "Version"; 070 protected static final String COMMENT_ATTRIBUTE_NAME = "Comment"; 071 protected static final String SECURE_ATTRIBUTE_NAME = "Secure"; 072 protected static final String HTTP_ONLY_ATTRIBUTE_NAME = "HttpOnly"; 073 protected static final String SAME_SITE_ATTRIBUTE_NAME = "SameSite"; 074 075 private static final Logger LOGGER = LoggerFactory.getLogger(SimpleCookie.class); 076 077 private String name; 078 private String value; 079 private String comment; 080 private String domain; 081 private String path; 082 private int maxAge; 083 private int version; 084 private boolean secure; 085 private boolean httpOnly; 086 private SameSiteOptions sameSite; 087 088 public SimpleCookie() { 089 this.maxAge = DEFAULT_MAX_AGE; 090 this.version = DEFAULT_VERSION; 091 //most of the cookies ever used by Shiro should be as secure as possible. 092 this.httpOnly = true; 093 this.sameSite = SameSiteOptions.LAX; 094 } 095 096 public SimpleCookie(String name) { 097 this(); 098 this.name = name; 099 } 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}