HttpCookie.java revision 1ca3a957ea02c279214b668925e0b61e618da606
1/* Licensed to the Apache Software Foundation (ASF) under one or more 2 * contributor license agreements. See the NOTICE file distributed with 3 * this work for additional information regarding copyright ownership. 4 * The ASF licenses this file to You under the Apache License, Version 2.0 5 * (the "License"); you may not use this file except in compliance with 6 * the License. You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17package java.net; 18 19import java.text.DateFormat; 20import java.text.ParseException; 21import java.text.SimpleDateFormat; 22import java.util.ArrayList; 23import java.util.Date; 24import java.util.HashSet; 25import java.util.List; 26import java.util.Locale; 27import java.util.Set; 28 29/** 30 * An opaque key-value value pair held by an HTTP client to permit a stateful 31 * session with an HTTP server. This class parses cookie headers for all three 32 * commonly used HTTP cookie specifications: 33 * 34 * <ul> 35 * <li>The Netscape cookie spec is officially obsolete but widely used in 36 * practice. Each cookie contains one key-value pair and the following 37 * attributes: {@code Domain}, {@code Expires}, {@code Path}, and 38 * {@code Secure}. The {@link #getVersion() version} of cookies in this 39 * format is {@code 0}. 40 * <p>There are no accessors for the {@code Expires} attribute. When 41 * parsed, expires attributes are assigned to the {@link #getMaxAge() 42 * Max-Age} attribute as an offset from {@link System#currentTimeMillis() 43 * now}. 44 * <li><a href="http://www.ietf.org/rfc/rfc2109.txt">RFC 2109</a> formalizes 45 * the Netscape cookie spec. It replaces the {@code Expires} timestamp 46 * with a {@code Max-Age} duration and adds {@code Comment} and {@code 47 * Version} attributes. The {@link #getVersion() version} of cookies in 48 * this format is {@code 1}. 49 * <li><a href="http://www.ietf.org/rfc/rfc2965.txt">RFC 2965</a> refines 50 * RFC 2109. It adds {@code Discard}, {@code Port}, and {@code 51 * CommentURL} attributes and renames the header from {@code Set-Cookie} 52 * to {@code Set-Cookie2}. The {@link #getVersion() version} of cookies 53 * in this format is {@code 1}. 54 * </ul> 55 * 56 * <p>This implementation silently discards unrecognized attributes. In 57 * particular, the {@code HttpOnly} attribute is widely served but isn't in any 58 * of the above specs. It was introduced by Internet Explorer to prevent server 59 * cookies from being exposed in the DOM to JavaScript, etc. 60 * 61 * @since 1.6 62 */ 63public final class HttpCookie implements Cloneable { 64 65 private static final ThreadLocal<DateFormat[]> DATE_FORMATS = new ThreadLocal<DateFormat[]>() { 66 @Override protected DateFormat[] initialValue() { 67 return new DateFormat[] { 68 new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz"), // RFC 1123 69 new SimpleDateFormat("EEEE, dd-MMM-yy HH:mm:ss zzz"), // RFC 1036 70 new SimpleDateFormat("EEE MMM d HH:mm:ss yyyy"), // ANSI C asctime() 71 }; 72 } 73 }; 74 75 private static final Set<String> RESERVED_NAMES = new HashSet<String>(); 76 static { 77 RESERVED_NAMES.add("comment"); // RFC 2109 RFC 2965 78 RESERVED_NAMES.add("commenturl"); // RFC 2965 79 RESERVED_NAMES.add("discard"); // RFC 2965 80 RESERVED_NAMES.add("domain"); // Netscape RFC 2109 RFC 2965 81 RESERVED_NAMES.add("expires"); // Netscape 82 RESERVED_NAMES.add("max-age"); // RFC 2109 RFC 2965 83 RESERVED_NAMES.add("path"); // Netscape RFC 2109 RFC 2965 84 RESERVED_NAMES.add("port"); // RFC 2965 85 RESERVED_NAMES.add("secure"); // Netscape RFC 2109 RFC 2965 86 RESERVED_NAMES.add("version"); // RFC 2109 RFC 2965 87 } 88 89 /** 90 * Returns true if {@code host} matches the domain pattern {@code domain}. 91 * 92 * @param domainPattern a host name (like {@code android.com} or {@code 93 * localhost}), or a pattern to match subdomains of a domain name (like 94 * {@code .android.com}). A special case pattern is {@code .local}, 95 * which matches all hosts without a TLD (like {@code localhost}). 96 * @param host the host name or IP address from an HTTP request. 97 */ 98 public static boolean domainMatches(String domainPattern, String host) { 99 if (domainPattern == null || host == null) { 100 return false; 101 } 102 103 String a = host.toLowerCase(); 104 String b = domainPattern.toLowerCase(); 105 106 /* 107 * From the spec: "both host names are IP addresses and their host name strings match 108 * exactly; or both host names are FQDN strings and their host name strings match exactly" 109 */ 110 if (a.equals(b) && (isFullyQualifiedDomainName(a, 0) || !InetAddress.isHostName(a))) { 111 return true; 112 } 113 if (!isFullyQualifiedDomainName(a, 0)) { 114 return b.equals(".local"); 115 } 116 117 /* 118 * From the spec: "A is a HDN string and has the form NB, where N is a 119 * non-empty name string, B has the form .B', and B' is a HDN string. 120 * (So, x.y.com domain-matches .Y.com but not Y.com.) 121 */ 122 return a.length() > b.length() 123 && a.endsWith(b) 124 && ((b.startsWith(".") && isFullyQualifiedDomainName(b, 1)) || b.equals(".local")); 125 } 126 127 /** 128 * Returns true if {@code cookie} should be sent to or accepted from {@code uri}. Cookies match 129 * by directory prefix: URI "/foo" matches cookies "/foo", "/foo/" and "/foo/bar", but not "/" 130 * or "/foobar". 131 */ 132 static boolean pathMatches(URI uri, HttpCookie cookie) { 133 String uriPath = matchablePath(uri.getPath()); 134 String cookiePath = matchablePath(cookie.getPath()); 135 return uriPath.startsWith(cookiePath); 136 } 137 138 /** 139 * Returns a non-null path ending in "/". 140 */ 141 private static String matchablePath(String path) { 142 if (path == null) { 143 return "/"; 144 } else if (path.endsWith("/")) { 145 return path; 146 } else { 147 return path + "/"; 148 } 149 } 150 151 /** 152 * Returns true if {@code s.substring(firstCharacter)} contains a dot 153 * between its first and last characters, exclusive. This considers both 154 * {@code android.com} and {@code co.uk} to be fully qualified domain names, 155 * but not {@code android.com.}, {@code .com}. or {@code android}. 156 */ 157 private static boolean isFullyQualifiedDomainName(String s, int firstCharacter) { 158 int dotPosition = s.indexOf('.', firstCharacter + 1); 159 return dotPosition != -1 && dotPosition < s.length() - 1; 160 } 161 162 /** 163 * Constructs a cookie from a string. The string should comply with 164 * set-cookie or set-cookie2 header format as specified in RFC 2965. Since 165 * set-cookies2 syntax allows more than one cookie definitions in one 166 * header, the returned object is a list. 167 * 168 * @param header 169 * a set-cookie or set-cookie2 header. 170 * @return a list of constructed cookies 171 * @throws IllegalArgumentException 172 * if the string does not comply with cookie specification, or 173 * the cookie name contains illegal characters, or reserved 174 * tokens of cookie specification appears 175 * @throws NullPointerException 176 * if header is null 177 */ 178 public static List<HttpCookie> parse(String header) { 179 return new CookieParser(header).parse(); 180 } 181 182 static class CookieParser { 183 private static final String ATTRIBUTE_NAME_TERMINATORS = ",;= \t"; 184 private static final String WHITESPACE = " \t"; 185 private final String input; 186 private final String inputLowerCase; 187 private int pos = 0; 188 189 /* 190 * The cookie's version is set based on an overly complex heuristic: 191 * If it has an expires attribute, the version is 0. 192 * Otherwise, if it has a max-age attribute, the version is 1. 193 * Otherwise, if the cookie started with "Set-Cookie2", the version is 1. 194 * Otherwise, if it has any explicit version attributes, use the first one. 195 * Otherwise, the version is 0. 196 */ 197 boolean hasExpires = false; 198 boolean hasMaxAge = false; 199 boolean hasVersion = false; 200 201 CookieParser(String input) { 202 this.input = input; 203 this.inputLowerCase = input.toLowerCase(Locale.US); 204 } 205 206 public List<HttpCookie> parse() { 207 List<HttpCookie> cookies = new ArrayList<HttpCookie>(2); 208 209 // The RI permits input without either the "Set-Cookie:" or "Set-Cookie2" headers. 210 boolean pre2965 = true; 211 if (inputLowerCase.startsWith("set-cookie2:")) { 212 pos += "set-cookie2:".length(); 213 pre2965 = false; 214 hasVersion = true; 215 } else if (inputLowerCase.startsWith("set-cookie:")) { 216 pos += "set-cookie:".length(); 217 } 218 219 /* 220 * Read a comma-separated list of cookies. Note that the values may contain commas! 221 * <NAME> "=" <VALUE> ( ";" <ATTR NAME> ( "=" <ATTR VALUE> )? )* 222 */ 223 while (true) { 224 String name = readAttributeName(false); 225 if (name == null) { 226 if (cookies.isEmpty()) { 227 throw new IllegalArgumentException("No cookies in " + input); 228 } 229 return cookies; 230 } 231 232 if (!readEqualsSign()) { 233 throw new IllegalArgumentException( 234 "Expected '=' after " + name + " in " + input); 235 } 236 237 String value = readAttributeValue(pre2965 ? ";" : ",;"); 238 HttpCookie cookie = new HttpCookie(name, value); 239 cookie.version = pre2965 ? 0 : 1; 240 cookies.add(cookie); 241 242 /* 243 * Read the attributes of the current cookie. Each iteration of this loop should 244 * enter with input either exhausted or prefixed with ';' or ',' as in ";path=/" 245 * and ",COOKIE2=value2". 246 */ 247 while (true) { 248 skipWhitespace(); 249 if (pos == input.length()) { 250 break; 251 } 252 253 if (input.charAt(pos) == ',') { 254 pos++; 255 break; // a true comma delimiter; the current cookie is complete. 256 } else if (input.charAt(pos) == ';') { 257 pos++; 258 } 259 260 String attributeName = readAttributeName(true); 261 if (attributeName == null) { 262 continue; // for empty attribute as in "Set-Cookie: foo=Foo;;path=/" 263 } 264 265 /* 266 * Since expires and port attributes commonly include comma delimiters, always 267 * scan until a semicolon when parsing these attributes. 268 */ 269 String terminators = pre2965 270 || "expires".equals(attributeName) || "port".equals(attributeName) 271 ? ";" 272 : ";,"; 273 String attributeValue = null; 274 if (readEqualsSign()) { 275 attributeValue = readAttributeValue(terminators); 276 } 277 setAttribute(cookie, attributeName, attributeValue); 278 } 279 280 if (hasExpires) { 281 cookie.version = 0; 282 } else if (hasMaxAge) { 283 cookie.version = 1; 284 } 285 } 286 } 287 288 private void setAttribute(HttpCookie cookie, String name, String value) { 289 if (name.equals("comment") && cookie.comment == null) { 290 cookie.comment = value; 291 } else if (name.equals("commenturl") && cookie.commentURL == null) { 292 cookie.commentURL = value; 293 } else if (name.equals("discard")) { 294 cookie.discard = true; 295 } else if (name.equals("domain") && cookie.domain == null) { 296 cookie.domain = value; 297 } else if (name.equals("expires")) { 298 hasExpires = true; 299 if (cookie.maxAge == -1L) { 300 cookie.maxAge = 0; // only in case parsing fails 301 for (DateFormat dateFormat : DATE_FORMATS.get()) { 302 try { 303 Date date = dateFormat.parse(value); 304 cookie.setExpires(date); 305 return; 306 } catch (ParseException ignore) { 307 } 308 } 309 } 310 } else if (name.equals("max-age") && cookie.maxAge == -1L) { 311 hasMaxAge = true; 312 cookie.maxAge = Long.parseLong(value); 313 } else if (name.equals("path") && cookie.path == null) { 314 cookie.path = value; 315 } else if (name.equals("port") && cookie.portList == null) { 316 cookie.portList = value; 317 } else if (name.equals("secure")) { 318 cookie.secure = true; 319 } else if (name.equals("version") && !hasVersion) { 320 cookie.version = Integer.parseInt(value); 321 } 322 } 323 324 /** 325 * Returns the next attribute name, or null if the input has been 326 * exhausted. Returns wth the cursor on the delimiter that follows. 327 */ 328 private String readAttributeName(boolean returnLowerCase) { 329 skipWhitespace(); 330 int c = find(ATTRIBUTE_NAME_TERMINATORS); 331 String forSubstring = returnLowerCase ? inputLowerCase : input; 332 String result = pos < c ? forSubstring.substring(pos, c) : null; 333 pos = c; 334 return result; 335 } 336 337 /** 338 * Returns true if an equals sign was read and consumed. 339 */ 340 private boolean readEqualsSign() { 341 skipWhitespace(); 342 if (pos < input.length() && input.charAt(pos) == '=') { 343 pos++; 344 return true; 345 } 346 return false; 347 } 348 349 /** 350 * Reads an attribute value, by parsing either a quoted string or until 351 * the next character in {@code terminators}. The terminator character 352 * is not consumed. 353 */ 354 private String readAttributeValue(String terminators) { 355 skipWhitespace(); 356 357 // quoted string: read 'til the close quote 358 if (pos < input.length() && input.charAt(pos) == '"') { 359 pos++; 360 int closeQuote = input.indexOf('"', pos); 361 if (closeQuote == -1) { 362 throw new IllegalArgumentException("Unterminated string literal in " + input); 363 } 364 String result = input.substring(pos, closeQuote); 365 pos = closeQuote + 1; 366 return result; 367 } 368 369 int c = find(terminators); 370 String result = input.substring(pos, c); 371 pos = c; 372 return result; 373 } 374 375 /** 376 * Returns the index of the next character in {@code chars}, or the end 377 * of the string. 378 */ 379 private int find(String chars) { 380 for (int c = pos; c < input.length(); c++) { 381 if (chars.indexOf(input.charAt(c)) != -1) { 382 return c; 383 } 384 } 385 return input.length(); 386 } 387 388 private void skipWhitespace() { 389 for (; pos < input.length(); pos++) { 390 if (WHITESPACE.indexOf(input.charAt(pos)) == -1) { 391 break; 392 } 393 } 394 } 395 } 396 397 private String comment; 398 private String commentURL; 399 private boolean discard; 400 private String domain; 401 private long maxAge = -1l; 402 private final String name; 403 private String path; 404 private String portList; 405 private boolean secure; 406 private String value; 407 private int version = 1; 408 409 /** 410 * Creates a new cookie. 411 * 412 * @param name a non-empty string that contains only printable ASCII, no 413 * commas or semicolons, and is not prefixed with {@code $}. May not be 414 * an HTTP attribute name. 415 * @code value an opaque value from the HTTP server. 416 * @throws IllegalArgumentException if {@code name} is invalid. 417 */ 418 public HttpCookie(String name, String value) { 419 String ntrim = name.trim(); // erase leading and trailing whitespace 420 if (!isValidName(ntrim)) { 421 throw new IllegalArgumentException(); 422 } 423 424 this.name = ntrim; 425 this.value = value; 426 } 427 428 429 private boolean isValidName(String n) { 430 // name cannot be empty or begin with '$' or equals the reserved 431 // attributes (case-insensitive) 432 boolean isValid = !(n.length() == 0 || n.startsWith("$") || RESERVED_NAMES.contains(n.toLowerCase())); 433 if (isValid) { 434 for (int i = 0; i < n.length(); i++) { 435 char nameChar = n.charAt(i); 436 // name must be ASCII characters and cannot contain ';', ',' and 437 // whitespace 438 if (nameChar < 0 439 || nameChar >= 127 440 || nameChar == ';' 441 || nameChar == ',' 442 || (Character.isWhitespace(nameChar) && nameChar != ' ')) { 443 isValid = false; 444 break; 445 } 446 } 447 } 448 return isValid; 449 } 450 451 /** 452 * Returns the {@code Comment} attribute. 453 */ 454 public String getComment() { 455 return comment; 456 } 457 458 /** 459 * Returns the value of {@code CommentURL} attribute. 460 */ 461 public String getCommentURL() { 462 return commentURL; 463 } 464 465 /** 466 * Returns the {@code Discard} attribute. 467 */ 468 public boolean getDiscard() { 469 return discard; 470 } 471 472 /** 473 * Returns the {@code Domain} attribute. 474 */ 475 public String getDomain() { 476 return domain; 477 } 478 479 /** 480 * Returns the {@code Max-Age} attribute, in delta-seconds. 481 */ 482 public long getMaxAge() { 483 return maxAge; 484 } 485 486 /** 487 * Returns the name of this cookie. 488 */ 489 public String getName() { 490 return name; 491 } 492 493 /** 494 * Returns the {@code Path} attribute. This cookie is visible to all 495 * subpaths. 496 */ 497 public String getPath() { 498 return path; 499 } 500 501 /** 502 * Returns the {@code Port} attribute, usually containing comma-separated 503 * port numbers. 504 */ 505 public String getPortlist() { 506 return portList; 507 } 508 509 /** 510 * Returns the {@code Secure} attribute. 511 */ 512 public boolean getSecure() { 513 return secure; 514 } 515 516 /** 517 * Returns the value of this cookie. 518 */ 519 public String getValue() { 520 return value; 521 } 522 523 /** 524 * Returns the version of this cookie. 525 */ 526 public int getVersion() { 527 return version; 528 } 529 530 /** 531 * Returns true if this cookie's Max-Age is 0. 532 */ 533 public boolean hasExpired() { 534 // -1 indicates the cookie will persist until browser shutdown 535 // so the cookie is not expired. 536 if (maxAge == -1l) { 537 return false; 538 } 539 540 boolean expired = false; 541 if (maxAge <= 0l) { 542 expired = true; 543 } 544 return expired; 545 } 546 547 /** 548 * Set the {@code Comment} attribute of this cookie. 549 */ 550 public void setComment(String comment) { 551 this.comment = comment; 552 } 553 554 /** 555 * Set the {@code CommentURL} attribute of this cookie. 556 */ 557 public void setCommentURL(String commentURL) { 558 this.commentURL = commentURL; 559 } 560 561 /** 562 * Set the {@code Discard} attribute of this cookie. 563 */ 564 public void setDiscard(boolean discard) { 565 this.discard = discard; 566 } 567 568 /** 569 * Set the {@code Domain} attribute of this cookie. HTTP clients send 570 * cookies only to matching domains. 571 */ 572 public void setDomain(String pattern) { 573 domain = pattern == null ? null : pattern.toLowerCase(); 574 } 575 576 /** 577 * Sets the {@code Max-Age} attribute of this cookie. 578 */ 579 public void setMaxAge(long deltaSeconds) { 580 maxAge = deltaSeconds; 581 } 582 583 private void setExpires(Date expires) { 584 maxAge = (expires.getTime() - System.currentTimeMillis()) / 1000; 585 } 586 587 /** 588 * Set the {@code Path} attribute of this cookie. HTTP clients send cookies 589 * to this path and its subpaths. 590 */ 591 public void setPath(String path) { 592 this.path = path; 593 } 594 595 /** 596 * Set the {@code Port} attribute of this cookie. 597 */ 598 public void setPortlist(String portList) { 599 this.portList = portList; 600 } 601 602 /** 603 * Sets the {@code Secure} attribute of this cookie. 604 */ 605 public void setSecure(boolean secure) { 606 this.secure = secure; 607 } 608 609 /** 610 * Sets the opaque value of this cookie. 611 */ 612 public void setValue(String value) { 613 // FIXME: According to spec, version 0 cookie value does not allow many 614 // symbols. But RI does not implement it. Follow RI temporarily. 615 this.value = value; 616 } 617 618 /** 619 * Sets the {@code Version} attribute of the cookie. 620 * 621 * @throws IllegalArgumentException if v is neither 0 nor 1 622 */ 623 public void setVersion(int v) { 624 if (v != 0 && v != 1) { 625 throw new IllegalArgumentException(); 626 } 627 version = v; 628 } 629 630 @Override public Object clone() { 631 try { 632 return super.clone(); 633 } catch (CloneNotSupportedException e) { 634 throw new AssertionError(); 635 } 636 } 637 638 /** 639 * Returns true if {@code object} is a cookie with the same domain, name and 640 * path. Domain and name use case-insensitive comparison; path uses a 641 * case-sensitive comparison. 642 */ 643 @Override public boolean equals(Object object) { 644 if (object == this) { 645 return true; 646 } 647 if (object instanceof HttpCookie) { 648 HttpCookie that = (HttpCookie) object; 649 return name.equalsIgnoreCase(that.getName()) 650 && (domain != null ? domain.equalsIgnoreCase(that.domain) : that.domain == null) 651 && (path != null ? path.equals(that.path) : that.path == null); 652 } 653 return false; 654 } 655 656 /** 657 * Returns the hash code of this HTTP cookie: <pre> {@code 658 * name.toLowerCase().hashCode() 659 * + (domain == null ? 0 : domain.toLowerCase().hashCode()) 660 * + (path == null ? 0 : path.hashCode()) 661 * }</pre> 662 */ 663 @Override public int hashCode() { 664 return name.toLowerCase().hashCode() 665 + (domain == null ? 0 : domain.toLowerCase().hashCode()) 666 + (path == null ? 0 : path.hashCode()); 667 } 668 669 /** 670 * Returns a string representing this cookie in the format used by the 671 * {@code Cookie} header line in an HTTP request. 672 */ 673 @Override public String toString() { 674 if (version == 0) { 675 return name + "=" + value; 676 } 677 678 StringBuilder result = new StringBuilder() 679 .append(name) 680 .append("=") 681 .append("\"") 682 .append(value) 683 .append("\""); 684 appendAttribute(result, "Path", path); 685 appendAttribute(result, "Domain", domain); 686 appendAttribute(result, "Port", portList); 687 return result.toString(); 688 } 689 690 private void appendAttribute(StringBuilder builder, String name, String value) { 691 if (value != null && builder != null) { 692 builder.append(";$"); 693 builder.append(name); 694 builder.append("=\""); 695 builder.append(value); 696 builder.append("\""); 697 } 698 } 699}