diff --git a/codequality/checkstyle_suppressions.xml b/codequality/checkstyle_suppressions.xml
index 952267f908..09fe1d7c15 100644
--- a/codequality/checkstyle_suppressions.xml
+++ b/codequality/checkstyle_suppressions.xml
@@ -20,6 +20,8 @@
+ * It will store the way the raw value was wrapped in {@link NettyCookie#setWrap(boolean)} so it can be + * eventually sent back to the Origin server as is. + * + * @see ClientCookieEncoder + */ +final class ClientCookieDecoder { + + private static final Logger logger = LoggerFactory.getLogger(ClientCookieDecoder.class); + /** + * Strict encoder that validates that name and value chars are in the valid scope + * defined in RFC6265 + */ + static final ClientCookieDecoder STRICT = new ClientCookieDecoder(true); + + /** + * Lax instance that doesn't validate name and value + */ + static final ClientCookieDecoder LAX = new ClientCookieDecoder(false); + private final boolean strict; + + + private ClientCookieDecoder(boolean strict) { + this.strict = strict; + } + + /** + * Decodes the specified Set-Cookie HTTP header value into a {@link NettyCookie}. + * + * @return the decoded {@link NettyCookie} + */ + NettyCookie decode(String header) { + final int headerLen = checkNotNull(header, "header").length(); + + if (headerLen == 0) { + return null; + } + + CookieBuilder cookieBuilder = null; + + loop: + for (int i = 0; ; ) { + + // Skip spaces and separators. + for (; ; ) { + if (i == headerLen) { + break loop; + } + char c = header.charAt(i); + if (c == ',') { + // Having multiple cookies in a single Set-Cookie header is + // deprecated, modern browsers only parse the first one + break loop; + + } else if (c == '\t' || c == '\n' || c == 0x0b || c == '\f' + || c == '\r' || c == ' ' || c == ';') { + i++; + continue; + } + break; + } + + int nameBegin = i; + int nameEnd; + int valueBegin; + int valueEnd; + + for (; ; ) { + char curChar = header.charAt(i); + if (curChar == ';') { + // NAME; (no value till ';') + nameEnd = i; + valueBegin = valueEnd = -1; + break; + + } else if (curChar == '=') { + // NAME=VALUE + nameEnd = i; + i++; + if (i == headerLen) { + // NAME= (empty value, i.e. nothing after '=') + valueBegin = valueEnd = 0; + break; + } + + valueBegin = i; + // NAME=VALUE; + int semiPos = header.indexOf(';', i); + valueEnd = i = semiPos > 0 ? semiPos : headerLen; + break; + } else { + i++; + } + + if (i == headerLen) { + // NAME (no value till the end of string) + nameEnd = headerLen; + valueBegin = valueEnd = -1; + break; + } + } + + if (valueEnd > 0 && header.charAt(valueEnd - 1) == ',') { + // old multiple cookies separator, skipping it + valueEnd--; + } + + if (cookieBuilder == null) { + // cookie name-value pair + NettyCookie cookie = initCookie(header, nameBegin, nameEnd, valueBegin, valueEnd); + if (nameBegin == -1 || nameBegin == nameEnd) { + logger.debug("Skipping cookie with null name"); + } else if (valueBegin == -1) { + logger.debug("Skipping cookie with null value"); + } else { + CharSequence wrappedValue = CharBuffer.wrap(header, valueBegin, valueEnd); + CharSequence unwrappedValue = unwrapValue(wrappedValue); + if (unwrappedValue == null) { + logger.debug("Skipping cookie because starting quotes are not properly balanced in '{}'", + wrappedValue); + } else { + final String name = header.substring(nameBegin, nameEnd); + int invalidOctetPos; + if (strict && (invalidOctetPos = firstInvalidCookieNameOctet(name)) >= 0) { + if (logger.isDebugEnabled()) { + logger.debug("Skipping cookie because name '{}' contains invalid char '{}'", + name, name.charAt(invalidOctetPos)); + } + } else { + final boolean wrap = unwrappedValue.length() != valueEnd - valueBegin; + if (strict && (invalidOctetPos = firstInvalidCookieValueOctet(unwrappedValue)) >= 0) { + if (logger.isDebugEnabled()) { + logger.debug("Skipping cookie because value '{}' contains invalid char '{}'", + unwrappedValue, unwrappedValue.charAt(invalidOctetPos)); + } + } else { + NettyCookie cookie1 = new NettyCookie(name, unwrappedValue.toString()); + cookie1.setWrap(wrap); + cookie = cookie1; + } + } + } + } + + if (cookie == null) { + return null; + } + + cookieBuilder = new CookieBuilder(cookie, header); + } else { + // cookie attribute + cookieBuilder.appendAttribute(nameBegin, nameEnd, valueBegin, valueEnd); + } + } + return cookieBuilder != null ? cookieBuilder.cookie() : null; + } + + protected NettyCookie initCookie(String header, int nameBegin, int nameEnd, int valueBegin, int valueEnd) { + if (nameBegin == -1 || nameBegin == nameEnd) { + logger.debug("Skipping cookie with null name"); + return null; + } + + if (valueBegin == -1) { + logger.debug("Skipping cookie with null value"); + return null; + } + + CharSequence wrappedValue = CharBuffer.wrap(header, valueBegin, valueEnd); + CharSequence unwrappedValue = unwrapValue(wrappedValue); + if (unwrappedValue == null) { + logger.debug("Skipping cookie because starting quotes are not properly balanced in '{}'", + wrappedValue); + return null; + } + + final String name = header.substring(nameBegin, nameEnd); + + int invalidOctetPos; + if (strict && (invalidOctetPos = firstInvalidCookieNameOctet(name)) >= 0) { + if (logger.isDebugEnabled()) { + logger.debug("Skipping cookie because name '{}' contains invalid char '{}'", + name, name.charAt(invalidOctetPos)); + } + return null; + } + + final boolean wrap = unwrappedValue.length() != valueEnd - valueBegin; + + if (strict && (invalidOctetPos = firstInvalidCookieValueOctet(unwrappedValue)) >= 0) { + if (logger.isDebugEnabled()) { + logger.debug("Skipping cookie because value '{}' contains invalid char '{}'", + unwrappedValue, unwrappedValue.charAt(invalidOctetPos)); + } + return null; + } + + NettyCookie cookie = new NettyCookie(name, unwrappedValue.toString()); + cookie.setWrap(wrap); + return cookie; + } + + + private static class CookieBuilder { + + private final String header; + private final NettyCookie cookie; + private String domain; + private String path; + private long maxAge = Long.MIN_VALUE; + private int expiresStart; + private int expiresEnd; + private boolean secure; + private boolean httpOnly; + private String sameSite; + + CookieBuilder(NettyCookie cookie, String header) { + this.cookie = cookie; + this.header = header; + } + + private long mergeMaxAgeAndExpires() { + // max age has precedence over expires + if (maxAge != Long.MIN_VALUE) { + return maxAge; + } else if (isValueDefined(expiresStart, expiresEnd)) { + Date expiresDate = DateFormatter.parseHttpDate(header, expiresStart, expiresEnd); + if (expiresDate != null) { + long maxAgeMillis = expiresDate.getTime() - System.currentTimeMillis(); + return maxAgeMillis / 1000 + (maxAgeMillis % 1000 != 0 ? 1 : 0); + } + } + return Long.MIN_VALUE; + } + + NettyCookie cookie() { + cookie.setDomain(domain); + cookie.setPath(path); + cookie.setMaxAge(mergeMaxAgeAndExpires()); + cookie.setSecure(secure); + cookie.setHttpOnly(httpOnly); + cookie.setSameSite(sameSite); + return cookie; + } + + /** + * Parse and store a key-value pair. First one is considered to be the + * cookie name/value. Unknown attribute names are silently discarded. + * + * @param keyStart where the key starts in the header + * @param keyEnd where the key ends in the header + * @param valueStart where the value starts in the header + * @param valueEnd where the value ends in the header + */ + void appendAttribute(int keyStart, int keyEnd, int valueStart, int valueEnd) { + int length = keyEnd - keyStart; + + if (length == 4) { + parse4(keyStart, valueStart, valueEnd); + } else if (length == 6) { + parse6(keyStart, valueStart, valueEnd); + } else if (length == 7) { + parse7(keyStart, valueStart, valueEnd); + } else if (length == 8) { + parse8(keyStart, valueStart, valueEnd); + } + } + + private void parse4(int nameStart, int valueStart, int valueEnd) { + if (header.regionMatches(true, nameStart, CookieHeaderNames.PATH, 0, 4)) { + path = computeValue(valueStart, valueEnd); + } + } + + private void parse6(int nameStart, int valueStart, int valueEnd) { + if (header.regionMatches(true, nameStart, CookieHeaderNames.DOMAIN, 0, 5)) { + domain = computeValue(valueStart, valueEnd); + } else if (header.regionMatches(true, nameStart, CookieHeaderNames.SECURE, 0, 5)) { + secure = true; + } + } + + private void setMaxAge(String value) { + try { + maxAge = Math.max(Long.parseLong(value), 0L); + } catch (NumberFormatException e1) { + // ignore failure to parse -> treat as session cookie + } + } + + private void parse7(int nameStart, int valueStart, int valueEnd) { + if (header.regionMatches(true, nameStart, CookieHeaderNames.EXPIRES, 0, 7)) { + expiresStart = valueStart; + expiresEnd = valueEnd; + } else if (header.regionMatches(true, nameStart, CookieHeaderNames.MAX_AGE, 0, 7)) { + setMaxAge(computeValue(valueStart, valueEnd)); + } + } + + private void parse8(int nameStart, int valueStart, int valueEnd) { + if (header.regionMatches(true, nameStart, CookieHeaderNames.HTTPONLY, 0, 8)) { + httpOnly = true; + } else if (header.regionMatches(true, nameStart, CookieHeaderNames.SAMESITE, 0, 8)) { + sameSite = computeValue(valueStart, valueEnd); + } + } + + + private static boolean isValueDefined(int valueStart, int valueEnd) { + return valueStart != -1 && valueStart != valueEnd; + } + + private String computeValue(int valueStart, int valueEnd) { + return isValueDefined(valueStart, valueEnd) ? header.substring(valueStart, valueEnd) : null; + } + } +} diff --git a/components/api/src/main/java/com/hotels/styx/api/CookieHeaderNames.java b/components/api/src/main/java/com/hotels/styx/api/CookieHeaderNames.java new file mode 100644 index 0000000000..6391d17b52 --- /dev/null +++ b/components/api/src/main/java/com/hotels/styx/api/CookieHeaderNames.java @@ -0,0 +1,37 @@ +/* + * Copyright 2015 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: + * + * http://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 com.hotels.styx.api; + +final class CookieHeaderNames { + static final String PATH = "Path"; + + static final String EXPIRES = "Expires"; + + static final String MAX_AGE = "Max-Age"; + + static final String DOMAIN = "Domain"; + + static final String SECURE = "Secure"; + + static final String HTTPONLY = "HTTPOnly"; + + static final String SAMESITE = "SameSite"; + + private CookieHeaderNames() { + // Unused. + } + +} diff --git a/components/api/src/main/java/com/hotels/styx/api/HttpVersion.java b/components/api/src/main/java/com/hotels/styx/api/HttpVersion.java index 69a4d6f393..f9b17bca9b 100644 --- a/components/api/src/main/java/com/hotels/styx/api/HttpVersion.java +++ b/components/api/src/main/java/com/hotels/styx/api/HttpVersion.java @@ -1,5 +1,5 @@ /* - Copyright (C) 2013-2018 Expedia Inc. + Copyright (C) 2013-2020 Expedia Inc. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -40,12 +40,12 @@ private HttpVersion(String version) { /** * Creates a HttpVersion from String. - * + *
* Accepted strings are "HTTP/1.0" and "HTTP/1.1". * Otherwise throws an {@link IllegalArgumentException}. * * @param version - * @return + * @return HttpVersion for the received version */ public static HttpVersion httpVersion(String version) { checkArgument(VERSIONS.containsKey(version), "No such HTTP version %s", version); diff --git a/components/api/src/main/java/com/hotels/styx/api/NettyCookie.java b/components/api/src/main/java/com/hotels/styx/api/NettyCookie.java new file mode 100644 index 0000000000..d505f3cc05 --- /dev/null +++ b/components/api/src/main/java/com/hotels/styx/api/NettyCookie.java @@ -0,0 +1,76 @@ +/* + Copyright (C) 2013-2020 Expedia Inc. + + 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 + + http://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 com.hotels.styx.api; + +import io.netty.handler.codec.http.cookie.DefaultCookie; + +import static com.hotels.styx.api.CookieUtil.stringBuilder; + + +class NettyCookie extends DefaultCookie { + + + private String sameSite; + /** + * Creates a new cookie with the specified name and value. + * + * @param name + * @param value + */ + public NettyCookie(String name, String value) { + super(name, value); + } + + + public String sameSite() { + return sameSite; + } + + public void setSameSite(String sameSite) { + this.sameSite = sameSite; + } + + @Override + public String toString() { + StringBuilder buf = stringBuilder() + .append(name()) + .append('=') + .append(value()); + if (domain() != null) { + buf.append(", domain=") + .append(domain()); + } + if (path() != null) { + buf.append(", path=") + .append(path()); + } + if (maxAge() != 0) { + buf.append(", maxAge=") + .append(maxAge()) + .append('s'); + } + if (isSecure()) { + buf.append(", secure"); + } + if (isHttpOnly()) { + buf.append(", HTTPOnly"); + } + if (sameSite != null) { + buf.append(", SameSite=").append(sameSite.toString()); + } + return buf.toString(); + } +} diff --git a/components/api/src/main/java/com/hotels/styx/api/ResponseCookie.java b/components/api/src/main/java/com/hotels/styx/api/ResponseCookie.java index a2601a0c47..91c2e2c2cf 100644 --- a/components/api/src/main/java/com/hotels/styx/api/ResponseCookie.java +++ b/components/api/src/main/java/com/hotels/styx/api/ResponseCookie.java @@ -15,9 +15,6 @@ */ package com.hotels.styx.api; -import io.netty.handler.codec.http.cookie.ClientCookieDecoder; -import io.netty.handler.codec.http.cookie.Cookie; -import io.netty.handler.codec.http.cookie.DefaultCookie; import java.util.Collection; import java.util.List; @@ -33,7 +30,7 @@ /** * Represents an HTTP cookie as sent in the HTTP response {@code Set-Cookie} header. - * + *
* A server can include a {@code ResponseCookie} in its response to a client request.
* It contains cookie {@code name}, {@code value}, and attributes such as {@code path}
* and {@code maxAge}.
@@ -49,6 +46,13 @@ public final class ResponseCookie {
private final boolean httpOnly;
private final boolean secure;
private final int hashCode;
+ private final String sameSite;
+
+ public enum SameSite {
+ Lax,
+ Strict,
+ None
+ }
private ResponseCookie(Builder builder) {
if (builder.name == null || builder.name.isEmpty()) {
@@ -63,7 +67,8 @@ private ResponseCookie(Builder builder) {
this.path = builder.path;
this.httpOnly = builder.httpOnly;
this.secure = builder.secure;
- this.hashCode = hash(name, value, domain, maxAge, path, secure, httpOnly);
+ this.sameSite = builder.sameSite;
+ this.hashCode = hash(name, value, domain, maxAge, path, secure, httpOnly, sameSite);
}
/**
@@ -98,7 +103,7 @@ public static Set
* As Netty's Cookie merges Expires and MaxAge into one single field, only Max-Age field is sent.
- *
+ *
* Note that multiple cookies are supposed to be sent at once in a single "Set-Cookie" header.
*
*
@@ -50,7 +47,6 @@
* {@link HttpRequest} req = ...;
* res.setHeader("Cookie", {@link ServerCookieEncoder}.encode("JSESSIONID", "1234"));
*
- *
*/
final class ServerCookieEncoder extends CookieEncoder {
@@ -58,7 +54,7 @@ final class ServerCookieEncoder extends CookieEncoder {
* Lax instance that doesn't validate name and value, and that allows multiple
* cookies with the same name.
*/
- public static final ServerCookieEncoder LAX = new ServerCookieEncoder(false);
+ static final ServerCookieEncoder LAX = new ServerCookieEncoder(false);
private ServerCookieEncoder(boolean strict) {
super(strict);
@@ -67,12 +63,12 @@ private ServerCookieEncoder(boolean strict) {
/**
* Encodes the specified cookie name-value pair into a Set-Cookie header value.
*
- * @param name the cookie name
+ * @param name the cookie name
* @param value the cookie value
* @return a single Set-Cookie header value
*/
- public String encode(String name, String value) {
- return encode(new DefaultCookie(name, value));
+ String encode(String name, String value) {
+ return encode(new NettyCookie(name, value));
}
/**
@@ -81,7 +77,8 @@ public String encode(String name, String value) {
* @param cookie the cookie
* @return a single Set-Cookie header value
*/
- public String encode(Cookie cookie) {
+ String encode(NettyCookie cookie) {
+
final String name = requireNonNull(cookie, "cookie").name();
final String value = cookie.value() != null ? cookie.value() : "";
@@ -117,12 +114,16 @@ public String encode(Cookie cookie) {
add(buf, CookieHeaderNames.HTTPONLY);
}
+ if (cookie.sameSite() != null) {
+ add(buf, CookieHeaderNames.SAMESITE, cookie.sameSite());
+ }
return stripTrailingSeparator(buf);
}
- /** Deduplicate a list of encoded cookies by keeping only the last instance with a given name.
+ /**
+ * Deduplicate a list of encoded cookies by keeping only the last instance with a given name.
*
- * @param encoded The list of encoded cookies.
+ * @param encoded The list of encoded cookies.
* @param nameToLastIndex A map from cookie name to index of last cookie instance.
* @return The encoded list with all but the last instance of a named cookie.
*/
@@ -146,7 +147,7 @@ private static List