io.netty5.handler.codec.http.headers.DefaultHttpSetCookie Maven / Gradle / Ivy
The newest version!
/*
* Copyright 2022 The Netty Project
*
* The Netty Project licenses this file to you under the Apache License, version 2.0 (the
* "License"); you may not use this file except in compliance with the License. You may obtain a
* copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License
* is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
* or implied. See the License for the specific language governing permissions and limitations under
* the License.
*/
/*
* Copyright © 2018, 2021 Apple Inc. and the ServiceTalk project authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.netty5.handler.codec.http.headers;
import io.netty5.util.AsciiString;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import static io.netty5.handler.codec.http.headers.HeaderUtils.validateCookieNameAndValue;
import static io.netty5.util.AsciiString.contentEquals;
import static io.netty5.util.AsciiString.contentEqualsIgnoreCase;
import static java.lang.Long.parseLong;
/**
* Default implementation of {@link HttpSetCookie}.
*/
public final class DefaultHttpSetCookie implements HttpSetCookie {
private static final String ENCODED_LABEL_DOMAIN = "; domain=";
private static final String ENCODED_LABEL_PATH = "; path=";
private static final String ENCODED_LABEL_EXPIRES = "; expires=";
private static final String ENCODED_LABEL_MAX_AGE = "; max-age=";
private static final String ENCODED_LABEL_HTTP_ONLY = "; httponly";
private static final String ENCODED_LABEL_SECURE = "; secure";
private static final String ENCODED_LABEL_SAMESITE = "; samesite=";
private static ParseState parseStateOf(CharSequence fieldName) {
// Try a binary search based on length. We can read length without bounds checks.
int len = fieldName.length();
if (len >= 4 && len <= 8) {
if (len < 7) {
if (len == 4) {
if (contentEqualsIgnoreCase("path", fieldName)) {
return ParseState.ParsingPath;
}
} else if (len == 6) {
if (contentEqualsIgnoreCase("domain", fieldName)) {
return ParseState.ParsingDomain;
}
}
} else {
if (len == 7) {
if (contentEqualsIgnoreCase("expires", fieldName)) {
return ParseState.ParsingExpires;
}
if (contentEqualsIgnoreCase("max-age", fieldName)) {
return ParseState.ParsingMaxAge;
}
} else {
if (contentEqualsIgnoreCase("samesite", fieldName)) {
return ParseState.ParsingSameSite;
}
}
}
}
return ParseState.Unknown;
}
private final CharSequence name;
private final CharSequence value;
@Nullable
private final CharSequence path;
@Nullable
private final CharSequence domain;
@Nullable
private final CharSequence expires;
@Nullable
private final Long maxAge;
@Nullable
private final SameSite sameSite;
private final boolean wrapped;
private final boolean secure;
private final boolean httpOnly;
/**
* Create a new not wrapped, not secure and not HTTP-only {@link HttpSetCookie} instance, with no path, domain,
* expire date and maximum age.
*
* @param name the cookie-name.
* @param value the cookie-value.
*/
public DefaultHttpSetCookie(final CharSequence name, final CharSequence value) {
this(name, value, false, false, false);
}
/**
* Create a new {@link HttpSetCookie} instance, with no path, domain, expire date and maximum age.
*
* @param name the cookie-name.
* @param value the cookie-value.
* @param wrapped {@code true} if the value should be wrapped in DQUOTE as described in
* cookie-value.
* @param secure the secure-av.
* @param httpOnly the httponly-av (see
* HTTP-only).
*/
public DefaultHttpSetCookie(final CharSequence name, final CharSequence value,
final boolean wrapped, final boolean secure, final boolean httpOnly) {
this(name, value, null, null, null, null, null, wrapped, secure, httpOnly);
}
/**
* Creates a new {@link HttpSetCookie} instance.
*
* @param name the cookie-name.
* @param value the cookie-value.
* @param path the path-value.
* @param domain the domain-value.
* @param expires the expires-av.
* Represented as an RFC-1123 date defined in
* RFC-2616, Section 3.3.1.
* @param maxAge the max-age-av.
* @param wrapped {@code true} if the value should be wrapped in DQUOTE as described in
* cookie-value.
* @param secure the secure-av.
* @param httpOnly the httponly-av (see
* HTTP-only).
* @param sameSite the
* SameSite attribute.
*/
public DefaultHttpSetCookie(final CharSequence name, final CharSequence value, @Nullable final CharSequence path,
@Nullable final CharSequence domain, @Nullable final CharSequence expires,
@Nullable final Long maxAge, @Nullable final SameSite sameSite, final boolean wrapped,
final boolean secure, final boolean httpOnly) {
validateCookieNameAndValue(name, value);
this.name = name;
this.value = value;
this.path = path;
this.domain = domain;
this.expires = expires;
this.maxAge = maxAge;
this.sameSite = sameSite;
this.wrapped = wrapped;
this.secure = secure;
this.httpOnly = httpOnly;
}
static HttpSetCookie parseSetCookie(final CharSequence setCookieString, boolean validateContent,
@Nullable CharSequence name, int i) {
CharSequence value = null;
CharSequence path = null;
CharSequence domain = null;
CharSequence expires = null;
Long maxAge = null;
SameSite sameSite = null;
boolean isWrapped = false;
boolean isSecure = false;
boolean isHttpOnly = false;
int begin;
ParseState parseState;
if (name != null) {
parseState = ParseState.ParsingValue;
begin = i;
} else {
parseState = ParseState.Unknown;
begin = 0;
}
int length = setCookieString.length();
while (i < length) {
final char c = setCookieString.charAt(i);
switch (c) {
case '=':
if (name == null) {
if (i <= begin) {
throw new IllegalArgumentException("cookie name cannot be null or empty");
}
name = setCookieString.subSequence(begin, i);
if (validateContent) {
int index = HttpHeaderValidationUtil.validateToken(name);
if (index != -1) {
throw new HeaderValidationException(
"a cookie name can only contain \"token\" characters, " +
"but found invalid character 0x" + Integer.toHexString(c) +
" at index " + index + " of header '" + name + "'.");
}
}
parseState = ParseState.ParsingValue;
} else if (parseState == ParseState.Unknown) {
final CharSequence avName = setCookieString.subSequence(begin, i);
parseState = parseStateOf(avName);
} else if (parseState == ParseState.ParsingValue) {
// Cookie values can contain '='.
++i;
break;
} else {
throw new IllegalArgumentException("unexpected = at index: " + i);
}
++i;
begin = i;
break;
case '"':
if (parseState == ParseState.ParsingValue) {
if (isWrapped) {
parseState = ParseState.Unknown;
value = setCookieString.subSequence(begin, i);
if (validateContent) {
// Increment by 3 because we are skipping DQUOTE SEMI SP
// See https://www.rfc-editor.org/rfc/rfc6265#section-4.1.1
// Specifically, how set-cookie-string interacts with quoted cookie-value.
i += 3;
} else {
// When validation is disabled, we need to check if there's an SP to skip
i += i + 2 < length && setCookieString.charAt(i + 2) == ' '? 3 : 2;
}
} else {
isWrapped = true;
++i;
}
begin = i;
break;
}
if (value == null) {
throw new IllegalArgumentException("unexpected quote at index: " + i);
}
++i;
break;
case ';':
// end of value, or end of av-value
if (i + 1 == length && validateContent) {
throw new IllegalArgumentException("unexpected trailing ';'");
}
switch (parseState) {
case ParsingValue:
value = setCookieString.subSequence(begin, i);
break;
case ParsingPath:
path = setCookieString.subSequence(begin, i);
break;
case ParsingDomain:
domain = setCookieString.subSequence(begin, i);
break;
case ParsingExpires:
expires = setCookieString.subSequence(begin, i);
break;
case ParsingMaxAge:
maxAge = parseLong(setCookieString, begin, i, 10);
break;
case ParsingSameSite:
sameSite = fromSequence(setCookieString, begin, i);
break;
default:
if (name == null) {
throw new IllegalArgumentException("cookie value not found at index " + i);
}
final CharSequence avName = setCookieString.subSequence(begin, i);
if (contentEqualsIgnoreCase(avName, "secure")) {
isSecure = true;
} else if (contentEqualsIgnoreCase(avName, "httponly")) {
isHttpOnly = true;
}
break;
}
parseState = ParseState.Unknown;
if (validateContent) {
if (i + 1 >= length || ' ' != setCookieString.charAt(i + 1)) {
throw new IllegalArgumentException(
"a space is required after ; in cookie attribute-value lists");
}
i += 2;
} else {
i++;
if (i < length && ' ' == setCookieString.charAt(i)) {
i++;
}
}
begin = i;
break;
default:
if (validateContent) {
if (parseState == ParseState.ParsingValue) {
// Cookie values need to conform to the cookie-octet rule of
// https://www.rfc-editor.org/rfc/rfc6265#section-4.1.1
validateCookieOctetHexValue(c, i);
} else {
// Cookie attribute-value rules are "any CHAR except CTLs or ';'"
validateCookieAttributeValue(c, i);
}
}
++i;
break;
}
}
if (begin < i) {
// end of value, or end of av-value
// check for "secure" and "httponly"
switch (parseState) {
case ParsingValue:
value = setCookieString.subSequence(begin, i);
break;
case ParsingPath:
path = setCookieString.subSequence(begin, i);
break;
case ParsingDomain:
domain = setCookieString.subSequence(begin, i);
break;
case ParsingExpires:
expires = setCookieString.subSequence(begin, i);
break;
case ParsingSameSite:
sameSite = fromSequence(setCookieString, begin, i);
break;
case ParsingMaxAge:
maxAge = parseLong(setCookieString, begin, i, 10);
break;
default:
if (name == null) {
throw new IllegalArgumentException("cookie value not found at index " + i);
}
final CharSequence avName = setCookieString.subSequence(begin, i);
if (contentEqualsIgnoreCase(avName, "secure")) {
isSecure = true;
} else if (contentEqualsIgnoreCase(avName, "httponly")) {
isHttpOnly = true;
}
break;
}
} else if (begin == i) {
switch (parseState) {
case ParsingValue:
if (!isWrapped) {
// Values can be the empty string, if we had a cookie name and an equals sign, and no quotes.
value = "";
}
break;
case ParsingPath:
path = "";
break;
case ParsingDomain:
domain = "";
break;
case Unknown:
break;
default:
throw new Error("Unhandled parse state: " + parseState);
}
}
assert name != null && value != null; // these are checked at runtime in the constructor
return new DefaultHttpSetCookie(name, value, path, domain, expires, maxAge, sameSite, isWrapped, isSecure,
isHttpOnly);
}
/**
* Parse a {@code setCookie} {@link CharSequence} into a {@link HttpSetCookie}.
*
* @param setCookieString The set-cookie-string
* value.
* @param validateContent {@code true} to make a best effort to validate the contents of the SetCookie.
* @return a {@link HttpSetCookie} representation of {@code setCookie}.
*/
public static HttpSetCookie parseSetCookie(final CharSequence setCookieString, boolean validateContent) {
return parseSetCookie(setCookieString, validateContent, null, 0);
}
@Override
public CharSequence name() {
return name;
}
@Override
public CharSequence value() {
return value;
}
@Override
public boolean isWrapped() {
return wrapped;
}
@Nullable
@Override
public CharSequence domain() {
return domain;
}
@Nullable
@Override
public CharSequence path() {
return path;
}
@Nullable
@Override
public Long maxAge() {
return maxAge;
}
@Nullable
@Override
public CharSequence expires() {
return expires;
}
@Nullable
@Override
public SameSite sameSite() {
return sameSite;
}
@Override
public boolean isSecure() {
return secure;
}
@Override
public boolean isHttpOnly() {
return httpOnly;
}
@Override
public CharSequence encoded() {
StringBuilder sb = new StringBuilder(1 + name.length() + value.length() +
(wrapped ? 2 : 0) +
(domain != null ? ENCODED_LABEL_DOMAIN.length() + domain.length() : 0) +
(path != null ? ENCODED_LABEL_PATH.length() + path.length() : 0) +
(expires != null ? ENCODED_LABEL_EXPIRES.length() + expires.length() : 0) +
(maxAge != null ? ENCODED_LABEL_MAX_AGE.length() + 11 : 0) +
(sameSite != null ? ENCODED_LABEL_SAMESITE.length() + SameSite.Strict.toString().length() : 0) +
(httpOnly ? ENCODED_LABEL_HTTP_ONLY.length() : 0) +
(secure ? ENCODED_LABEL_SECURE.length() : 0));
sb.append(name).append('=');
if (wrapped) {
sb.append('"').append(value).append('"');
} else {
sb.append(value);
}
if (domain != null) {
sb.append(ENCODED_LABEL_DOMAIN);
sb.append(domain);
}
if (path != null) {
sb.append(ENCODED_LABEL_PATH);
sb.append(path);
}
if (expires != null) {
sb.append(ENCODED_LABEL_EXPIRES);
sb.append(expires);
}
if (maxAge != null) {
sb.append(ENCODED_LABEL_MAX_AGE);
sb.append(maxAge);
}
if (sameSite != null) {
sb.append(ENCODED_LABEL_SAMESITE);
sb.append(sameSite);
}
if (httpOnly) {
sb.append(ENCODED_LABEL_HTTP_ONLY);
}
if (secure) {
sb.append(ENCODED_LABEL_SECURE);
}
return sb.toString();
}
@Override
public boolean equals(final Object o) {
if (this == o) {
return true;
}
if (!(o instanceof HttpSetCookie)) {
return false;
}
final HttpSetCookie rhs = (HttpSetCookie) o;
// It is not possible to do domain [1] and path [2] equality and preserve the equals/hashCode API because the
// equality comparisons in the RFC are variable so we cannot guarantee the following property:
// if equals(a) == equals(b) then a.hasCode() == b.hashCode()
// [1] https://tools.ietf.org/html/rfc6265#section-5.1.3
// [2] https://tools.ietf.org/html/rfc6265#section-5.1.4
return contentEquals(name, rhs.name()) &&
contentEquals(value, rhs.value()) &&
contentEqualsIgnoreCase(domain, rhs.domain()) &&
contentEquals(path, rhs.path());
}
@Override
public int hashCode() {
int hash = 31 + AsciiString.hashCode(name);
hash = 31 * hash + AsciiString.hashCode(value);
if (domain != null) {
hash = 31 * hash + AsciiString.hashCode(domain);
}
if (path != null) {
hash = 31 * hash + AsciiString.hashCode(path);
}
return hash;
}
@Override
public String toString() {
return getClass().getSimpleName() + '[' + name + ']';
}
private enum ParseState {
ParsingValue,
ParsingPath,
ParsingDomain,
ParsingExpires,
ParsingMaxAge,
ParsingSameSite,
Unknown
}
@Nullable
private static SameSite fromSequence(CharSequence cs, int begin, int end) {
switch (end - begin) {
case 3:
if (equalsIgnoreCaseLower(cs.charAt(begin), 'l') &&
equalsIgnoreCaseLower(cs.charAt(begin + 1), 'a') &&
equalsIgnoreCaseLower(cs.charAt(begin + 2), 'x')) {
return SameSite.Lax;
}
break;
case 4:
if (equalsIgnoreCaseLower(cs.charAt(begin), 'n') &&
equalsIgnoreCaseLower(cs.charAt(begin + 1), 'o') &&
equalsIgnoreCaseLower(cs.charAt(begin + 2), 'n') &&
equalsIgnoreCaseLower(cs.charAt(begin + 3), 'e')) {
return SameSite.None;
}
break;
case 6:
if (equalsIgnoreCaseLower(cs.charAt(begin), 's') &&
equalsIgnoreCaseLower(cs.charAt(begin + 1), 't') &&
equalsIgnoreCaseLower(cs.charAt(begin + 2), 'r') &&
equalsIgnoreCaseLower(cs.charAt(begin + 3), 'i') &&
equalsIgnoreCaseLower(cs.charAt(begin + 4), 'c') &&
equalsIgnoreCaseLower(cs.charAt(begin + 5), 't')) {
return SameSite.Strict;
}
break;
default:
break;
}
return null;
}
private static boolean equalsIgnoreCaseLower(char c, char k) {
return c == k || c >= 'A' && c <= 'Z' && c == k - 32;
}
/**
*
* cookie-octet = %x21 / %x23-2B / %x2D-3A / %x3C-5B / %x5D-7E
*
* @param hexValue The decimal representation of the hexadecimal value.
* @param index The index of the character in the inputs, for error reporting.
*/
private static void validateCookieOctetHexValue(final int hexValue, int index) {
if (hexValue != 33 &&
(hexValue < 35 || hexValue > 43) &&
(hexValue < 45 || hexValue > 58) &&
(hexValue < 60 || hexValue > 91) &&
(hexValue < 93 || hexValue > 126)) {
throw unexpectedHexValue(hexValue, index);
}
}
/**
* Attribute values are
* any CHAR except CTLs or ";",
* and CTLs are %x00 to %x1F, and %x7F.
*
* @param hexValue The decimal representation of the hexadecimal value.
* @param index The index of the character in the inputs, for error reporting.
*/
private static void validateCookieAttributeValue(final int hexValue, int index) {
if (hexValue == ';' || hexValue == 0x7F || hexValue <= 0x1F) {
throw unexpectedHexValue(hexValue, index);
}
}
@NotNull
private static IllegalArgumentException unexpectedHexValue(int hexValue, int index) {
return new IllegalArgumentException(
"Unexpected hex value at index " + index + ": 0x" + Integer.toHexString(hexValue));
}
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy