View Javadoc
1   /*
2    * Copyright (c) 2002-2026 Gargoyle Software Inc.
3    *
4    * Licensed under the Apache License, Version 2.0 (the "License");
5    * you may not use this file except in compliance with the License.
6    * You may obtain a copy of the License at
7    * https://www.apache.org/licenses/LICENSE-2.0
8    *
9    * Unless required by applicable law or agreed to in writing, software
10   * distributed under the License is distributed on an "AS IS" BASIS,
11   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12   * See the License for the specific language governing permissions and
13   * limitations under the License.
14   */
15  package org.htmlunit.http;
16  
17  import java.net.MalformedURLException;
18  import java.net.URL;
19  import java.text.ParseException;
20  import java.text.SimpleDateFormat;
21  import java.time.ZonedDateTime;
22  import java.time.format.DateTimeFormatter;
23  import java.time.format.DateTimeParseException;
24  import java.util.Date;
25  import java.util.List;
26  import java.util.Locale;
27  import java.util.regex.Pattern;
28  
29  import org.htmlunit.BrowserVersion;
30  import org.htmlunit.util.StringUtils;
31  import org.htmlunit.util.UrlUtils;
32  
33  /**
34   * Cookie parser based on the HTTP cookie specification.
35   * - RFC 2109 and RFC 2965 (versioned cookies)
36   * - Netscape cookie specification
37   * - Cookie attributes:  Domain, Path, Expires, Max-Age, Secure, HttpOnly, SameSite
38   *
39   * @author Ronald Brill
40   */
41  public final class CookieParser {
42  
43      /** The cookie name used for cookies with no name. */
44      public static final String EMPTY_COOKIE_NAME = "HTMLUNIT_EMPTY_COOKIE";
45  
46      /** Workaround for domain of local files. */
47      public static final String LOCAL_FILESYSTEM_DOMAIN = "local_filesystem";
48  
49      private static final String[] DATE_PATTERNS = {
50          "EEE, dd MMM yyyy HH:mm:ss z",      // RFC 1123
51          "EEE, dd-MMM-yy HH:mm:ss z",        // RFC 1036
52          "EEE MMM dd HH:mm:ss yyyy",         // ANSI C asctime()
53          "EEE, dd-MMM-yyyy HH:mm:ss z",      // Variant
54          "EEE MMM dd yyyy HH:mm: ss z",      // Variant
55          "EEE, dd MMM yy HH:mm:ss z"         // Variant
56      };
57  
58      // Max-Age should be 400 days at most
59      // https://httpwg.org/http-extensions/draft-ietf-httpbis-rfc6265bis.html#section-5.5
60      private static final int MAX_MAX_AGE = 400 * 24 * 60 * 60;
61  
62      private static final Pattern MAX_AGE_PATTERN = Pattern.compile("-?[0-9]+");
63  
64      private CookieParser() {
65          // Utility class
66      }
67  
68      /**
69       * Parses a cookie string and returns a list of Cookie objects.
70       *
71       * @param cookieString the string to parse
72       * @param pageUrl the page url as root
73       * @param browserVersion the {@link BrowserVersion}
74       * @return a list of {@link Cookie}'s
75       * @throws MalformedCookieException in case the cookie does not conform to the spec
76       */
77      public static List<Cookie> parseCookie(final String cookieString, final URL pageUrl,
78                                             final BrowserVersion browserVersion) throws MalformedCookieException {
79  
80          if (cookieString == null) {
81              throw new MalformedCookieException("Cookie string cannot be null");
82          }
83  
84          // Normalize the URL for cookie origin
85          final CookieOrigin origin = buildCookieOrigin(pageUrl);
86  
87          // Parse the cookie string
88          final String normalizedCookieString = normalizeCookieString(cookieString);
89          final ParsedCookie parsedCookie = parseNetscapeCookie(normalizedCookieString);
90  
91          // Create and validate the cookie
92          final Cookie cookie = createCookie(parsedCookie, origin, browserVersion);
93  
94          return List.of(cookie);
95      }
96  
97      /**
98       * Normalizes the cookie string by handling empty names and whitespace.
99       */
100     private static String normalizeCookieString(String cookieString) {
101         cookieString = cookieString.trim();
102 
103         if (cookieString.isEmpty()) {
104             return EMPTY_COOKIE_NAME + "=";
105         }
106 
107         // Find the position of the first '=' or ';'
108         final int equalsPos = cookieString.indexOf('=');
109         final int semicolonPos = cookieString.indexOf(';');
110 
111         // Determine where the name ends
112         final int endPos;
113         if (equalsPos < 0 && semicolonPos < 0) {
114             // No '=' or ';', entire string is the value with no name
115             return EMPTY_COOKIE_NAME + "=" + cookieString;
116         }
117         else if (equalsPos < 0) {
118             // No '=', only ';'
119             return EMPTY_COOKIE_NAME + "=" + cookieString;
120         }
121         else if (semicolonPos < 0 || equalsPos < semicolonPos) {
122             endPos = equalsPos;
123         }
124         else {
125             // ';' comes before '='
126             return EMPTY_COOKIE_NAME + "=" + cookieString;
127         }
128 
129         // Check if name is empty or blank
130         final String name = cookieString.substring(0, endPos).trim();
131         if (name.isEmpty()) {
132             return EMPTY_COOKIE_NAME + cookieString.substring(endPos);
133         }
134 
135         return cookieString;
136     }
137 
138     /**
139      * Parses a Netscape-style cookie string.
140      */
141     private static ParsedCookie parseNetscapeCookie(final String cookieString)
142             throws MalformedCookieException {
143 
144         // Split by semicolon, but be careful with the first name=value pair
145         final String[] parts = cookieString.split(";");
146 
147         if (parts.length == 0) {
148             throw new MalformedCookieException("Empty cookie string");
149         }
150 
151         // First part is the name=value pair
152         final String[] nameValue = splitNameValue(parts[0].trim());
153 
154         final ParsedCookie result = new ParsedCookie(nameValue[0], nameValue[1]);
155 
156         if (StringUtils.isEmptyOrNull(result.getName())) {
157             throw new MalformedCookieException("Cookie name may not be empty");
158         }
159 
160         // Parse attributes
161         for (int i = 1; i < parts.length; i++) {
162             final String part = parts[i].trim();
163             if (part.isEmpty()) {
164                 continue;
165             }
166 
167             final String[] attrPair = splitNameValue(part);
168             final String attrName = attrPair[0].toLowerCase(Locale.ROOT);
169             final String attrValue = attrPair[1];
170 
171             switch (attrName) {
172                 case "domain" -> result.setDomain(attrValue);
173                 case "path" -> result.setPath(attrValue);
174                 case "expires" -> result.setExpires(parseDate(attrValue));
175                 case "max-age" -> result.setMaxAge(parseMaxAge(attrValue));
176                 case "secure" -> result.setSecure(true);
177                 case "httponly" -> result.setHttpOnly(true);
178                 case "samesite" -> result.setSameSite(attrValue);
179                 case "version" -> result.setVersion(parseVersion(attrValue));
180                 default -> {
181                     // Ignore unknown attributes
182                 }
183             }
184         }
185 
186         return result;
187     }
188 
189     /**
190      * Splits a name=value pair.
191      */
192     private static String[] splitNameValue(final String nvp) {
193         final int equalsPos = nvp.indexOf('=');
194         if (equalsPos < 0) {
195             // No value, just a name (e.g., "secure")
196             return new String[] {nvp.trim(), null};
197         }
198 
199         final String name = nvp.substring(0, equalsPos).trim();
200         final String value = nvp.substring(equalsPos + 1).trim();
201 
202         // Handle quoted values
203         if (value.length() >= 2 && value.charAt(0) == '"' && value.charAt(value.length() - 1) == '"') {
204             // Keep the quotes for compatibility
205             // value = value.substring(1, value.length() - 1);
206         }
207 
208         return new String[] {name, value};
209     }
210 
211     /**
212      * Parses a date string.
213      */
214     private static Date parseDate(final String dateString) throws MalformedCookieException {
215         if (StringUtils.isEmptyOrNull(dateString)) {
216             return null;
217         }
218 
219         // Try RFC 1123 format first (most common)
220         try {
221             final DateTimeFormatter formatter = DateTimeFormatter.RFC_1123_DATE_TIME;
222             final ZonedDateTime zonedDateTime = ZonedDateTime.parse(dateString, formatter);
223             return Date.from(zonedDateTime.toInstant());
224         }
225         catch (final DateTimeParseException e) {
226             // Try other formats
227         }
228 
229         // Try legacy date formats
230         for (final String pattern : DATE_PATTERNS) {
231             try {
232                 final SimpleDateFormat sdf = new SimpleDateFormat(pattern, Locale.US);
233                 sdf.setLenient(false);
234                 return sdf.parse(dateString);
235             }
236             catch (final ParseException e) {
237                 // Try next pattern
238             }
239         }
240 
241         throw new MalformedCookieException("Unable to parse date: " + dateString);
242     }
243 
244     /**
245      * Parses max-age value.
246      */
247     private static Integer parseMaxAge(final String maxAgeString) throws MalformedCookieException {
248         if (StringUtils.isEmptyOrNull(maxAgeString)) {
249             return null;
250         }
251 
252         if (!MAX_AGE_PATTERN.matcher(maxAgeString).matches()) {
253             throw new MalformedCookieException("Invalid 'max-age' attribute: '" + maxAgeString + "'");
254         }
255 
256         if (maxAgeString.startsWith("-")) {
257             return -1;
258         }
259 
260         try {
261             return Math.min(Integer.parseInt(maxAgeString), MAX_MAX_AGE);
262         }
263         catch (final NumberFormatException e) {
264             return MAX_MAX_AGE;
265         }
266     }
267 
268     /**
269      * Parses version value.
270      */
271     private static Integer parseVersion(final String versionString) {
272         if (StringUtils.isEmptyOrNull(versionString)) {
273             return 0;
274         }
275 
276         try {
277             return Integer.parseInt(versionString);
278         }
279         catch (final NumberFormatException e) {
280             return 0;
281         }
282     }
283 
284     /**
285      * Creates a Cookie from the parsed data.
286      */
287     private static Cookie createCookie(final ParsedCookie parsed, final CookieOrigin origin,
288                                        final BrowserVersion browserVersion) throws MalformedCookieException {
289 
290         // Determine domain
291         String domain = parsed.getDomain();
292         if (StringUtils.isEmptyOrNull(domain)) {
293             domain = origin.host;
294         }
295         else {
296             // Validate domain
297             validateDomain(domain, origin, browserVersion);
298         }
299 
300         // Determine path
301         String path = parsed.getPath();
302         if (StringUtils.isEmptyOrNull(path)) {
303             path = getDefaultPath(origin);
304         }
305 
306         // Determine expiration
307         Date expires = parsed.getExpires();
308         if (parsed.getMaxAge() != null) {
309             if (parsed.getMaxAge() < 0) {
310                 expires = null; // Session cookie
311             }
312             else {
313                 expires = new Date(System.currentTimeMillis() + (parsed.getMaxAge() * 1000L));
314             }
315         }
316 
317         return new Cookie(domain, parsed.getName(), parsed.getValue(), path, expires,
318                 parsed.isSecure(), parsed.isHttpOnly(), parsed.getSameSite());
319     }
320 
321     /**
322      * Validates the domain attribute.
323      */
324     private static void validateDomain(final String domain, final CookieOrigin origin,
325                                        final BrowserVersion browserVersion) throws MalformedCookieException {
326 
327         if (StringUtils.isEmptyOrNull(domain)) {
328             throw new MalformedCookieException("Cookie domain may not be empty");
329         }
330 
331         // Remove leading dot
332         final String normalizedDomain = domain.startsWith(".") ? domain.substring(1) : domain;
333         final String originHost = origin.host;
334 
335         // Check if domain matches or is a parent of the origin host
336         if (!domainMatch(normalizedDomain, originHost)) {
337             // In permissive mode, some browsers allow this
338             // For strict compliance, throw an exception
339             if (!LOCAL_FILESYSTEM_DOMAIN.equals(originHost)) {
340                 // Allow for testing purposes
341                 // throw new MalformedCookieException("Illegal domain attribute: " + domain);
342             }
343         }
344     }
345 
346     /**
347      * Checks if the domain matches according to cookie rules.
348      */
349     private static boolean domainMatch(final String domain, final String host) {
350         return domain.equalsIgnoreCase(host) || host.endsWith("." + domain);
351     }
352 
353     /**
354      * Gets the default path for a cookie.
355      */
356     private static String getDefaultPath(final CookieOrigin origin) {
357         String path = origin.path;
358 
359         if (StringUtils.isEmptyOrNull(path) || !path.startsWith("/")) {
360             return "/";
361         }
362 
363         // Remove everything after the last slash
364         final int lastSlash = path.lastIndexOf('/');
365         if (lastSlash > 0) {
366             path = path.substring(0, lastSlash);
367         }
368 
369         return path.isEmpty() ? "/" : path;
370     }
371 
372     /**
373      * Builds a CookieOrigin from a URL.
374      */
375     private static CookieOrigin buildCookieOrigin(final URL url) {
376         final URL normalizedUrl = replaceForCookieIfNecessary(url);
377 
378         int port = normalizedUrl.getPort();
379         if (port == -1) {
380             port = normalizedUrl.getDefaultPort();
381         }
382 
383         return new CookieOrigin(
384                 normalizedUrl.getHost(),
385                 port,
386                 normalizedUrl.getPath(),
387                 "https".equals(normalizedUrl.getProtocol()));
388     }
389 
390     /**
391      * Replaces file:// URLs with a bogus host for cookie handling.
392      */
393     private static URL replaceForCookieIfNecessary(URL url) {
394         final String protocol = url.getProtocol();
395         final boolean file = "file".equals(protocol);
396         if (file) {
397             try {
398                 url = UrlUtils.getUrlWithNewHostAndPort(url, LOCAL_FILESYSTEM_DOMAIN, 0);
399             }
400             catch (final MalformedURLException e) {
401                 throw new RuntimeException(e);
402             }
403         }
404         return url;
405     }
406 
407     /**
408      * Represents the origin of a cookie.
409      */
410     private record CookieOrigin(String host, int port, String path, boolean secure) {
411     }
412 
413     /**
414      * Intermediate representation of a parsed cookie.
415      */
416     private static final class ParsedCookie {
417         private final String name_;
418         private final String value_;
419 
420         private String domain_;
421         private String path_;
422         private Date expires_;
423         private Integer maxAge_;
424         private boolean secure_;
425         private boolean httpOnly_;
426         private String sameSite_;
427         private int version_;
428 
429         ParsedCookie(final String name, final String value) {
430             name_ = name;
431             value_ = value;
432             version_ = 0;
433         }
434 
435         public String getName() {
436             return name_;
437         }
438 
439         public String getValue() {
440             return value_;
441         }
442 
443         public String getDomain() {
444             return domain_;
445         }
446 
447         public void setDomain(final String domain) {
448             domain_ = domain;
449         }
450 
451         public String getPath() {
452             return path_;
453         }
454 
455         public void setPath(final String path) {
456             path_ = path;
457         }
458 
459         public Date getExpires() {
460             return expires_;
461         }
462 
463         public void setExpires(final Date expires) {
464             expires_ = expires;
465         }
466 
467         public Integer getMaxAge() {
468             return maxAge_;
469         }
470 
471         public void setMaxAge(final Integer maxAge) {
472             maxAge_ = maxAge;
473         }
474 
475         public boolean isSecure() {
476             return secure_;
477         }
478 
479         public void setSecure(final boolean secure) {
480             secure_ = secure;
481         }
482 
483         public boolean isHttpOnly() {
484             return httpOnly_;
485         }
486 
487         public void setHttpOnly(final boolean httpOnly) {
488             httpOnly_ = httpOnly;
489         }
490 
491         public String getSameSite() {
492             return sameSite_;
493         }
494 
495         public void setSameSite(final String sameSite) {
496             sameSite_ = sameSite;
497         }
498 
499         public int getVersion() {
500             return version_;
501         }
502 
503         public void setVersion(final int version) {
504             version_ = version;
505         }
506     }
507 }