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.util.ArrayList; 20import java.util.Arrays; 21import java.util.Date; 22import java.util.HashSet; 23import java.util.List; 24import java.util.Locale; 25import java.util.Set; 26import libcore.net.http.HttpDate; 27import libcore.util.Objects; 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 Set<String> RESERVED_NAMES = new HashSet<String>(); 66 67 static { 68 RESERVED_NAMES.add("comment"); // RFC 2109 RFC 2965 69 RESERVED_NAMES.add("commenturl"); // RFC 2965 70 RESERVED_NAMES.add("discard"); // RFC 2965 71 RESERVED_NAMES.add("domain"); // Netscape RFC 2109 RFC 2965 72 RESERVED_NAMES.add("expires"); // Netscape 73 RESERVED_NAMES.add("max-age"); // RFC 2109 RFC 2965 74 RESERVED_NAMES.add("path"); // Netscape RFC 2109 RFC 2965 75 RESERVED_NAMES.add("port"); // RFC 2965 76 RESERVED_NAMES.add("secure"); // Netscape RFC 2109 RFC 2965 77 RESERVED_NAMES.add("version"); // RFC 2109 RFC 2965 78 } 79 80 /** 81 * Returns true if {@code host} matches the domain pattern {@code domain}. 82 * 83 * @param domainPattern a host name (like {@code android.com} or {@code 84 * localhost}), or a pattern to match subdomains of a domain name (like 85 * {@code .android.com}). A special case pattern is {@code .local}, 86 * which matches all hosts without a TLD (like {@code localhost}). 87 * @param host the host name or IP address from an HTTP request. 88 */ 89 public static boolean domainMatches(String domainPattern, String host) { 90 if (domainPattern == null || host == null) { 91 return false; 92 } 93 94 String a = host.toLowerCase(Locale.US); 95 String b = domainPattern.toLowerCase(Locale.US); 96 97 /* 98 * From the spec: "both host names are IP addresses and their host name strings match 99 * exactly; or both host names are FQDN strings and their host name strings match exactly" 100 */ 101 if (a.equals(b) && (isFullyQualifiedDomainName(a, 0) || InetAddress.isNumeric(a))) { 102 return true; 103 } 104 if (!isFullyQualifiedDomainName(a, 0)) { 105 return b.equals(".local"); 106 } 107 108 /* 109 * Not in the spec! If prefixing a hostname with "." causes it to equal the domain pattern, 110 * then it should match. This is necessary so that the pattern ".google.com" will match the 111 * host "google.com". 112 */ 113 if (b.length() == 1 + a.length() 114 && b.startsWith(".") 115 && b.endsWith(a) 116 && isFullyQualifiedDomainName(b, 1)) { 117 return true; 118 } 119 120 /* 121 * From the spec: "A is a HDN string and has the form NB, where N is a 122 * non-empty name string, B has the form .B', and B' is a HDN string. 123 * (So, x.y.com domain-matches .Y.com but not Y.com.) 124 */ 125 return a.length() > b.length() 126 && a.endsWith(b) 127 && ((b.startsWith(".") && isFullyQualifiedDomainName(b, 1)) || b.equals(".local")); 128 } 129 130 /** 131 * Returns true if {@code cookie} should be sent to or accepted from {@code uri} with respect 132 * to the cookie's path. Cookies match by directory prefix: URI "/foo" matches cookies "/foo", 133 * "/foo/" and "/foo/bar", but not "/" or "/foobar". 134 */ 135 static boolean pathMatches(HttpCookie cookie, URI uri) { 136 String uriPath = matchablePath(uri.getPath()); 137 String cookiePath = matchablePath(cookie.getPath()); 138 return uriPath.startsWith(cookiePath); 139 } 140 141 /** 142 * Returns true if {@code cookie} should be sent to {@code uri} with respect to the cookie's 143 * secure attribute. Secure cookies should not be sent in insecure (ie. non-HTTPS) requests. 144 */ 145 static boolean secureMatches(HttpCookie cookie, URI uri) { 146 return !cookie.getSecure() || "https".equalsIgnoreCase(uri.getScheme()); 147 } 148 149 /** 150 * Returns true if {@code cookie} should be sent to {@code uri} with respect to the cookie's 151 * port list. 152 */ 153 static boolean portMatches(HttpCookie cookie, URI uri) { 154 if (cookie.getPortlist() == null) { 155 return true; 156 } 157 return Arrays.asList(cookie.getPortlist().split(",")) 158 .contains(Integer.toString(uri.getEffectivePort())); 159 } 160 161 /** 162 * Returns a non-null path ending in "/". 163 */ 164 private static String matchablePath(String path) { 165 if (path == null) { 166 return "/"; 167 } else if (path.endsWith("/")) { 168 return path; 169 } else { 170 return path + "/"; 171 } 172 } 173 174 /** 175 * Returns true if {@code s.substring(firstCharacter)} contains a dot 176 * between its first and last characters, exclusive. This considers both 177 * {@code android.com} and {@code co.uk} to be fully qualified domain names, 178 * but not {@code android.com.}, {@code .com}. or {@code android}. 179 * 180 * <p>Although this implements the cookie spec's definition of FQDN, it is 181 * not general purpose. For example, this returns true for IPv4 addresses. 182 */ 183 private static boolean isFullyQualifiedDomainName(String s, int firstCharacter) { 184 int dotPosition = s.indexOf('.', firstCharacter + 1); 185 return dotPosition != -1 && dotPosition < s.length() - 1; 186 } 187 188 /** 189 * Constructs a cookie from a string. The string should comply with 190 * set-cookie or set-cookie2 header format as specified in 191 * <a href="http://www.ietf.org/rfc/rfc2965.txt">RFC 2965</a>. Since 192 * set-cookies2 syntax allows more than one cookie definitions in one 193 * header, the returned object is a list. 194 * 195 * @param header 196 * a set-cookie or set-cookie2 header. 197 * @return a list of constructed cookies 198 * @throws IllegalArgumentException 199 * if the string does not comply with cookie specification, or 200 * the cookie name contains illegal characters, or reserved 201 * tokens of cookie specification appears 202 * @throws NullPointerException 203 * if header is null 204 */ 205 public static List<HttpCookie> parse(String header) { 206 return new CookieParser(header).parse(); 207 } 208 209 static class CookieParser { 210 private static final String ATTRIBUTE_NAME_TERMINATORS = ",;= \t"; 211 private static final String WHITESPACE = " \t"; 212 private final String input; 213 private final String inputLowerCase; 214 private int pos = 0; 215 216 /* 217 * The cookie's version is set based on an overly complex heuristic: 218 * If it has an expires attribute, the version is 0. 219 * Otherwise, if it has a max-age attribute, the version is 1. 220 * Otherwise, if the cookie started with "Set-Cookie2", the version is 1. 221 * Otherwise, if it has any explicit version attributes, use the first one. 222 * Otherwise, the version is 0. 223 */ 224 boolean hasExpires = false; 225 boolean hasMaxAge = false; 226 boolean hasVersion = false; 227 228 CookieParser(String input) { 229 this.input = input; 230 this.inputLowerCase = input.toLowerCase(Locale.US); 231 } 232 233 public List<HttpCookie> parse() { 234 List<HttpCookie> cookies = new ArrayList<HttpCookie>(2); 235 236 // The RI permits input without either the "Set-Cookie:" or "Set-Cookie2" headers. 237 boolean pre2965 = true; 238 if (inputLowerCase.startsWith("set-cookie2:")) { 239 pos += "set-cookie2:".length(); 240 pre2965 = false; 241 hasVersion = true; 242 } else if (inputLowerCase.startsWith("set-cookie:")) { 243 pos += "set-cookie:".length(); 244 } 245 246 /* 247 * Read a comma-separated list of cookies. Note that the values may contain commas! 248 * <NAME> "=" <VALUE> ( ";" <ATTR NAME> ( "=" <ATTR VALUE> )? )* 249 */ 250 while (true) { 251 String name = readAttributeName(false); 252 if (name == null) { 253 if (cookies.isEmpty()) { 254 throw new IllegalArgumentException("No cookies in " + input); 255 } 256 return cookies; 257 } 258 259 if (!readEqualsSign()) { 260 throw new IllegalArgumentException( 261 "Expected '=' after " + name + " in " + input); 262 } 263 264 String value = readAttributeValue(pre2965 ? ";" : ",;"); 265 HttpCookie cookie = new HttpCookie(name, value); 266 cookie.version = pre2965 ? 0 : 1; 267 cookies.add(cookie); 268 269 /* 270 * Read the attributes of the current cookie. Each iteration of this loop should 271 * enter with input either exhausted or prefixed with ';' or ',' as in ";path=/" 272 * and ",COOKIE2=value2". 273 */ 274 while (true) { 275 skipWhitespace(); 276 if (pos == input.length()) { 277 break; 278 } 279 280 if (input.charAt(pos) == ',') { 281 pos++; 282 break; // a true comma delimiter; the current cookie is complete. 283 } else if (input.charAt(pos) == ';') { 284 pos++; 285 } 286 287 String attributeName = readAttributeName(true); 288 if (attributeName == null) { 289 continue; // for empty attribute as in "Set-Cookie: foo=Foo;;path=/" 290 } 291 292 /* 293 * Since expires and port attributes commonly include comma delimiters, always 294 * scan until a semicolon when parsing these attributes. 295 */ 296 String terminators = pre2965 297 || "expires".equals(attributeName) || "port".equals(attributeName) 298 ? ";" 299 : ";,"; 300 String attributeValue = null; 301 if (readEqualsSign()) { 302 attributeValue = readAttributeValue(terminators); 303 } 304 setAttribute(cookie, attributeName, attributeValue); 305 } 306 307 if (hasExpires) { 308 cookie.version = 0; 309 } else if (hasMaxAge) { 310 cookie.version = 1; 311 } 312 } 313 } 314 315 private void setAttribute(HttpCookie cookie, String name, String value) { 316 if (name.equals("comment") && cookie.comment == null) { 317 cookie.comment = value; 318 } else if (name.equals("commenturl") && cookie.commentURL == null) { 319 cookie.commentURL = value; 320 } else if (name.equals("discard")) { 321 cookie.discard = true; 322 } else if (name.equals("domain") && cookie.domain == null) { 323 cookie.domain = value; 324 } else if (name.equals("expires")) { 325 hasExpires = true; 326 if (cookie.maxAge == -1L) { 327 Date date = HttpDate.parse(value); 328 if (date != null) { 329 cookie.setExpires(date); 330 } else { 331 cookie.maxAge = 0; 332 } 333 } 334 } else if (name.equals("max-age") && cookie.maxAge == -1L) { 335 hasMaxAge = true; 336 cookie.maxAge = Long.parseLong(value); 337 } else if (name.equals("path") && cookie.path == null) { 338 cookie.path = value; 339 } else if (name.equals("port") && cookie.portList == null) { 340 cookie.portList = value != null ? value : ""; 341 } else if (name.equals("secure")) { 342 cookie.secure = true; 343 } else if (name.equals("version") && !hasVersion) { 344 cookie.version = Integer.parseInt(value); 345 } 346 } 347 348 /** 349 * Returns the next attribute name, or null if the input has been 350 * exhausted. Returns wth the cursor on the delimiter that follows. 351 */ 352 private String readAttributeName(boolean returnLowerCase) { 353 skipWhitespace(); 354 int c = find(ATTRIBUTE_NAME_TERMINATORS); 355 String forSubstring = returnLowerCase ? inputLowerCase : input; 356 String result = pos < c ? forSubstring.substring(pos, c) : null; 357 pos = c; 358 return result; 359 } 360 361 /** 362 * Returns true if an equals sign was read and consumed. 363 */ 364 private boolean readEqualsSign() { 365 skipWhitespace(); 366 if (pos < input.length() && input.charAt(pos) == '=') { 367 pos++; 368 return true; 369 } 370 return false; 371 } 372 373 /** 374 * Reads an attribute value, by parsing either a quoted string or until 375 * the next character in {@code terminators}. The terminator character 376 * is not consumed. 377 */ 378 private String readAttributeValue(String terminators) { 379 skipWhitespace(); 380 381 /* 382 * Quoted string: read 'til the close quote. The spec mentions only "double quotes" 383 * but RI bug 6901170 claims that 'single quotes' are also used. 384 */ 385 if (pos < input.length() && (input.charAt(pos) == '"' || input.charAt(pos) == '\'')) { 386 char quoteCharacter = input.charAt(pos++); 387 int closeQuote = input.indexOf(quoteCharacter, pos); 388 if (closeQuote == -1) { 389 throw new IllegalArgumentException("Unterminated string literal in " + input); 390 } 391 String result = input.substring(pos, closeQuote); 392 pos = closeQuote + 1; 393 return result; 394 } 395 396 int c = find(terminators); 397 String result = input.substring(pos, c); 398 pos = c; 399 return result; 400 } 401 402 /** 403 * Returns the index of the next character in {@code chars}, or the end 404 * of the string. 405 */ 406 private int find(String chars) { 407 for (int c = pos; c < input.length(); c++) { 408 if (chars.indexOf(input.charAt(c)) != -1) { 409 return c; 410 } 411 } 412 return input.length(); 413 } 414 415 private void skipWhitespace() { 416 for (; pos < input.length(); pos++) { 417 if (WHITESPACE.indexOf(input.charAt(pos)) == -1) { 418 break; 419 } 420 } 421 } 422 } 423 424 private String comment; 425 private String commentURL; 426 private boolean discard; 427 private String domain; 428 private long maxAge = -1l; 429 private final String name; 430 private String path; 431 private String portList; 432 private boolean secure; 433 private String value; 434 private int version = 1; 435 436 /** 437 * Creates a new cookie. 438 * 439 * @param name a non-empty string that contains only printable ASCII, no 440 * commas or semicolons, and is not prefixed with {@code $}. May not be 441 * an HTTP attribute name. 442 * @param value an opaque value from the HTTP server. 443 * @throws IllegalArgumentException if {@code name} is invalid. 444 */ 445 public HttpCookie(String name, String value) { 446 String ntrim = name.trim(); // erase leading and trailing whitespace 447 if (!isValidName(ntrim)) { 448 throw new IllegalArgumentException(); 449 } 450 451 this.name = ntrim; 452 this.value = value; 453 } 454 455 456 private boolean isValidName(String n) { 457 // name cannot be empty or begin with '$' or equals the reserved 458 // attributes (case-insensitive) 459 boolean isValid = !(n.length() == 0 || n.startsWith("$") 460 || RESERVED_NAMES.contains(n.toLowerCase(Locale.US))); 461 if (isValid) { 462 for (int i = 0; i < n.length(); i++) { 463 char nameChar = n.charAt(i); 464 // name must be ASCII characters and cannot contain ';', ',' and 465 // whitespace 466 if (nameChar < 0 467 || nameChar >= 127 468 || nameChar == ';' 469 || nameChar == ',' 470 || (Character.isWhitespace(nameChar) && nameChar != ' ')) { 471 isValid = false; 472 break; 473 } 474 } 475 } 476 return isValid; 477 } 478 479 /** 480 * Returns the {@code Comment} attribute. 481 */ 482 public String getComment() { 483 return comment; 484 } 485 486 /** 487 * Returns the value of {@code CommentURL} attribute. 488 */ 489 public String getCommentURL() { 490 return commentURL; 491 } 492 493 /** 494 * Returns the {@code Discard} attribute. 495 */ 496 public boolean getDiscard() { 497 return discard; 498 } 499 500 /** 501 * Returns the {@code Domain} attribute. 502 */ 503 public String getDomain() { 504 return domain; 505 } 506 507 /** 508 * Returns the {@code Max-Age} attribute, in delta-seconds. 509 */ 510 public long getMaxAge() { 511 return maxAge; 512 } 513 514 /** 515 * Returns the name of this cookie. 516 */ 517 public String getName() { 518 return name; 519 } 520 521 /** 522 * Returns the {@code Path} attribute. This cookie is visible to all 523 * subpaths. 524 */ 525 public String getPath() { 526 return path; 527 } 528 529 /** 530 * Returns the {@code Port} attribute, usually containing comma-separated 531 * port numbers. A null port indicates that the cookie may be sent to any 532 * port. The empty string indicates that the cookie should only be sent to 533 * the port of the originating request. 534 */ 535 public String getPortlist() { 536 return portList; 537 } 538 539 /** 540 * Returns the {@code Secure} attribute. 541 */ 542 public boolean getSecure() { 543 return secure; 544 } 545 546 /** 547 * Returns the value of this cookie. 548 */ 549 public String getValue() { 550 return value; 551 } 552 553 /** 554 * Returns the version of this cookie. 555 */ 556 public int getVersion() { 557 return version; 558 } 559 560 /** 561 * Returns true if this cookie's Max-Age is 0. 562 */ 563 public boolean hasExpired() { 564 // -1 indicates the cookie will persist until browser shutdown 565 // so the cookie is not expired. 566 if (maxAge == -1l) { 567 return false; 568 } 569 570 boolean expired = false; 571 if (maxAge <= 0l) { 572 expired = true; 573 } 574 return expired; 575 } 576 577 /** 578 * Set the {@code Comment} attribute of this cookie. 579 */ 580 public void setComment(String comment) { 581 this.comment = comment; 582 } 583 584 /** 585 * Set the {@code CommentURL} attribute of this cookie. 586 */ 587 public void setCommentURL(String commentURL) { 588 this.commentURL = commentURL; 589 } 590 591 /** 592 * Set the {@code Discard} attribute of this cookie. 593 */ 594 public void setDiscard(boolean discard) { 595 this.discard = discard; 596 } 597 598 /** 599 * Set the {@code Domain} attribute of this cookie. HTTP clients send 600 * cookies only to matching domains. 601 */ 602 public void setDomain(String pattern) { 603 domain = pattern == null ? null : pattern.toLowerCase(Locale.US); 604 } 605 606 /** 607 * Sets the {@code Max-Age} attribute of this cookie. 608 */ 609 public void setMaxAge(long deltaSeconds) { 610 maxAge = deltaSeconds; 611 } 612 613 private void setExpires(Date expires) { 614 maxAge = (expires.getTime() - System.currentTimeMillis()) / 1000; 615 } 616 617 /** 618 * Set the {@code Path} attribute of this cookie. HTTP clients send cookies 619 * to this path and its subpaths. 620 */ 621 public void setPath(String path) { 622 this.path = path; 623 } 624 625 /** 626 * Set the {@code Port} attribute of this cookie. 627 */ 628 public void setPortlist(String portList) { 629 this.portList = portList; 630 } 631 632 /** 633 * Sets the {@code Secure} attribute of this cookie. 634 */ 635 public void setSecure(boolean secure) { 636 this.secure = secure; 637 } 638 639 /** 640 * Sets the opaque value of this cookie. 641 */ 642 public void setValue(String value) { 643 // FIXME: According to spec, version 0 cookie value does not allow many 644 // symbols. But RI does not implement it. Follow RI temporarily. 645 this.value = value; 646 } 647 648 /** 649 * Sets the {@code Version} attribute of the cookie. 650 * 651 * @throws IllegalArgumentException if v is neither 0 nor 1 652 */ 653 public void setVersion(int v) { 654 if (v != 0 && v != 1) { 655 throw new IllegalArgumentException(); 656 } 657 version = v; 658 } 659 660 @Override public Object clone() { 661 try { 662 return super.clone(); 663 } catch (CloneNotSupportedException e) { 664 throw new AssertionError(); 665 } 666 } 667 668 /** 669 * Returns true if {@code object} is a cookie with the same domain, name and 670 * path. Domain and name use case-insensitive comparison; path uses a 671 * case-sensitive comparison. 672 */ 673 @Override public boolean equals(Object object) { 674 if (object == this) { 675 return true; 676 } 677 if (object instanceof HttpCookie) { 678 HttpCookie that = (HttpCookie) object; 679 return name.equalsIgnoreCase(that.getName()) 680 && (domain != null ? domain.equalsIgnoreCase(that.domain) : that.domain == null) 681 && Objects.equal(path, that.path); 682 } 683 return false; 684 } 685 686 /** 687 * Returns the hash code of this HTTP cookie: <pre> {@code 688 * name.toLowerCase(Locale.US).hashCode() 689 * + (domain == null ? 0 : domain.toLowerCase(Locale.US).hashCode()) 690 * + (path == null ? 0 : path.hashCode()) 691 * }</pre> 692 */ 693 @Override public int hashCode() { 694 return name.toLowerCase(Locale.US).hashCode() 695 + (domain == null ? 0 : domain.toLowerCase(Locale.US).hashCode()) 696 + (path == null ? 0 : path.hashCode()); 697 } 698 699 /** 700 * Returns a string representing this cookie in the format used by the 701 * {@code Cookie} header line in an HTTP request. 702 */ 703 @Override public String toString() { 704 if (version == 0) { 705 return name + "=" + value; 706 } 707 708 StringBuilder result = new StringBuilder() 709 .append(name) 710 .append("=") 711 .append("\"") 712 .append(value) 713 .append("\""); 714 appendAttribute(result, "Path", path); 715 appendAttribute(result, "Domain", domain); 716 appendAttribute(result, "Port", portList); 717 return result.toString(); 718 } 719 720 private void appendAttribute(StringBuilder builder, String name, String value) { 721 if (value != null && builder != null) { 722 builder.append(";$"); 723 builder.append(name); 724 builder.append("=\""); 725 builder.append(value); 726 builder.append("\""); 727 } 728 } 729} 730