package org.lobobrowser.request; import java.net.URI; import java.text.DateFormat; import java.text.ParseException; import java.text.SimpleDateFormat; import java.time.DateTimeException; import java.time.OffsetDateTime; import java.time.ZoneOffset; import java.util.Date; import java.util.HashMap; import java.util.Locale; import java.util.Map; import java.util.Optional; import java.util.StringTokenizer; import java.util.TimeZone; import java.util.logging.Level; import java.util.logging.Logger; import java.util.regex.Matcher; import java.util.regex.Pattern; final class CookieParsing { private static final Logger logger = Logger.getLogger(CookieParsing.class.getName()); private static final DateFormat EXPIRES_FORMAT; private static final DateFormat EXPIRES_FORMAT_BAK1; private static final DateFormat EXPIRES_FORMAT_BAK2; static { // Note: Using yy in case years are given as two digits. // Note: Must use US locale for cookie dates. final Locale locale = Locale.US; final SimpleDateFormat ef1 = new SimpleDateFormat("EEE, dd MMM yy HH:mm:ss 'GMT'", locale); final SimpleDateFormat ef2 = new SimpleDateFormat("EEE, dd-MMM-yy HH:mm:ss 'GMT'", locale); final SimpleDateFormat ef3 = new SimpleDateFormat("EEE MMM dd HH:mm:ss yy 'GMT'", locale); final TimeZone gmtTimeZone = TimeZone.getTimeZone("GMT"); ef1.setTimeZone(gmtTimeZone); ef2.setTimeZone(gmtTimeZone); ef3.setTimeZone(gmtTimeZone); EXPIRES_FORMAT = ef1; EXPIRES_FORMAT_BAK1 = ef2; EXPIRES_FORMAT_BAK2 = ef3; } /** @deprecated Use parseExpiresRFC6265 instead */ @Deprecated private static Optional<java.util.Date> parseExpires(final String expiresStr) { Optional<java.util.Date> expiresDate = Optional.empty(); synchronized (EXPIRES_FORMAT) { try { expiresDate = Optional.of(EXPIRES_FORMAT.parse(expiresStr)); } catch (final Exception pe) { if (logger.isLoggable(Level.INFO)) { logger.log(Level.INFO, "parseExpires(): Bad date format: " + expiresStr + ". Will try again.", pe); } try { expiresDate = Optional.of(EXPIRES_FORMAT_BAK1.parse(expiresStr)); } catch (final Exception pe2) { try { expiresDate = Optional.of(EXPIRES_FORMAT_BAK2.parse(expiresStr)); } catch (final ParseException pe3) { logger.log(Level.SEVERE, "parseExpires(): Giving up on cookie date format: " + expiresStr, pe3); } } } } return expiresDate; } static Optional<CookieDetails> parseCookieSpec(final URI requestURL, final String cookieSpec) { final StringTokenizer tok = new StringTokenizer(cookieSpec, ";"); String cookieName = null; String cookieValue = null; String domain = null; String path = null; Optional<Date> expires = Optional.empty(); Long maxAge = null; boolean secure = false; boolean httpOnly = false; boolean hasCookieName = false; while (tok.hasMoreTokens()) { final String token = tok.nextToken(); if (!hasCookieName) { final Matcher matcher = COOKIE_PAIR_STRICT_MEDIUM.matcher(token); // if ((idx == -1) || (name.length() == 0)) { if (!matcher.lookingAt()) { return Optional.empty(); } else { cookieName = matcher.group(1); // the regexp should trim() already // cookieValue = matcher.group(3); // [2] is quotes or empty-string cookieValue = matcher.group(2); hasCookieName = true; } } else { final int idx = token.indexOf('='); final String name = idx == -1 ? token.trim() : token.substring(0, idx).trim(); final String value = idx == -1 ? "" : token.substring(idx + 1).trim(); if ("max-age".equalsIgnoreCase(name)) { try { maxAge = Long.parseLong(value); } catch (final NumberFormatException e) { // Ignore this attribute logger.log(Level.WARNING, "parseCookieSpec(): Max-age is not formatted correctly: " + value + "."); } } else if ("path".equalsIgnoreCase(name)) { path = value; } else if ("domain".equalsIgnoreCase(name)) { if (value.length() == 0) { // Ignore this attribute logger.log(Level.WARNING, "parseCookieSpec(): domain is empty, hence attribute is ignored"); } else { if (value.charAt(0) == '.') { domain = value.substring(1); } else { domain = value; } } } else if ("expires".equalsIgnoreCase(name)) { final Optional<Date> parsedExpires = parseExpiresRFC6265(value); if (parsedExpires.isPresent()) { expires = parsedExpires; } } else if ("secure".equalsIgnoreCase(name)) { secure = true; } else if ("httponly".equalsIgnoreCase(name)) { httpOnly = true; } } } return Optional.of(new CookieDetails(requestURL, cookieName, cookieValue, domain, path, expires, maxAge, secure, httpOnly)); } private static final Map<String, Integer> monthToNum = new HashMap<>(); static { monthToNum.put("jan", 1); monthToNum.put("feb", 2); monthToNum.put("mar", 3); monthToNum.put("apr", 4); monthToNum.put("may", 5); monthToNum.put("jun", 6); monthToNum.put("jul", 7); monthToNum.put("aug", 8); monthToNum.put("sep", 9); monthToNum.put("oct", 10); monthToNum.put("nov", 11); monthToNum.put("dec", 12); } private static final Pattern DATE_DELIM = Pattern.compile("[\\x09\\x20-\\x2F\\x3B-\\x40\\x5B-\\x60\\x7B-\\x7E]"); //From RFC2616 S2.2: private static final Pattern TOKEN = Pattern.compile("[\\x21\\x23-\\x26\\x2A\\x2B\\x2D\\x2E\\x30-\\x39\\x41-\\x5A\\x5E-\\x7A\\x7C\\x7E]"); //From RFC6265 S4.1.1 //note that it excludes \x3B ";" private static final Pattern COOKIE_OCTET = Pattern.compile("[\\x21\\x23-\\x2B\\x2D-\\x3A\\x3C-\\x5B\\x5D-\\x7E]"); // private static final Pattern COOKIE_OCTETS = Pattern.compile('^' + COOKIE_OCTET.pattern() + '$'); //The name/key cannot be empty but the value can (S5.2): // private static final Pattern COOKIE_PAIR_STRICT = Pattern.compile("^(" + TOKEN.pattern() + "+)=(\"?)(" + COOKIE_OCTET.pattern() + "*)\\2$"); private static final Pattern COOKIE_PAIR_STRICT_MEDIUM = Pattern.compile("^(" + TOKEN.pattern() + "+)=((\"?)(" + COOKIE_OCTET.pattern() + "*)\\3).*$"); // private static final Pattern COOKIE_PAIR = Pattern.compile("^([^=\\s]+)\\s*=\\s*(\"?)\\s*(.*)\\s*\\2\\s*$"); //RFC6265 S4.1.1 defines extension-av as 'any CHAR except CTLs or ";"' //Note ';' is \x3B // private static final Pattern NON_CTL_SEMICOLON = Pattern.compile("[\\x20-\\x3A\\x3C-\\x7E]+"); // private static final Pattern EXTENSION_AV = NON_CTL_SEMICOLON; // private static final Pattern PATH_VALUE = NON_CTL_SEMICOLON; //Used for checking whether or not there is a trailing semi-colon // private static final Pattern TRAILING_SEMICOLON = Pattern.compile(";+$"); /* RFC6265 S5.1.1.5: * [fail if] the day-of-month-value is less than 1 or greater than 31 */ private static final Pattern DAY_OF_MONTH = Pattern.compile("^(0?[1-9]|[12][0-9]|3[01])$"); /* RFC6265 S5.1.1.5: * [fail if] * * the hour-value is greater than 23, * * the minute-value is greater than 59, or * * the second-value is greater than 59. */ // private static final Pattern TIME = Pattern.compile("(0?[0-9]|1[0-9]|2[0-3]):([0-5][0-9]):([0-5][0-9])"); // private static final Pattern STRICT_TIME = Pattern.compile("^(0?[0-9]|1[0-9]|2[0-3]):([0-5][0-9]):([0-5][0-9])$"); private static final Pattern LENIENT_TIME = Pattern.compile("([0-9][0-9]?):([0-9][0-9]?):([0-9][0-9]?)"); private static final Pattern MONTH = Pattern.compile("^(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec).*$", Pattern.CASE_INSENSITIVE); private static final Pattern YEAR = Pattern.compile("^([0-9][0-9]{1,3})$"); static Optional<java.util.Date> parseExpiresRFC6265(final String expiresStr) { final Optional<java.util.Date> expiresDate = Optional.empty(); final String[] tokens = DATE_DELIM.split(expiresStr); if (tokens.length == 0) { return expiresDate; } boolean found_time = false, found_dom = false, found_month = false, found_year = false; // LocalDateTime date = LocalDateTime.MIN; // date = date.withNano(0); // LocalDate date = LocalDate.MIN; int dayOfMonth = 0; int month = 0; int year = 0; int hour = 0; int minute = 0; int second = 0; // time = time.withNano(0); for (final String token2 : tokens) { final String token = token2.trim(); if (token.length() == 0) { continue; } // var result; /* 2.1. If the found-time flag is not set and the token matches the time * production, set the found-time flag and set the hour- value, * minute-value, and second-value to the numbers denoted by the digits in * the date-token, respectively. Skip the remaining sub-steps and continue * to the next date-token. */ if (!found_time) { // final Matcher matcher = (strict ? STRICT_TIME : LENIENT_TIME).matcher(token); final Matcher matcher = LENIENT_TIME.matcher(token); if (matcher.matches()) { found_time = true; hour = Integer.parseInt(matcher.group(1)); minute = Integer.parseInt(matcher.group(2)); second = Integer.parseInt(matcher.group(3)); continue; } } /* 2.2. If the found-day-of-month flag is not set and the date-token matches * the day-of-month production, set the found-day-of- month flag and set * the day-of-month-value to the number denoted by the date-token. Skip * the remaining sub-steps and continue to the next date-token. */ if (!found_dom) { final Matcher matcher = DAY_OF_MONTH.matcher(token); if (matcher.matches()) { found_dom = true; dayOfMonth = Integer.parseInt(matcher.group(1)); continue; } } /* 2.3. If the found-month flag is not set and the date-token matches the * month production, set the found-month flag and set the month-value to * the month denoted by the date-token. Skip the remaining sub-steps and * continue to the next date-token. */ if (!found_month) { final Matcher matcher = MONTH.matcher(token); if (matcher.matches()) { found_month = true; month = monthToNum.get(matcher.group(1).toLowerCase()); continue; } } /* 2.4. If the found-year flag is not set and the date-token matches the year * production, set the found-year flag and set the year-value to the number * denoted by the date-token. Skip the remaining sub-steps and continue to * the next date-token. */ if (!found_year) { final Matcher matcher = YEAR.matcher(token); if (matcher.matches()) { year = Integer.parseInt(matcher.group(1)); /* From S5.1.1: * 3. If the year-value is greater than or equal to 70 and less * than or equal to 99, increment the year-value by 1900. * 4. If the year-value is greater than or equal to 0 and less * than or equal to 69, increment the year-value by 2000. */ if ((70 <= year) && (year <= 99)) { year += 1900; } else if ((0 <= year) && (year <= 69)) { year += 2000; } if (year < 1601) { return expiresDate; // 5. ... the year-value is less than 1601 } found_year = true; continue; } } } if (!(found_time && found_dom && found_month && found_year)) { // 5. ... at least one of the found-day-of-month, found-month, found-year, or found-time flags is not set, return expiresDate; } else { try { final OffsetDateTime dateTime = OffsetDateTime.of(year, month, dayOfMonth, hour, minute, second, 0, ZoneOffset.UTC); final long millis = dateTime.toInstant().toEpochMilli(); return Optional.of(new Date(millis)); } catch (final DateTimeException e) { logger.log(Level.WARNING, "parseExpires(): Bad date-time.", e); return Optional.empty(); } } } }