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