1
2
3
4
5
6
7
8
9
10
11
12
13
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
35
36
37
38
39
40
41 public final class CookieParser {
42
43
44 public static final String EMPTY_COOKIE_NAME = "HTMLUNIT_EMPTY_COOKIE";
45
46
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",
51 "EEE, dd-MMM-yy HH:mm:ss z",
52 "EEE MMM dd HH:mm:ss yyyy",
53 "EEE, dd-MMM-yyyy HH:mm:ss z",
54 "EEE MMM dd yyyy HH:mm: ss z",
55 "EEE, dd MMM yy HH:mm:ss z"
56 };
57
58
59
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
66 }
67
68
69
70
71
72
73
74
75
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
85 final CookieOrigin origin = buildCookieOrigin(pageUrl);
86
87
88 final String normalizedCookieString = normalizeCookieString(cookieString);
89 final ParsedCookie parsedCookie = parseNetscapeCookie(normalizedCookieString);
90
91
92 final Cookie cookie = createCookie(parsedCookie, origin, browserVersion);
93
94 return List.of(cookie);
95 }
96
97
98
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
108 final int equalsPos = cookieString.indexOf('=');
109 final int semicolonPos = cookieString.indexOf(';');
110
111
112 final int endPos;
113 if (equalsPos < 0 && semicolonPos < 0) {
114
115 return EMPTY_COOKIE_NAME + "=" + cookieString;
116 }
117 else if (equalsPos < 0) {
118
119 return EMPTY_COOKIE_NAME + "=" + cookieString;
120 }
121 else if (semicolonPos < 0 || equalsPos < semicolonPos) {
122 endPos = equalsPos;
123 }
124 else {
125
126 return EMPTY_COOKIE_NAME + "=" + cookieString;
127 }
128
129
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
140
141 private static ParsedCookie parseNetscapeCookie(final String cookieString)
142 throws MalformedCookieException {
143
144
145 final String[] parts = cookieString.split(";");
146
147 if (parts.length == 0) {
148 throw new MalformedCookieException("Empty cookie string");
149 }
150
151
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
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
182 }
183 }
184 }
185
186 return result;
187 }
188
189
190
191
192 private static String[] splitNameValue(final String nvp) {
193 final int equalsPos = nvp.indexOf('=');
194 if (equalsPos < 0) {
195
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
203 if (value.length() >= 2 && value.charAt(0) == '"' && value.charAt(value.length() - 1) == '"') {
204
205
206 }
207
208 return new String[] {name, value};
209 }
210
211
212
213
214 private static Date parseDate(final String dateString) throws MalformedCookieException {
215 if (StringUtils.isEmptyOrNull(dateString)) {
216 return null;
217 }
218
219
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
227 }
228
229
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
238 }
239 }
240
241 throw new MalformedCookieException("Unable to parse date: " + dateString);
242 }
243
244
245
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
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
286
287 private static Cookie createCookie(final ParsedCookie parsed, final CookieOrigin origin,
288 final BrowserVersion browserVersion) throws MalformedCookieException {
289
290
291 String domain = parsed.getDomain();
292 if (StringUtils.isEmptyOrNull(domain)) {
293 domain = origin.host;
294 }
295 else {
296
297 validateDomain(domain, origin, browserVersion);
298 }
299
300
301 String path = parsed.getPath();
302 if (StringUtils.isEmptyOrNull(path)) {
303 path = getDefaultPath(origin);
304 }
305
306
307 Date expires = parsed.getExpires();
308 if (parsed.getMaxAge() != null) {
309 if (parsed.getMaxAge() < 0) {
310 expires = null;
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
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
332 final String normalizedDomain = domain.startsWith(".") ? domain.substring(1) : domain;
333 final String originHost = origin.host;
334
335
336 if (!domainMatch(normalizedDomain, originHost)) {
337
338
339 if (!LOCAL_FILESYSTEM_DOMAIN.equals(originHost)) {
340
341
342 }
343 }
344 }
345
346
347
348
349 private static boolean domainMatch(final String domain, final String host) {
350 return domain.equalsIgnoreCase(host) || host.endsWith("." + domain);
351 }
352
353
354
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
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
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
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
409
410 private record CookieOrigin(String host, int port, String path, boolean secure) {
411 }
412
413
414
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 }