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}