From b8b689f2487a8d53393819d7284d9c0a2f812b4f Mon Sep 17 00:00:00 2001 From: Trustin Lee Date: Thu, 5 Dec 2019 18:11:22 +0900 Subject: [PATCH] Add immutable HTTP cookie API (#2286) Motivation: We currently rely on Netty's HTTP cookie API, but it has the following drawbacks: - It is mutable. - It will not have `SameSite` attribute until Netty 5. - https://github.com/netty/netty/issues/8161 - It will not allow cookies with the same name until Netty 5. - https://github.com/netty/netty/issues/7210 Modifications: - Add `Cookie`, which is an immutable representation of HTTP Cookie. - Add `CookieBuilder` which builds a `Cookie`. - Fork `{Client,Server}Cookie{Encoder,Decoder}` from Netty so it is possible to encode and decode our `Cookie`. - Modify `ServerCookieDecoder` to allow cookies with the same name. Result: - Immutable HTTP cookie API - (Deprecation) `Cookies.copyOf()` has been deprecated in favor of `Cookies.of()`. - (Breaking) `Cookies` now use Armeria's own `Cookie`. - (Breaking?) `Cookies` may contain the cookies with the same name. - RFC does not prohibit duplicate names at all, so this is a correct behavior. - (Breaking?) Annotated services now accept only the first `"Cookie"` header when decoding cookies in an `HttpRequest`. Sending multiple `"Cookie"` headers was a violation of RFC 6265 anyway. --- .../armeria/common/ClientCookieDecoder.java | 265 ++++++++ .../armeria/common/ClientCookieEncoder.java | 180 ++++++ .../com/linecorp/armeria/common/Cookie.java | 590 ++++++++++++++++++ .../armeria/common/CookieBuilder.java | 176 ++++++ .../linecorp/armeria/common/CookieUtil.java | 283 +++++++++ .../annotation => common}/Cookies.java | 34 +- .../armeria/common/DefaultCookie.java | 173 +++++ .../annotation => common}/DefaultCookies.java | 9 +- .../armeria/common/ServerCookieDecoder.java | 162 +++++ .../armeria/common/ServerCookieEncoder.java | 114 ++++ .../annotation/AnnotatedValueResolver.java | 18 +- .../common/ClientCookieDecoderTest.java | 314 ++++++++++ .../common/ClientCookieEncoderTest.java | 90 +++ .../armeria/common/DefaultCookieTest.java | 42 ++ .../common/ServerCookieDecoderTest.java | 214 +++++++ .../common/ServerCookieEncoderTest.java | 163 +++++ .../AnnotatedValueResolverTest.java | 53 +- .../server/annotated/InjectionService.java | 5 +- .../armeria/server/saml/sp/MyAuthHandler.java | 21 +- .../armeria/server/saml/sp/MyService.java | 5 +- .../server/saml/SamlServiceProviderTest.java | 18 +- site/src/sphinx/advanced-saml.rst | 15 +- .../reactive/ArmeriaServerHttpResponse.java | 28 +- .../ArmeriaServerHttpResponseTest.java | 29 +- 24 files changed, 2888 insertions(+), 113 deletions(-) create mode 100644 core/src/main/java/com/linecorp/armeria/common/ClientCookieDecoder.java create mode 100644 core/src/main/java/com/linecorp/armeria/common/ClientCookieEncoder.java create mode 100644 core/src/main/java/com/linecorp/armeria/common/Cookie.java create mode 100644 core/src/main/java/com/linecorp/armeria/common/CookieBuilder.java create mode 100644 core/src/main/java/com/linecorp/armeria/common/CookieUtil.java rename core/src/main/java/com/linecorp/armeria/{server/annotation => common}/Cookies.java (53%) create mode 100644 core/src/main/java/com/linecorp/armeria/common/DefaultCookie.java rename core/src/main/java/com/linecorp/armeria/{server/annotation => common}/DefaultCookies.java (83%) create mode 100644 core/src/main/java/com/linecorp/armeria/common/ServerCookieDecoder.java create mode 100644 core/src/main/java/com/linecorp/armeria/common/ServerCookieEncoder.java create mode 100644 core/src/test/java/com/linecorp/armeria/common/ClientCookieDecoderTest.java create mode 100644 core/src/test/java/com/linecorp/armeria/common/ClientCookieEncoderTest.java create mode 100644 core/src/test/java/com/linecorp/armeria/common/DefaultCookieTest.java create mode 100644 core/src/test/java/com/linecorp/armeria/common/ServerCookieDecoderTest.java create mode 100644 core/src/test/java/com/linecorp/armeria/common/ServerCookieEncoderTest.java diff --git a/core/src/main/java/com/linecorp/armeria/common/ClientCookieDecoder.java b/core/src/main/java/com/linecorp/armeria/common/ClientCookieDecoder.java new file mode 100644 index 00000000000..d001758b56e --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/common/ClientCookieDecoder.java @@ -0,0 +1,265 @@ +/* + * Copyright 2019 LINE Corporation + * + * LINE Corporation 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 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.linecorp.armeria.common; + +import static com.linecorp.armeria.common.CookieUtil.initCookie; + +import java.util.Date; + +import javax.annotation.Nullable; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.netty.handler.codec.DateFormatter; +import io.netty.handler.codec.http.cookie.CookieHeaderNames; + +/** + * A RFC 6265 compliant cookie decoder for client side. + * + *

It will store the way the raw value was wrapped in {@link Cookie#isValueQuoted()} so it can be sent back + * to the origin server as is.

+ * + * @see ClientCookieEncoder + */ +final class ClientCookieDecoder { + + // Forked from netty-4.1.43 + // https://github.com/netty/netty/blob/587afddb279bea3fd0f64d3421de8e69a35cecb9/codec-http/src/main/java/io/netty/handler/codec/http/cookie/ClientCookieDecoder.java + + private static final Logger logger = LoggerFactory.getLogger(ClientCookieDecoder.class); + + /** + * Decodes the specified {@code "Set-Cookie"} header value into a {@link Cookie}. + * + * @param strict whether to validate the name and value chars are in the valid scope defined in RFC 6265. + * @return the decoded {@link Cookie}, or {@code null} if malformed. + */ + @Nullable + static Cookie decode(boolean strict, String header) { + final int headerLen = header.length(); + assert headerLen != 0 : headerLen; + + CookieBuilder builder = null; + + loop: for (int i = 0;;) { + + // Skip spaces and separators. + for (;;) { + if (i == headerLen) { + break loop; + } + final 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; + } + + if (c == '\t' || c == '\n' || c == 0x0b || c == '\f' || + c == '\r' || c == ' ' || c == ';') { + i++; + continue; + } + break; + } + + final int nameBegin = i; + final int nameEnd; + final int valueBegin; + int valueEnd; + + for (;;) { + final char curChar = header.charAt(i); + if (curChar == ';') { + // NAME; (no value till ';') + nameEnd = i; + valueBegin = valueEnd = -1; + break; + } + + 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; + final int semiPos = header.indexOf(';', i); + valueEnd = i = semiPos > 0 ? semiPos : headerLen; + break; + } + + 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 (builder == null) { + // cookie name-value pair + builder = initCookie(logger, strict, header, nameBegin, nameEnd, valueBegin, valueEnd); + if (builder == null) { + return null; + } + } else { + // cookie attribute + appendAttribute(builder, header, nameBegin, nameEnd, valueBegin, valueEnd); + } + } + + if (builder == null) { + return null; + } + + mergeMaxAgeAndExpires(builder, header); + return builder.build(); + } + + /** + * 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 + */ + private static void appendAttribute(CookieBuilder builder, String header, + int keyStart, int keyEnd, int valueStart, int valueEnd) { + final int length = keyEnd - keyStart; + + if (length == 4) { + parse4(builder, header, keyStart, valueStart, valueEnd); + } else if (length == 6) { + parse6(builder, header, keyStart, valueStart, valueEnd); + } else if (length == 7) { + parse7(builder, header, keyStart, valueStart, valueEnd); + } else if (length == 8) { + parse8(builder, header, keyStart); + } + } + + private static void parse4(CookieBuilder builder, String header, + int nameStart, int valueStart, int valueEnd) { + if (header.regionMatches(true, nameStart, CookieHeaderNames.PATH, 0, 4)) { + final String path = computeValue(header, valueStart, valueEnd); + if (path != null) { + builder.path(path); + } + } + } + + private static void parse6(CookieBuilder builder, String header, + int nameStart, int valueStart, int valueEnd) { + if (header.regionMatches(true, nameStart, CookieHeaderNames.DOMAIN, 0, 5)) { + final String domain = computeValue(header, valueStart, valueEnd); + if (domain != null) { + builder.domain(domain); + } + } else if (header.regionMatches(true, nameStart, CookieHeaderNames.SECURE, 0, 5)) { + builder.secure(true); + } + } + + private static void setMaxAge(CookieBuilder builder, String value) { + try { + builder.maxAge(Math.max(Long.parseLong(value), 0L)); + } catch (NumberFormatException e1) { + // ignore failure to parse -> treat as session cookie + } + } + + private static void parse7(CookieBuilder builder, String header, + int nameStart, int valueStart, int valueEnd) { + if (header.regionMatches(true, nameStart, CookieHeaderNames.EXPIRES, 0, 7)) { + builder.expiresStart = valueStart; + builder.expiresEnd = valueEnd; + } else if (header.regionMatches(true, nameStart, CookieHeaderNames.MAX_AGE, 0, 7)) { + final String maxAge = computeValue(header, valueStart, valueEnd); + if (maxAge != null) { + setMaxAge(builder, maxAge); + } + } + } + + private static void parse8(CookieBuilder builder, String header, + int nameStart) { + if (header.regionMatches(true, nameStart, CookieHeaderNames.HTTPONLY, 0, 8)) { + builder.httpOnly(true); + } + } + + private static boolean isValueDefined(int valueStart, int valueEnd) { + return valueStart != -1 && valueStart != valueEnd; + } + + @Nullable + private static String computeValue(String header, int valueStart, int valueEnd) { + return isValueDefined(valueStart, valueEnd) ? header.substring(valueStart, valueEnd) : null; + } + + private static void mergeMaxAgeAndExpires(CookieBuilder builder, String header) { + // max age has precedence over expires + if (builder.maxAge != Cookie.UNDEFINED_MAX_AGE) { + return; + } + + if (isValueDefined(builder.expiresStart, builder.expiresEnd)) { + final Date expiresDate = + DateFormatter.parseHttpDate(header, builder.expiresStart, builder.expiresEnd); + if (expiresDate != null) { + final long maxAgeMillis = expiresDate.getTime() - System.currentTimeMillis(); + builder.maxAge(maxAgeMillis / 1000 + (maxAgeMillis % 1000 != 0 ? 1 : 0)); + } + } + } + + private ClientCookieDecoder() {} +} diff --git a/core/src/main/java/com/linecorp/armeria/common/ClientCookieEncoder.java b/core/src/main/java/com/linecorp/armeria/common/ClientCookieEncoder.java new file mode 100644 index 00000000000..92860ae23ef --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/common/ClientCookieEncoder.java @@ -0,0 +1,180 @@ +/* + * Copyright 2019 LINE Corporation + * + * LINE Corporation 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 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.linecorp.armeria.common; + +import static com.google.common.base.MoreObjects.firstNonNull; +import static com.linecorp.armeria.common.CookieUtil.add; +import static com.linecorp.armeria.common.CookieUtil.addQuoted; +import static com.linecorp.armeria.common.CookieUtil.stringBuilder; +import static com.linecorp.armeria.common.CookieUtil.stripTrailingSeparator; +import static java.util.Objects.requireNonNull; + +import java.util.Arrays; +import java.util.Comparator; +import java.util.Iterator; +import java.util.List; + +import io.netty.util.internal.InternalThreadLocalMap; + +/** + * A RFC 6265 compliant cookie encoder for client side. + * + *

Note that multiple cookies are supposed to be sent at once in a single {@code "Cookie"} header.

+ * + * @see ClientCookieDecoder + */ +final class ClientCookieEncoder { + + // Forked from netty-4.1.43 + // https://github.com/netty/netty/blob/0623c6c5334bf43299e835cfcf86bfda19e2d4ce/codec-http/src/main/java/io/netty/handler/codec/http/ClientCookieEncoder.java + + private static final Cookie[] EMPTY_COOKIES = new Cookie[0]; + + /** + * Encodes the specified {@link Cookie} into a single {@code "Cookie"} header value. + * + * @param strict whether to validate that name and value chars are in the valid scope. + * @param cookie the {@link Cookie} to encode. + * @return a RFC 6265-style {@code "Cookie"} header value. + */ + static String encode(boolean strict, Cookie cookie) { + requireNonNull(cookie, "cookie"); + final StringBuilder buf = stringBuilder(); + encode(strict, buf, cookie); + return stripTrailingSeparator(buf); + } + + /** + * Sort cookies into decreasing order of path length, breaking ties by sorting into increasing chronological + * order of creation time, as recommended by RFC 6265. + */ + private static final Comparator COOKIE_COMPARATOR = (c1, c2) -> { + final String path1 = c1.path(); + final String path2 = c2.path(); + // Cookies with unspecified path default to the path of the request. We don't + // know the request path here, but we assume that the length of an unspecified + // path is longer than any specified path (i.e. pathless cookies come first), + // because setting cookies with a path longer than the request path is of + // limited use. + final int len1 = path1 == null ? Integer.MAX_VALUE : path1.length(); + final int len2 = path2 == null ? Integer.MAX_VALUE : path2.length(); + final int res = Integer.compare(len2, len1); + if (res != 0) { + return res; + } + // Rely on Java's sort stability to retain creation order in cases where + // cookies have same path length. + return -1; + }; + + /** + * Encodes the specified cookies into a single {@code "Cookie"} header value. + * + * @param strict whether to validate that name and value chars are in the valid scope and + * to sort the cookies into order of decreasing path length, as specified in RFC 6265. + * If {@code false}, the cookies are encoded in the order in which they are given. + * @param cookies the {@link Cookie}s to encode. + * @return a RFC 6265-style {@code "Cookie"} header value. + */ + static String encode(boolean strict, Cookie... cookies) { + assert cookies.length != 0 : cookies.length; + + final StringBuilder buf = stringBuilder(); + if (strict) { + if (cookies.length == 1) { + encode(true, buf, cookies[0]); + } else { + final Cookie[] cookiesSorted = Arrays.copyOf(cookies, cookies.length); + Arrays.sort(cookiesSorted, COOKIE_COMPARATOR); + for (Cookie c : cookiesSorted) { + encode(true, buf, c); + } + } + } else { + for (Cookie c : cookies) { + encode(false, buf, c); + } + } + return stripTrailingSeparator(buf); + } + + /** + * Encodes the specified cookies into a single {@code "Cookie"} header value. + * + * @param strict whether to validate that name and value chars are in the valid scope and + * to sort the cookies into order of decreasing path length, as specified in RFC 6265. + * If {@code false}, the cookies are encoded in the order in which they are given. + * @param cookiesIt the {@link Iterator} of the {@link Cookie}s to encode. + * @return a RFC 6265-style {@code "Cookie"} header value, or {@code null} if no cookies were specified. + */ + static String encode(boolean strict, Iterator cookiesIt) { + assert cookiesIt.hasNext(); + + final StringBuilder buf = stringBuilder(); + if (strict) { + final Cookie firstCookie = cookiesIt.next(); + if (!cookiesIt.hasNext()) { + encode(true, buf, firstCookie); + } else { + final List cookiesList = InternalThreadLocalMap.get().arrayList(); + cookiesList.add(firstCookie); + while (cookiesIt.hasNext()) { + cookiesList.add(cookiesIt.next()); + } + final Cookie[] cookiesSorted = cookiesList.toArray(EMPTY_COOKIES); + Arrays.sort(cookiesSorted, COOKIE_COMPARATOR); + for (Cookie c : cookiesSorted) { + encode(true, buf, c); + } + } + } else { + do { + encode(false, buf, cookiesIt.next()); + } while (cookiesIt.hasNext()); + } + return stripTrailingSeparator(buf); + } + + private static void encode(boolean strict, StringBuilder buf, Cookie c) { + final String name = c.name(); + final String value = firstNonNull(c.value(), ""); + + CookieUtil.validateCookie(strict, name, value); + + if (c.isValueQuoted()) { + addQuoted(buf, name, value); + } else { + add(buf, name, value); + } + } + + private ClientCookieEncoder() {} +} diff --git a/core/src/main/java/com/linecorp/armeria/common/Cookie.java b/core/src/main/java/com/linecorp/armeria/common/Cookie.java new file mode 100644 index 00000000000..86aef317b50 --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/common/Cookie.java @@ -0,0 +1,590 @@ +/* + * Copyright 2019 LINE Corporation + * + * LINE Corporation 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 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.linecorp.armeria.common; + +import static com.google.common.base.Preconditions.checkArgument; +import static java.util.Objects.requireNonNull; + +import java.util.Collection; +import java.util.Iterator; +import java.util.List; +import java.util.function.Consumer; + +import javax.annotation.Nullable; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; + +/** + * An interface defining an + * HTTP cookie. + */ +@SuppressWarnings("checkstyle:OverloadMethodsDeclarationOrder") +public interface Cookie extends Comparable { + + // Forked from netty-4.1.43 + // https://github.com/netty/netty/blob/f89dfb0bd53af45eb5f1c1dcc7d9badd889d17f0/codec-http/src/main/java/io/netty/handler/codec/http/Cookie.java + + /** + * Returns a newly created {@link Cookie}. + * + * @param name the name of the {@link Cookie} + * @param value the value of the {@link Cookie} + */ + static Cookie of(String name, String value) { + return builder(name, value).build(); + } + + /** + * Returns a newly created {@link CookieBuilder} which builds a {@link Cookie}. + * + * @param name the name of the {@link Cookie} + * @param value the value of the {@link Cookie} + */ + static CookieBuilder builder(String name, String value) { + return new CookieBuilder(name, value); + } + + /** + * Decodes the specified {@code "Cookie"} header value into a set of {@link Cookie}s. + * + * @param cookieHeader the {@code "Cookie"} header value. + * @return the decoded {@link Cookie}s. + */ + static Cookies fromCookieHeader(String cookieHeader) { + return fromCookieHeader(true, cookieHeader); + } + + /** + * Decodes the specified {@code "Cookie"} header value into a set of {@link Cookie}s. + * + * @param strict whether to validate that the cookie names and values are in the valid scope + * defined in RFC 6265. + * @param cookieHeader the {@code "Cookie"} header value. + * @return the decoded {@link Cookie}s. + */ + static Cookies fromCookieHeader(boolean strict, String cookieHeader) { + requireNonNull(cookieHeader, "cookieHeader"); + if (cookieHeader.isEmpty()) { + return Cookies.empty(); + } + return ServerCookieDecoder.decode(strict, cookieHeader); + } + + /** + * Encodes the specified {@link Cookie}s into a {@code "Cookie"} header value. + * + * @param cookies the {@link Cookie}s to encode. + * @return the encoded {@code "Cookie"} header value. + */ + static String toCookieHeader(Cookie... cookies) { + return toCookieHeader(true, cookies); + } + + /** + * Encodes the specified {@link Cookie}s into a {@code "Cookie"} header value. + * + * @param cookies the {@link Cookie}s to encode. + * @return the encoded {@code "Cookie"} header value. + * + * @throws IllegalArgumentException if {@code cookies} is empty. + */ + static String toCookieHeader(Iterable cookies) { + return toCookieHeader(true, cookies); + } + + /** + * Encodes the specified {@link Cookie}s into a {@code "Cookie"} header value. + * + * @param cookies the {@link Cookie}s to encode. + * @return the encoded {@code "Cookie"} header value. + * + * @throws IllegalArgumentException if {@code cookies} is empty. + */ + static String toCookieHeader(Collection cookies) { + return toCookieHeader(true, cookies); + } + + /** + * Encodes the specified {@link Cookie}s into a {@code "Cookie"} header value. + * + * @param strict whether to validate that cookie names and values are in the valid scope + * defined in RFC 6265 and to sort the {@link Cookie}s into order of decreasing path length, + * as specified in RFC 6265. If {@code false}, the {@link Cookie}s are encoded in the order + * in which they are given. + * @param cookies the {@link Cookie}s to encode. + * @return the encoded {@code "Cookie"} header value. + */ + static String toCookieHeader(boolean strict, Cookie... cookies) { + requireNonNull(cookies, "cookies"); + checkArgument(cookies.length != 0, "cookies is empty."); + return ClientCookieEncoder.encode(strict, cookies); + } + + /** + * Encodes the specified {@link Cookie}s into a {@code "Cookie"} header value. + * + * @param strict whether to validate that cookie names and values are in the valid scope + * defined in RFC 6265 and to sort the {@link Cookie}s into order of decreasing path length, + * as specified in RFC 6265. If {@code false}, the {@link Cookie}s are encoded in the order + * in which they are given. + * @param cookies the {@link Cookie}s to encode. + * @return the encoded {@code "Cookie"} header value. + * + * @throws IllegalArgumentException if {@code cookies} is empty. + */ + static String toCookieHeader(boolean strict, Iterable cookies) { + if (cookies instanceof Collection) { + @SuppressWarnings("unchecked") + final Collection cast = (Collection) cookies; + return toCookieHeader(strict, cast); + } + + requireNonNull(cookies, "cookies"); + final Iterator it = cookies.iterator(); + checkArgument(it.hasNext(), "cookies is empty"); + return ClientCookieEncoder.encode(strict, it); + } + + /** + * Encodes the specified {@link Cookie}s into a {@code "Cookie"} header value. + * + * @param strict whether to validate that cookie names and values are in the valid scope + * defined in RFC 6265 and to sort the {@link Cookie}s into order of decreasing path length, + * as specified in RFC 6265. If {@code false}, the {@link Cookie}s are encoded in the order + * in which they are given. + * @param cookies the {@link Cookie}s to encode. + * @return the encoded {@code "Cookie"} header value. + * + * @throws IllegalArgumentException if {@code cookies} is empty. + */ + static String toCookieHeader(boolean strict, Collection cookies) { + requireNonNull(cookies, "cookies"); + checkArgument(!cookies.isEmpty(), "cookies is empty"); + return ClientCookieEncoder.encode(strict, cookies.iterator()); + } + + /** + * Decodes the specified {@code "Set-Cookie"} header value into a {@link Cookie}. + * + * @param setCookieHeader the {@code "Set-Cookie"} header value. + * @return the decoded {@link Cookie} if decoded successfully. {@code null} otherwise. + */ + @Nullable + static Cookie fromSetCookieHeader(String setCookieHeader) { + return fromSetCookieHeader(true, setCookieHeader); + } + + /** + * Decodes the specified {@code "Set-Cookie"} header value into a {@link Cookie}. + * + * @param strict whether to validate the cookie names and values are in the valid scope defined in RFC 6265. + * @param setCookieHeader the {@code "Set-Cookie"} header value. + * @return the decoded {@link Cookie} if decoded successfully. {@code null} otherwise. + */ + @Nullable + static Cookie fromSetCookieHeader(boolean strict, String setCookieHeader) { + requireNonNull(setCookieHeader, "setCookieHeader"); + if (setCookieHeader.isEmpty()) { + return null; + } + return ClientCookieDecoder.decode(strict, setCookieHeader); + } + + /** + * Decodes the specified {@code "Set-Cookie"} header values into {@link Cookie}s. + * + * @param setCookieHeaders the {@code "Set-Cookie"} header values. + * @return the decoded {@link Cookie}s. + */ + static Cookies fromSetCookieHeaders(String... setCookieHeaders) { + return fromSetCookieHeaders(true, setCookieHeaders); + } + + /** + * Decodes the specified {@code "Set-Cookie"} header values into {@link Cookie}s. + * + * @param setCookieHeaders the {@code "Set-Cookie"} header values. + * @return the decoded {@link Cookie}s. + */ + static Cookies fromSetCookieHeaders(Iterable setCookieHeaders) { + return fromSetCookieHeaders(true, setCookieHeaders); + } + + /** + * Decodes the specified {@code "Set-Cookie"} header values into {@link Cookie}s. + * + * @param setCookieHeaders the {@code "Set-Cookie"} header values. + * @return the decoded {@link Cookie}s. + */ + static Cookies fromSetCookieHeaders(Collection setCookieHeaders) { + return fromSetCookieHeaders(true, setCookieHeaders); + } + + /** + * Decodes the specified {@code "Set-Cookie"} header values into {@link Cookie}s. + * + * @param strict whether to validate the cookie names and values are in the valid scope defined in RFC 6265. + * @param setCookieHeaders the {@code "Set-Cookie"} header values. + * @return the decoded {@link Cookie}s. + */ + static Cookies fromSetCookieHeaders(boolean strict, String... setCookieHeaders) { + requireNonNull(setCookieHeaders, "setCookieHeaders"); + if (setCookieHeaders.length == 0) { + return Cookies.empty(); + } + + final ImmutableSet.Builder builder = + ImmutableSet.builderWithExpectedSize(setCookieHeaders.length); + for (String v : setCookieHeaders) { + requireNonNull(v, "setCookieHeaders contains null."); + final Cookie cookie = fromSetCookieHeader(strict, v); + if (cookie != null) { + builder.add(cookie); + } + } + + return Cookies.of(builder.build()); + } + + /** + * Decodes the specified {@code "Set-Cookie"} header values into {@link Cookie}s. + * + * @param strict whether to validate the cookie names and values are in the valid scope defined in RFC 6265. + * @param setCookieHeaders the {@code "Set-Cookie"} header values. + * @return the decoded {@link Cookie}s. + */ + static Cookies fromSetCookieHeaders(boolean strict, Iterable setCookieHeaders) { + if (setCookieHeaders instanceof Collection) { + return fromSetCookieHeaders(strict, (Collection) setCookieHeaders); + } + + requireNonNull(setCookieHeaders, "setCookieHeaders"); + final Iterator it = setCookieHeaders.iterator(); + if (!it.hasNext()) { + return Cookies.empty(); + } + + return CookieUtil.fromSetCookieHeaders(ImmutableSet.builder(), strict, it); + } + + /** + * Decodes the specified {@code "Set-Cookie"} header values into {@link Cookie}s. + * + * @param strict whether to validate the cookie names and values are in the valid scope defined in RFC 6265. + * @param setCookieHeaders the {@code "Set-Cookie"} header values. + * @return the decoded {@link Cookie}s. + */ + static Cookies fromSetCookieHeaders(boolean strict, Collection setCookieHeaders) { + requireNonNull(setCookieHeaders, "setCookieHeaders"); + if (setCookieHeaders.isEmpty()) { + return Cookies.empty(); + } + + return CookieUtil.fromSetCookieHeaders(ImmutableSet.builderWithExpectedSize(setCookieHeaders.size()), + strict, setCookieHeaders.iterator()); + } + + /** + * Encodes the specified {@link Cookie}s into {@code "Set-Cookie"} header values. + * + * @param cookies the {@link Cookie}s to encode. + * @return the encoded {@code "Set-Cookie"} header values. + */ + static List toSetCookieHeaders(Cookie... cookies) { + return toSetCookieHeaders(true, cookies); + } + + /** + * Encodes the specified {@link Cookie}s into {@code "Set-Cookie"} header values. + * + * @param cookies the {@link Cookie}s to encode. + * @return the encoded {@code "Set-Cookie"} header values. + */ + static List toSetCookieHeaders(Iterable cookies) { + return toSetCookieHeaders(true, cookies); + } + + /** + * Encodes the specified {@link Cookie}s into {@code "Set-Cookie"} header values. + * + * @param cookies the {@link Cookie}s to encode. + * @return the encoded {@code "Set-Cookie"} header values. + */ + static List toSetCookieHeaders(Collection cookies) { + return toSetCookieHeaders(true, cookies); + } + + /** + * Encodes the specified {@link Cookie}s into {@code "Set-Cookie"} header values. + * + * @param strict whether to validate that the cookie names and values are in the valid scope + * defined in RFC 6265. + * @param cookies the {@link Cookie}s to encode. + * @return the encoded {@code "Set-Cookie"} header values. + */ + static List toSetCookieHeaders(boolean strict, Cookie... cookies) { + requireNonNull(cookies, "cookies"); + if (cookies.length == 0) { + return ImmutableList.of(); + } + + final ImmutableList.Builder encoded = ImmutableList.builderWithExpectedSize(cookies.length); + for (final Cookie c : cookies) { + encoded.add(c.toSetCookieHeader(strict)); + } + return encoded.build(); + } + + /** + * Encodes the specified {@link Cookie}s into {@code "Set-Cookie"} header values. + * + * @param strict whether to validate that the cookie names and values are in the valid scope + * defined in RFC 6265. + * @param cookies the {@link Cookie}s to encode. + * @return the encoded {@code "Set-Cookie"} header values. + */ + static List toSetCookieHeaders(boolean strict, Iterable cookies) { + if (cookies instanceof Collection) { + @SuppressWarnings("unchecked") + final Collection cast = (Collection) cookies; + return toSetCookieHeaders(strict, cast); + } + + requireNonNull(cookies, "cookies"); + final Iterator it = cookies.iterator(); + if (!it.hasNext()) { + return ImmutableList.of(); + } + + final ImmutableList.Builder encoded = ImmutableList.builder(); + return CookieUtil.toSetCookieHeaders(encoded, strict, it); + } + + /** + * Encodes the specified {@link Cookie}s into {@code "Set-Cookie"} header values. + * + * @param strict whether to validate that the cookie names and values are in the valid scope + * defined in RFC 6265. + * @param cookies the {@link Cookie}s to encode. + * @return the encoded {@code "Set-Cookie"} header values. + */ + static List toSetCookieHeaders(boolean strict, Collection cookies) { + requireNonNull(cookies, "cookies"); + if (cookies.isEmpty()) { + return ImmutableList.of(); + } + + return CookieUtil.toSetCookieHeaders(ImmutableList.builderWithExpectedSize(cookies.size()), + strict, cookies.iterator()); + } + + /** + * Constant for undefined MaxAge attribute value. + */ + long UNDEFINED_MAX_AGE = Long.MIN_VALUE; + + /** + * Returns the name of this {@link Cookie}. + */ + String name(); + + /** + * Returns the value of this {@link Cookie}. + */ + String value(); + + /** + * Returns whether the raw value of this {@link Cookie} was wrapped with double quotes + * in the original {@code "Set-Cookie"} header. + */ + boolean isValueQuoted(); + + /** + * Returns the domain of this {@link Cookie}. + * + * @return the domain, or {@code null}. + */ + @Nullable + String domain(); + + /** + * Returns the path of this {@link Cookie}. + * + * @return the path, or {@code null}. + */ + @Nullable + String path(); + + /** + * Returns the maximum age of this {@link Cookie} in seconds. + * + * @return the maximum age, or {@link Cookie#UNDEFINED_MAX_AGE} if unspecified. + */ + long maxAge(); + + /** + * Returns whether this {@link Cookie} is secure. + */ + boolean isSecure(); + + /** + * Returns whether this {@link Cookie} can only be accessed via HTTP. + * If this returns {@code true}, the {@link Cookie} cannot be accessed through client side script. + * However, it works only if the browser supports it. + * Read here for more information. + */ + boolean isHttpOnly(); + + /** + * Returns the {@code "SameSite"} attribute of this {@link Cookie}. + * + * @return the {@code "SameSite"} attribute, or {@code null}. + */ + @Nullable + String sameSite(); + + /** + * Encodes this {@link Cookie} into a single {@code "Cookie"} header value. + * Note that you must use {@link #toCookieHeader(Collection)} when encoding more than one {@link Cookie}, + * because it is prohibited to send multiple {@code "Cookie"} headers in an HTTP request, + * according to RFC 6265. + * + * @return a single RFC 6265-style {@code "Cookie"} header value. + */ + default String toCookieHeader() { + return toCookieHeader(true); + } + + /** + * Encodes this {@link Cookie} into a single {@code "Cookie"} header value. + * Note that you must use {@link #toCookieHeader(boolean, Collection)} when encoding + * more than one {@link Cookie}, because it is prohibited to send multiple {@code "Cookie"} headers + * in an HTTP request, according to RFC 6265. + * + * @param strict whether to validate that the cookie name and value are in the valid scope + * defined in RFC 6265. + * @return a single RFC 6265-style {@code "Cookie"} header value. + */ + default String toCookieHeader(boolean strict) { + return ClientCookieEncoder.encode(strict, this); + } + + /** + * Encodes this {@link Cookie} into a single {@code "Set-Cookie"} header value. + * + * @return a single {@code "Set-Cookie"} header value. + */ + default String toSetCookieHeader() { + return toSetCookieHeader(true); + } + + /** + * Encodes this {@link Cookie} into a single {@code "Set-Cookie"} header value. + * + * @param strict whether to validate that the cookie name and value are in the valid scope + * defined in RFC 6265. + * @return a single {@code "Set-Cookie"} header value. + */ + default String toSetCookieHeader(boolean strict) { + return ServerCookieEncoder.encode(strict, this); + } + + /** + * Returns a new {@link CookieBuilder} created from this {@link Cookie}. + * + * @see #withMutations(Consumer) + */ + default CookieBuilder toBuilder() { + return new CookieBuilder(this); + } + + /** + * Returns a new {@link Cookie} which is the result from the mutation by the specified {@link Consumer}. + * This method is a shortcut of: + *
{@code
+     * builder = toBuilder();
+     * mutator.accept(builder);
+     * return builder.build();
+     * }
+ * + * @see #toBuilder() + */ + default Cookie withMutations(Consumer mutator) { + final CookieBuilder builder = toBuilder(); + mutator.accept(builder); + return builder.build(); + } + + @Override + default int compareTo(Cookie c) { + int v = name().compareTo(c.name()); + if (v != 0) { + return v; + } + + v = value().compareTo(c.value()); + if (v != 0) { + return v; + } + + final String path = path(); + final String otherPath = c.path(); + if (path == null) { + if (otherPath != null) { + return -1; + } + } else if (otherPath == null) { + return 1; + } else { + v = path.compareTo(otherPath); + if (v != 0) { + return v; + } + } + + final String domain = domain(); + final String otherDomain = c.domain(); + if (domain == null) { + if (otherDomain != null) { + return -1; + } + } else if (otherDomain == null) { + return 1; + } else { + v = domain.compareToIgnoreCase(otherDomain); + return v; + } + + return 0; + } +} diff --git a/core/src/main/java/com/linecorp/armeria/common/CookieBuilder.java b/core/src/main/java/com/linecorp/armeria/common/CookieBuilder.java new file mode 100644 index 00000000000..c7f3cdb0ce9 --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/common/CookieBuilder.java @@ -0,0 +1,176 @@ +/* + * Copyright 2019 LINE Corporation + * + * LINE Corporation 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. + */ +package com.linecorp.armeria.common; + +import static com.google.common.base.Preconditions.checkArgument; +import static java.util.Objects.requireNonNull; + +import java.util.BitSet; + +import javax.annotation.Nullable; + +/** + * Builds a {@link Cookie}. + */ +public final class CookieBuilder { + + private static final BitSet VALID_COOKIE_ATTRIBUTE_VALUE_OCTETS = validCookieAttributeValueOctets(); + + // path-value = + private static BitSet validCookieAttributeValueOctets() { + final BitSet bits = new BitSet(); + for (int i = 32; i < 127; i++) { + bits.set(i); + } + bits.set(';', false); + return bits; + } + + private static String validateAttributeValue(String value, String valueName) { + value = requireNonNull(value, valueName).trim(); + checkArgument(!value.isEmpty(), "%s is empty.", valueName); + final int i = CookieUtil.firstInvalidOctet(value, VALID_COOKIE_ATTRIBUTE_VALUE_OCTETS); + if (i >= 0) { + throw new IllegalArgumentException( + valueName + " contains a prohibited character: " + value.charAt(i)); + } + return value; + } + + private String name; + private String value; + private boolean valueQuoted; + @Nullable + private String domain; + @Nullable + private String path; + long maxAge = Cookie.UNDEFINED_MAX_AGE; + private boolean secure; + private boolean httpOnly; + @Nullable + private String sameSite; + + // These two fields are only used by ClientCookieDecoder. + int expiresStart; + int expiresEnd; + + CookieBuilder(String name, String value) { + this.name = requireNonNull(name, "name"); + this.value = requireNonNull(value, "value"); + } + + CookieBuilder(Cookie cookie) { + name = cookie.name(); + value = cookie.value(); + valueQuoted = cookie.isValueQuoted(); + domain = cookie.domain(); + path = cookie.path(); + maxAge = cookie.maxAge(); + secure = cookie.isSecure(); + httpOnly = cookie.isHttpOnly(); + sameSite = cookie.sameSite(); + } + + /** + * Sets the name of the {@link Cookie}. + */ + public CookieBuilder name(String name) { + this.name = requireNonNull(name, "name"); + return this; + } + + /** + * Sets the value of the {@link Cookie}. + */ + public CookieBuilder value(String value) { + this.value = requireNonNull(value, "value"); + return this; + } + + /** + * Sets whether the value of the {@link Cookie} needs to be wrapped with double quotes when encoding. + * If unspecified, the {@link Cookie} will not be wrapped with double quotes. + */ + public CookieBuilder valueQuoted(boolean valueQuoted) { + this.valueQuoted = valueQuoted; + return this; + } + + /** + * Sets the domain of the {@link Cookie}. + */ + public CookieBuilder domain(String domain) { + this.domain = validateAttributeValue(domain, "domain"); + return this; + } + + /** + * Sets the path of the {@link Cookie}. + */ + public CookieBuilder path(String path) { + this.path = validateAttributeValue(path, "path"); + return this; + } + + /** + * Sets the maximum age of the {@link Cookie} in seconds. If an age of {@code 0} is specified, + * the {@link Cookie} will be automatically removed by browser because it will expire immediately. + * If {@link Cookie#UNDEFINED_MAX_AGE} is specified, this {@link Cookie} will be removed when the + * browser is closed. If unspecified, {@link Cookie#UNDEFINED_MAX_AGE} will be used. + * + * @param maxAge The maximum age of this {@link Cookie} in seconds + */ + public CookieBuilder maxAge(long maxAge) { + this.maxAge = maxAge; + return this; + } + + /** + * Sets the security status of the {@link Cookie}. If unspecified, {@code false} will be used. + */ + public CookieBuilder secure(boolean secure) { + this.secure = secure; + return this; + } + + /** + * Sets whether the {@link Cookie} is HTTP only. If {@code true}, the {@link Cookie} cannot be accessed + * by a client side script. However, this works only if the browser supports it. For more information, + * please look here. If unspecified, {@code false} + * will be used. + */ + public CookieBuilder httpOnly(boolean httpOnly) { + this.httpOnly = httpOnly; + return this; + } + + /** + * Sets the {@code SameSite} attribute of the {@link Cookie}. The value is supposed to be one of {@code "Lax"}, + * {@code "Strict"} or {@code "None"}. Note that this attribute is server-side only. + */ + public CookieBuilder sameSite(String sameSite) { + this.sameSite = validateAttributeValue(sameSite, "sameSite"); + return this; + } + + /** + * Returns a newly created {@link Cookie} with the properties set so far. + */ + public Cookie build() { + return new DefaultCookie(name, value, valueQuoted, domain, path, maxAge, secure, httpOnly, sameSite); + } +} diff --git a/core/src/main/java/com/linecorp/armeria/common/CookieUtil.java b/core/src/main/java/com/linecorp/armeria/common/CookieUtil.java new file mode 100644 index 00000000000..fb812a3d001 --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/common/CookieUtil.java @@ -0,0 +1,283 @@ +/* + * Copyright 2019 LINE Corporation + * + * LINE Corporation 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 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.linecorp.armeria.common; + +import static java.util.Objects.requireNonNull; + +import java.nio.CharBuffer; +import java.util.BitSet; +import java.util.Iterator; +import java.util.List; + +import javax.annotation.Nullable; + +import org.slf4j.Logger; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; + +import io.netty.handler.codec.http.HttpConstants; +import io.netty.util.internal.InternalThreadLocalMap; + +final class CookieUtil { + + // Forked from netty-4.1.43 + // https://github.com/netty/netty/blob/5d448377e94ca1eca3ec994d34a1170912e57ae9/codec-http/src/main/java/io/netty/handler/codec/http/cookie/CookieUtil.java + + private static final BitSet VALID_COOKIE_NAME_OCTETS = validCookieNameOctets(); + + private static final BitSet VALID_COOKIE_VALUE_OCTETS = validCookieValueOctets(); + + // token = 1* + // separators = "(" | ")" | "<" | ">" | "@" + // | "," | ";" | ":" | "\" | <"> + // | "/" | "[" | "]" | "?" | "=" + // | "{" | "}" | SP | HT + private static BitSet validCookieNameOctets() { + final BitSet bits = new BitSet(); + for (int i = 32; i < 127; i++) { + bits.set(i); + } + final int[] separators = { '(', ')', '<', '>', '@', ',', ';', ':', '\\', '"', + '/', '[', ']', '?', '=', '{', '}', ' ', '\t' }; + for (int separator : separators) { + bits.set(separator, false); + } + return bits; + } + + // cookie-octet = %x21 / %x23-2B / %x2D-3A / %x3C-5B / %x5D-7E + // US-ASCII characters excluding CTLs, whitespace, DQUOTE, comma, semicolon, and backslash + private static BitSet validCookieValueOctets() { + final BitSet bits = new BitSet(); + bits.set(0x21); + for (int i = 0x23; i <= 0x2B; i++) { + bits.set(i); + } + for (int i = 0x2D; i <= 0x3A; i++) { + bits.set(i); + } + for (int i = 0x3C; i <= 0x5B; i++) { + bits.set(i); + } + for (int i = 0x5D; i <= 0x7E; i++) { + bits.set(i); + } + return bits; + } + + static StringBuilder stringBuilder() { + return InternalThreadLocalMap.get().stringBuilder(); + } + + /** + * Strips out the trailing 2-char separator from the specified {@link StringBuilder}. + * + * @param buf a buffer where some cookies were encoded. + * @return the {@link String} without the trailing separator + */ + static String stripTrailingSeparator(StringBuilder buf) { + if (buf.length() > 0) { + buf.setLength(buf.length() - 2); + } + return buf.toString(); + } + + static void add(StringBuilder sb, String name, long val) { + sb.append(name); + sb.append('='); + sb.append(val); + sb.append(';'); + sb.append(HttpConstants.SP_CHAR); + } + + static void add(StringBuilder sb, String name, String val) { + sb.append(name); + sb.append('='); + sb.append(val); + sb.append(';'); + sb.append(HttpConstants.SP_CHAR); + } + + static void add(StringBuilder sb, String name) { + sb.append(name); + sb.append(';'); + sb.append(HttpConstants.SP_CHAR); + } + + static void addQuoted(StringBuilder sb, String name, @Nullable String val) { + if (val == null) { + val = ""; + } + + sb.append(name); + sb.append('='); + sb.append('"'); + sb.append(val); + sb.append('"'); + sb.append(';'); + sb.append(HttpConstants.SP_CHAR); + } + + private static int firstInvalidCookieNameOctet(CharSequence cs) { + return firstInvalidOctet(cs, VALID_COOKIE_NAME_OCTETS); + } + + private static int firstInvalidCookieValueOctet(CharSequence cs) { + return firstInvalidOctet(cs, VALID_COOKIE_VALUE_OCTETS); + } + + static int firstInvalidOctet(CharSequence cs, BitSet bits) { + for (int i = 0; i < cs.length(); i++) { + final char c = cs.charAt(i); + if (!bits.get(c)) { + return i; + } + } + return -1; + } + + @Nullable + private static CharSequence unwrapValue(CharSequence cs) { + final int len = cs.length(); + if (len > 0 && cs.charAt(0) == '"') { + if (len >= 2 && cs.charAt(len - 1) == '"') { + // properly balanced + return len == 2 ? "" : cs.subSequence(1, len - 1); + } else { + return null; + } + } + return cs; + } + + // Forked from netty-4.1.43 + // https://github.com/netty/netty/blob/4c709be1abf6e52c6a5640c1672d259f1de638d1/codec-http/src/main/java/io/netty/handler/codec/http/cookie/CookieEncoder.java + + static void validateCookie(boolean strict, String name, String value) { + if (strict) { + int pos; + + if ((pos = firstInvalidCookieNameOctet(name)) >= 0) { + throw new IllegalArgumentException("Cookie name contains an invalid char: " + name.charAt(pos)); + } + + final CharSequence unwrappedValue = unwrapValue(value); + if (unwrappedValue == null) { + throw new IllegalArgumentException("Cookie value wrapping quotes are not balanced: " + value); + } + + if ((pos = firstInvalidCookieValueOctet(unwrappedValue)) >= 0) { + throw new IllegalArgumentException("Cookie value contains an invalid char: " + + unwrappedValue.charAt(pos)); + } + } + } + + // Forked from netty-4.1.43 + // https://github.com/netty/netty/blob/97d871a7553a01384b43df855dccdda5205ae77a/codec-http/src/main/java/io/netty/handler/codec/http/cookie/CookieDecoder.java + + @Nullable + static CookieBuilder initCookie(Logger logger, boolean strict, + 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; + } + + final CharSequence wrappedValue = CharBuffer.wrap(header, valueBegin, valueEnd); + final 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 valueQuoted = 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; + } + + return Cookie.builder(name, unwrappedValue.toString()).valueQuoted(valueQuoted); + } + + // The methods newly added in the fork. + + static Cookies fromSetCookieHeaders(ImmutableSet.Builder builder, + boolean strict, Iterator it) { + assert it.hasNext(); + do { + final String v = it.next(); + requireNonNull(v, "setCookieHeaders contains null."); + final Cookie cookie = Cookie.fromSetCookieHeader(strict, v); + if (cookie != null) { + builder.add(cookie); + } + } while (it.hasNext()); + + return Cookies.of(builder.build()); + } + + static List toSetCookieHeaders(ImmutableList.Builder builder, + boolean strict, Iterator it) { + assert it.hasNext(); + do { + final Cookie c = it.next(); + requireNonNull(c, "cookies contains null."); + builder.add(c.toSetCookieHeader(strict)); + } while (it.hasNext()); + + return builder.build(); + } + + private CookieUtil() {} +} diff --git a/core/src/main/java/com/linecorp/armeria/server/annotation/Cookies.java b/core/src/main/java/com/linecorp/armeria/common/Cookies.java similarity index 53% rename from core/src/main/java/com/linecorp/armeria/server/annotation/Cookies.java rename to core/src/main/java/com/linecorp/armeria/common/Cookies.java index 5a5145fd36f..0e29cbcb74f 100644 --- a/core/src/main/java/com/linecorp/armeria/server/annotation/Cookies.java +++ b/core/src/main/java/com/linecorp/armeria/common/Cookies.java @@ -13,7 +13,7 @@ * License for the specific language governing permissions and limitations * under the License. */ -package com.linecorp.armeria.server.annotation; +package com.linecorp.armeria.common; import static java.util.Objects.requireNonNull; @@ -21,24 +21,44 @@ import com.google.common.collect.ImmutableSet; -import io.netty.handler.codec.http.cookie.Cookie; - /** - * An interface which holds decoded {@link Cookie} instances for an HTTP request. + * An immutable {@link Set} of {@link Cookie}s. */ public interface Cookies extends Set { + /** + * Returns an immutable empty {@link Set} of {@link Cookie}s. + */ + static Cookies empty() { + return DefaultCookies.EMPTY; + } + /** * Creates an instance with a copy of the specified set of {@link Cookie}s. */ static Cookies of(Cookie... cookies) { - return new DefaultCookies(ImmutableSet.copyOf(requireNonNull(cookies, "cookies"))); + return of(ImmutableSet.copyOf(requireNonNull(cookies, "cookies"))); } /** - * Creates an instance with a copy of the specified {@link Iterable} of {@link Cookie}s. + * Creates an instance with a copy of the specified set of {@link Cookie}s. + */ + static Cookies of(Iterable cookies) { + final ImmutableSet cookiesCopy = ImmutableSet.copyOf(requireNonNull(cookies, "cookies")); + if (cookiesCopy.isEmpty()) { + return empty(); + } else { + return new DefaultCookies(cookiesCopy); + } + } + + /** + * Creates an instance with a copy of the specified set of {@link Cookie}s. + * + * @deprecated Use {@link #of(Iterable)}. */ + @Deprecated static Cookies copyOf(Iterable cookies) { - return new DefaultCookies(ImmutableSet.copyOf(requireNonNull(cookies, "cookies"))); + return of(cookies); } } diff --git a/core/src/main/java/com/linecorp/armeria/common/DefaultCookie.java b/core/src/main/java/com/linecorp/armeria/common/DefaultCookie.java new file mode 100644 index 00000000000..0fbec278283 --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/common/DefaultCookie.java @@ -0,0 +1,173 @@ +/* + * Copyright 2019 LINE Corporation + * + * LINE Corporation 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 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.linecorp.armeria.common; + +import java.util.Objects; + +import javax.annotation.Nullable; + +import com.google.common.base.MoreObjects; +import com.google.common.base.MoreObjects.ToStringHelper; + +/** + * The default {@link Cookie} implementation. + */ +final class DefaultCookie implements Cookie { + + // Forked from netty-4.1.43 + // https://github.com/netty/netty/blob/9d45f514a47ee8ad5259ee782fcca240017fc3a3/codec-http/src/main/java/io/netty/handler/codec/http/cookie/DefaultCookie.java + + private final String name; + private final String value; + private final boolean valueQuoted; + @Nullable + private final String domain; + @Nullable + private final String path; + private final long maxAge; + private final boolean secure; + private final boolean httpOnly; + @Nullable + private final String sameSite; + + DefaultCookie(String name, String value, boolean valueQuoted, + @Nullable String domain, @Nullable String path, + long maxAge, boolean secure, boolean httpOnly, @Nullable String sameSite) { + this.name = name; + this.value = value; + this.valueQuoted = valueQuoted; + this.domain = domain; + this.path = path; + this.maxAge = maxAge; + this.secure = secure; + this.httpOnly = httpOnly; + this.sameSite = sameSite; + } + + @Override + public String name() { + return name; + } + + @Override + public String value() { + return value; + } + + @Override + public boolean isValueQuoted() { + return valueQuoted; + } + + @Override + public String domain() { + return domain; + } + + @Override + public String path() { + return path; + } + + @Override + public long maxAge() { + return maxAge; + } + + @Override + public boolean isSecure() { + return secure; + } + + @Override + public boolean isHttpOnly() { + return httpOnly; + } + + @Override + public String sameSite() { + return sameSite; + } + + @Override + public int hashCode() { + return Objects.hash(name, value); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + + if (!(o instanceof Cookie)) { + return false; + } + + final Cookie that = (Cookie) o; + if (!name.equals(that.name()) || + !value.equals(that.value()) || + !Objects.equals(path, that.path())) { + return false; + } + + if (domain() == null) { + return that.domain() == null; + } else { + return domain().equalsIgnoreCase(that.domain()); + } + } + + @Override + public String toString() { + final ToStringHelper helper = MoreObjects.toStringHelper(this).omitNullValues() + .add("name", name) + .add("value", !value.isEmpty() ? value : "") + .add("valueQuoted", valueQuoted) + .add("domain", domain) + .add("path", path); + + if (maxAge != Cookie.UNDEFINED_MAX_AGE) { + helper.add("maxAge", maxAge); + } + + if (secure) { + helper.addValue("secure"); + } + + if (httpOnly) { + helper.addValue("httpOnly"); + } + + helper.add("sameSite", sameSite); + return helper.toString(); + } +} diff --git a/core/src/main/java/com/linecorp/armeria/server/annotation/DefaultCookies.java b/core/src/main/java/com/linecorp/armeria/common/DefaultCookies.java similarity index 83% rename from core/src/main/java/com/linecorp/armeria/server/annotation/DefaultCookies.java rename to core/src/main/java/com/linecorp/armeria/common/DefaultCookies.java index 6072890da61..4490032481e 100644 --- a/core/src/main/java/com/linecorp/armeria/server/annotation/DefaultCookies.java +++ b/core/src/main/java/com/linecorp/armeria/common/DefaultCookies.java @@ -13,24 +13,25 @@ * License for the specific language governing permissions and limitations * under the License. */ -package com.linecorp.armeria.server.annotation; +package com.linecorp.armeria.common; import static java.util.Objects.requireNonNull; import java.util.Set; import com.google.common.collect.ForwardingSet; - -import io.netty.handler.codec.http.cookie.Cookie; +import com.google.common.collect.ImmutableSet; /** * A default implementation of {@link Cookies} interface. */ final class DefaultCookies extends ForwardingSet implements Cookies { + static final DefaultCookies EMPTY = new DefaultCookies(ImmutableSet.of()); + private final Set delegate; - DefaultCookies(Set delegate) { + DefaultCookies(ImmutableSet delegate) { this.delegate = requireNonNull(delegate, "delegate"); } diff --git a/core/src/main/java/com/linecorp/armeria/common/ServerCookieDecoder.java b/core/src/main/java/com/linecorp/armeria/common/ServerCookieDecoder.java new file mode 100644 index 00000000000..11620f10f69 --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/common/ServerCookieDecoder.java @@ -0,0 +1,162 @@ +/* + * Copyright 2019 LINE Corporation + * + * LINE Corporation 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 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.linecorp.armeria.common; + +import static com.linecorp.armeria.common.CookieUtil.initCookie; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.common.collect.ImmutableSet; + +import io.netty.handler.codec.http.cookie.CookieHeaderNames; + +/** + * A RFC 6265 compliant cookie decoder for server side. + * + *

Thie decoder decodes only cookie name and value. The old fields in + * RFC 2965 such as {@code "path"} and {@code "domain"} are + * ignored.

+ * + * @see ServerCookieEncoder + */ +final class ServerCookieDecoder { + + // Forked from netty-4.1.43 + // https://github.com/netty/netty/blob/ba95c401a7cf8c7923fce660e16c8ba567d62f30/codec-http/src/main/java/io/netty/handler/codec/http/cookie/ServerCookieDecoder.java + + private static final Logger logger = LoggerFactory.getLogger(ServerCookieDecoder.class); + + private static final String RFC2965_VERSION = "$Version"; + + private static final String RFC2965_PATH = '$' + CookieHeaderNames.PATH; + + private static final String RFC2965_DOMAIN = '$' + CookieHeaderNames.DOMAIN; + + private static final String RFC2965_PORT = "$Port"; + + /** + * Decodes the specified {@code "Cookie"} header value into {@link Cookie}s. + * + * @param strict whether to validate that name and value chars are in the valid scope defined in RFC 6265. + * @return the decoded {@link Cookie}s. + */ + static Cookies decode(boolean strict, String header) { + final int headerLen = header.length(); + final ImmutableSet.Builder cookies = ImmutableSet.builder(); + + int i = 0; + + boolean rfc2965Style = false; + if (header.regionMatches(true, 0, RFC2965_VERSION, 0, RFC2965_VERSION.length())) { + // RFC 2965 style cookie, move to after version value + i = header.indexOf(';') + 1; + rfc2965Style = true; + } + + loop: for (;;) { + + // Skip spaces and separators. + for (;;) { + if (i == headerLen) { + break loop; + } + final char c = header.charAt(i); + if (c == '\t' || c == '\n' || c == 0x0b || c == '\f' || + c == '\r' || c == ' ' || c == ',' || c == ';') { + i++; + continue; + } + break; + } + + final int nameBegin = i; + final int nameEnd; + final int valueBegin; + final int valueEnd; + + for (;;) { + + final char curChar = header.charAt(i); + if (curChar == ';') { + // NAME; (no value till ';') + nameEnd = i; + valueBegin = valueEnd = -1; + break; + } + + 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; + final int semiPos = header.indexOf(';', i); + valueEnd = i = semiPos > 0 ? semiPos : headerLen; + break; + } + + i++; + + if (i == headerLen) { + // NAME (no value till the end of string) + nameEnd = headerLen; + valueBegin = valueEnd = -1; + break; + } + } + + if (rfc2965Style && (header.regionMatches(nameBegin, RFC2965_PATH, 0, RFC2965_PATH.length()) || + header.regionMatches(nameBegin, RFC2965_DOMAIN, 0, RFC2965_DOMAIN.length()) || + header.regionMatches(nameBegin, RFC2965_PORT, 0, RFC2965_PORT.length()))) { + + // skip obsolete RFC2965 fields + continue; + } + + final CookieBuilder builder = initCookie(logger, strict, + header, nameBegin, nameEnd, valueBegin, valueEnd); + if (builder != null) { + cookies.add(builder.build()); + } + } + + return Cookies.of(cookies.build()); + } + + private ServerCookieDecoder() {} +} diff --git a/core/src/main/java/com/linecorp/armeria/common/ServerCookieEncoder.java b/core/src/main/java/com/linecorp/armeria/common/ServerCookieEncoder.java new file mode 100644 index 00000000000..baf6e448ff2 --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/common/ServerCookieEncoder.java @@ -0,0 +1,114 @@ +/* + * Copyright 2019 LINE Corporation + * + * LINE Corporation 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 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.linecorp.armeria.common; + +import static com.google.common.base.MoreObjects.firstNonNull; +import static com.linecorp.armeria.common.CookieUtil.add; +import static com.linecorp.armeria.common.CookieUtil.addQuoted; +import static com.linecorp.armeria.common.CookieUtil.stringBuilder; +import static com.linecorp.armeria.common.CookieUtil.stripTrailingSeparator; +import static com.linecorp.armeria.common.CookieUtil.validateCookie; +import static java.util.Objects.requireNonNull; + +import java.util.Date; + +import io.netty.handler.codec.DateFormatter; +import io.netty.handler.codec.http.HttpConstants; +import io.netty.handler.codec.http.cookie.CookieHeaderNames; + +/** + * A RFC 6265 compliant cookie encoder for server side. + * + *

Note that multiple cookies must be sent as separate "Set-Cookie" headers.

+ * + * @see ServerCookieDecoder + */ +final class ServerCookieEncoder { + + // Forked from netty-4.1.43 + // https://github.com/netty/netty/blob/5d448377e94ca1eca3ec994d34a1170912e57ae9/codec-http/src/main/java/io/netty/handler/codec/http/cookie/ServerCookieEncoder.java + + /** + * Encodes the specified {@link Cookie} into a {@code "Set-Cookie"} header value. + * + * @param strict whether to validate that name and value chars are in the valid scope defined in RFC 6265. + * @param cookie the {@link Cookie} to encode. + * @return a single {@code "Set-Cookie"} header value. + */ + static String encode(boolean strict, Cookie cookie) { + final String name = requireNonNull(cookie, "cookie").name(); + final String value = firstNonNull(cookie.value(), ""); + + validateCookie(strict, name, value); + + final StringBuilder buf = stringBuilder(); + + if (cookie.isValueQuoted()) { + addQuoted(buf, name, value); + } else { + add(buf, name, value); + } + + if (cookie.maxAge() != Long.MIN_VALUE) { + add(buf, CookieHeaderNames.MAX_AGE, cookie.maxAge()); + final Date expires = new Date(cookie.maxAge() * 1000 + System.currentTimeMillis()); + buf.append(CookieHeaderNames.EXPIRES); + buf.append('='); + DateFormatter.append(expires, buf); + buf.append(';'); + buf.append(HttpConstants.SP_CHAR); + } + + final String path = cookie.path(); + if (path != null) { + add(buf, CookieHeaderNames.PATH, path); + } + + final String domain = cookie.domain(); + if (domain != null) { + add(buf, CookieHeaderNames.DOMAIN, domain); + } + if (cookie.isSecure()) { + add(buf, CookieHeaderNames.SECURE); + } + if (cookie.isHttpOnly()) { + add(buf, CookieHeaderNames.HTTPONLY); + } + final String sameSite = cookie.sameSite(); + if (sameSite != null) { + add(buf, "SameSite", sameSite); + } + + return stripTrailingSeparator(buf); + } + + private ServerCookieEncoder() {} +} diff --git a/core/src/main/java/com/linecorp/armeria/internal/annotation/AnnotatedValueResolver.java b/core/src/main/java/com/linecorp/armeria/internal/annotation/AnnotatedValueResolver.java index 1e3d6581859..7e4098dc931 100644 --- a/core/src/main/java/com/linecorp/armeria/internal/annotation/AnnotatedValueResolver.java +++ b/core/src/main/java/com/linecorp/armeria/internal/annotation/AnnotatedValueResolver.java @@ -62,10 +62,11 @@ import com.google.common.base.Ascii; import com.google.common.base.MoreObjects; import com.google.common.collect.ImmutableList; -import com.google.common.collect.ImmutableSet; import com.google.common.collect.MapMaker; import com.linecorp.armeria.common.AggregatedHttpRequest; +import com.linecorp.armeria.common.Cookie; +import com.linecorp.armeria.common.Cookies; import com.linecorp.armeria.common.HttpHeaderNames; import com.linecorp.armeria.common.HttpHeaders; import com.linecorp.armeria.common.HttpParameters; @@ -79,7 +80,6 @@ import com.linecorp.armeria.internal.annotation.AnnotatedBeanFactoryRegistry.BeanFactoryId; import com.linecorp.armeria.server.ServiceRequestContext; import com.linecorp.armeria.server.annotation.ByteArrayRequestConverterFunction; -import com.linecorp.armeria.server.annotation.Cookies; import com.linecorp.armeria.server.annotation.Default; import com.linecorp.armeria.server.annotation.Header; import com.linecorp.armeria.server.annotation.JacksonRequestConverterFunction; @@ -91,8 +91,6 @@ import io.netty.handler.codec.http.HttpConstants; import io.netty.handler.codec.http.QueryStringDecoder; -import io.netty.handler.codec.http.cookie.Cookie; -import io.netty.handler.codec.http.cookie.ServerCookieDecoder; final class AnnotatedValueResolver { private static final Logger logger = LoggerFactory.getLogger(AnnotatedValueResolver.class); @@ -566,15 +564,11 @@ private static AnnotatedValueResolver ofInjectableTypes0(AnnotatedElement annota return builder(annotatedElement, type) .supportOptional(true) .resolver((unused, ctx) -> { - final List values = ctx.request().headers().getAll(HttpHeaderNames.COOKIE); - if (values.isEmpty()) { - return Cookies.copyOf(ImmutableSet.of()); + final String value = ctx.request().headers().get(HttpHeaderNames.COOKIE); + if (value == null) { + return Cookies.empty(); } - final ImmutableSet.Builder cookies = ImmutableSet.builder(); - values.stream() - .map(ServerCookieDecoder.STRICT::decode) - .forEach(cookies::addAll); - return Cookies.copyOf(cookies.build()); + return Cookie.fromCookieHeader(value); }) .build(); } diff --git a/core/src/test/java/com/linecorp/armeria/common/ClientCookieDecoderTest.java b/core/src/test/java/com/linecorp/armeria/common/ClientCookieDecoderTest.java new file mode 100644 index 00000000000..4e1881f836a --- /dev/null +++ b/core/src/test/java/com/linecorp/armeria/common/ClientCookieDecoderTest.java @@ -0,0 +1,314 @@ +/* + * Copyright 2019 LINE Corporation + * + * LINE Corporation 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 2014 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.linecorp.armeria.common; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Collection; +import java.util.Date; +import java.util.Iterator; +import java.util.TimeZone; + +import org.junit.jupiter.api.Test; + +import io.netty.handler.codec.DateFormatter; + +class ClientCookieDecoderTest { + + // Forked from netty-4.1.34. + // https://github.com/netty/netty/blob/587afddb279bea3fd0f64d3421de8e69a35cecb9/codec-http/src/test/java/io/netty/handler/codec/http/cookie/ClientCookieDecoderTest.java + + @Test + void testDecodingSingleCookieV0() { + final String cookieString = "myCookie=myValue;expires=" + + DateFormatter.format(new Date(System.currentTimeMillis() + 50000)) + + ";path=/apathsomewhere;domain=.adomainsomewhere;secure;"; + + final Cookie cookie = Cookie.fromSetCookieHeader(cookieString); + assertThat(cookie).isNotNull(); + assertThat(cookie.value()).isEqualTo("myValue"); + assertThat(cookie.domain()).isEqualTo(".adomainsomewhere"); + assertThat(cookie.maxAge()).withFailMessage("maxAge should be defined when parsing cookie: " + + cookieString) + .isNotEqualTo(Cookie.UNDEFINED_MAX_AGE); + assertThat(cookie.maxAge()).withFailMessage("maxAge should be about 50ms when parsing cookie: " + + cookieString) + .isGreaterThanOrEqualTo(40) + .isLessThanOrEqualTo(60); + assertThat(cookie.path()).isEqualTo("/apathsomewhere"); + assertThat(cookie.isSecure()).isTrue(); + } + + @Test + void testDecodingSingleCookieV0ExtraParamsIgnored() { + final String cookieString = "myCookie=myValue;max-age=50;path=/apathsomewhere;" + + "domain=.adomainsomewhere;secure;comment=this is a comment;version=0;" + + "commentURL=http://aurl.com;port=\"80,8080\";discard;"; + final Cookie cookie = Cookie.fromSetCookieHeader(cookieString); + assertThat(cookie).isNotNull(); + assertThat(cookie.value()).isEqualTo("myValue"); + assertThat(cookie.domain()).isEqualTo(".adomainsomewhere"); + assertThat(cookie.maxAge()).isEqualTo(50); + assertThat(cookie.path()).isEqualTo("/apathsomewhere"); + assertThat(cookie.isSecure()).isTrue(); + } + + @Test + void testDecodingSingleCookieV1() { + final String cookieString = "myCookie=myValue;max-age=50;path=/apathsomewhere;" + + "domain=.adomainsomewhere;secure;comment=this is a comment;version=1;"; + final Cookie cookie = Cookie.fromSetCookieHeader(cookieString); + assertThat(cookie).isNotNull(); + assertThat(cookie.value()).isEqualTo("myValue"); + assertThat(cookie.domain()).isEqualTo(".adomainsomewhere"); + assertThat(cookie.maxAge()).isEqualTo(50); + assertThat(cookie.path()).isEqualTo("/apathsomewhere"); + assertThat(cookie.isSecure()).isTrue(); + } + + @Test + void testDecodingSingleCookieV1ExtraParamsIgnored() { + final String cookieString = "myCookie=myValue;max-age=50;path=/apathsomewhere;" + + "domain=.adomainsomewhere;secure;comment=this is a comment;version=1;" + + "commentURL=http://aurl.com;port='80,8080';discard;"; + final Cookie cookie = Cookie.fromSetCookieHeader(cookieString); + assertThat(cookie).isNotNull(); + assertThat(cookie.value()).isEqualTo("myValue"); + assertThat(cookie.domain()).isEqualTo(".adomainsomewhere"); + assertThat(cookie.maxAge()).isEqualTo(50); + assertThat(cookie.path()).isEqualTo("/apathsomewhere"); + assertThat(cookie.isSecure()).isTrue(); + } + + @Test + void testDecodingSingleCookieV2() { + final String cookieString = "myCookie=myValue;max-age=50;path=/apathsomewhere;" + + "domain=.adomainsomewhere;secure;comment=this is a comment;version=2;" + + "commentURL=http://aurl.com;port=\"80,8080\";discard;"; + final Cookie cookie = Cookie.fromSetCookieHeader(cookieString); + assertThat(cookie).isNotNull(); + assertThat(cookie.value()).isEqualTo("myValue"); + assertThat(cookie.domain()).isEqualTo(".adomainsomewhere"); + assertThat(cookie.maxAge()).isEqualTo(50); + assertThat(cookie.path()).isEqualTo("/apathsomewhere"); + assertThat(cookie.isSecure()).isTrue(); + } + + @Test + void testDecodingComplexCookie() { + final String c1 = "myCookie=myValue;max-age=50;path=/apathsomewhere;" + + "domain=.adomainsomewhere;secure;comment=this is a comment;version=2;" + + "commentURL=\"http://aurl.com\";port='80,8080';discard;"; + + final Cookie cookie = Cookie.fromSetCookieHeader(c1); + assertThat(cookie).isNotNull(); + assertThat(cookie.value()).isEqualTo("myValue"); + assertThat(cookie.domain()).isEqualTo(".adomainsomewhere"); + assertThat(cookie.maxAge()).isEqualTo(50); + assertThat(cookie.path()).isEqualTo("/apathsomewhere"); + assertThat(cookie.isSecure()).isTrue(); + } + + @Test + void testDecodingQuotedCookie() { + final Collection sources = new ArrayList<>(); + sources.add("a=\"\","); + sources.add("b=\"1\","); + + final Collection cookies = new ArrayList<>(); + for (String source : sources) { + cookies.add(Cookie.fromSetCookieHeader(source)); + } + + final Iterator it = cookies.iterator(); + Cookie c; + + c = it.next(); + assertThat(c.name()).isEqualTo("a"); + assertThat(c.value()).isEmpty(); + + c = it.next(); + assertThat(c.name()).isEqualTo("b"); + assertThat(c.value()).isEqualTo("1"); + + assertThat(it.hasNext()).isFalse(); + } + + @Test + void testDecodingGoogleAnalyticsCookie() { + final String source = "ARPT=LWUKQPSWRTUN04CKKJI; " + + "kw-2E343B92-B097-442c-BFA5-BE371E0325A2=unfinished furniture; " + + "__utma=48461872.1094088325.1258140131.1258140131.1258140131.1; " + + "__utmb=48461872.13.10.1258140131; __utmc=48461872; " + + "__utmz=48461872.1258140131.1.1.utmcsr=overstock.com|utmccn=(referral)|" + + "utmcmd=referral|utmcct=/Home-Garden/Furniture/Clearance,/clearance," + + "/32/dept.html"; + final Cookie cookie = Cookie.fromSetCookieHeader(source); + assertThat(cookie).isNotNull(); + assertThat(cookie.name()).isEqualTo("ARPT"); + assertThat(cookie.value()).isEqualTo("LWUKQPSWRTUN04CKKJI"); + } + + @Test + void testDecodingLongDates() { + final Calendar cookieDate = Calendar.getInstance(TimeZone.getTimeZone("UTC")); + cookieDate.set(9999, Calendar.DECEMBER, 31, 23, 59, 59); + final long expectedMaxAge = (cookieDate.getTimeInMillis() - System.currentTimeMillis()) / 1000; + + final String source = "Format=EU; expires=Fri, 31-Dec-9999 23:59:59 GMT; path=/"; + + final Cookie cookie = Cookie.fromSetCookieHeader(source); + assertThat(cookie).isNotNull(); + assertThat(Math.abs(expectedMaxAge - cookie.maxAge())).isLessThan(2); + } + + @Test + void testDecodingValueWithCommaFails() { + final String source = "UserCookie=timeZoneName=(GMT+04:00) Moscow, St. Petersburg, " + + "Volgograd&promocode=®ion=BE; expires=Sat, 01-Dec-2012 10:53:31 GMT; path=/"; + final Cookie cookie = Cookie.fromSetCookieHeader(source); + assertThat(cookie).isNull(); + } + + @Test + void testDecodingWeirdNames1() { + final String src = "path=; expires=Mon, 01-Jan-1990 00:00:00 GMT; path=/; domain=.www.google.com"; + final Cookie cookie = Cookie.fromSetCookieHeader(src); + assertThat(cookie).isNotNull(); + assertThat(cookie.name()).isEqualTo("path"); + assertThat(cookie.value()).isEqualTo(""); + assertThat(cookie.path()).isEqualTo("/"); + } + + @Test + void testDecodingWeirdNames2() { + final String src = "HTTPOnly="; + final Cookie cookie = Cookie.fromSetCookieHeader(src); + assertThat(cookie).isNotNull(); + assertThat(cookie.name()).isEqualTo("HTTPOnly"); + assertThat(cookie.value()).isEmpty(); + } + + @Test + void testDecodingWeirdNames3() { + final String src = "SameSite="; + final Cookie cookie = Cookie.fromSetCookieHeader(src); + assertThat(cookie).isNotNull(); + assertThat(cookie.name()).isEqualTo("SameSite"); + assertThat(cookie.value()).isEmpty(); + } + + @Test + void testDecodingValuesWithCommasAndEqualsFails() { + final String src = "A=v=1&lg=en-US,it-IT,it&intl=it&np=1;T=z=E"; + final Cookie cookie = Cookie.fromSetCookieHeader(src); + assertThat(cookie).isNull(); + } + + @Test + void testDecodingInvalidValuesWithCommaAtStart() { + assertThat(Cookie.fromSetCookieHeader(",")).isNull(); + assertThat(Cookie.fromSetCookieHeader(",a")).isNull(); + assertThat(Cookie.fromSetCookieHeader(",a=a")).isNull(); + } + + @Test + void testDecodingLongValue() { + final String longValue = + "b___$Q__$ha______" + + "%=J^wI__3iD____$=HbQW__3iF____#=J^wI__3iH____%=J^wI__3iM____%=J^wI__3iS____" + + "#=J^wI__3iU____%=J^wI__3iZ____#=J^wI__3i]____%=J^wI__3ig____%=J^wI__3ij____" + + "%=J^wI__3ik____#=J^wI__3il____$=HbQW__3in____%=J^wI__3ip____$=HbQW__3iq____" + + "$=HbQW__3it____%=J^wI__3ix____#=J^wI__3j_____$=HbQW__3j%____$=HbQW__3j'____" + + "%=J^wI__3j(____%=J^wI__9mJ____'=KqtH__=SE__M____" + + "'=KqtH__s1X____$=MMyc__s1_____#=MN#O__ypn____'=KqtH__ypr____'=KqtH_#%h_____" + + "%=KqtH_#%o_____'=KqtH_#)H6______'=KqtH_#]9R____$=H/Lt_#]I6____#=KqtH_#]Z#____%=KqtH_#^*N____" + + "#=KqtH_#^:m____#=KqtH_#_*_____%=J^wI_#`-7____#=KqtH_#`T>____'=KqtH_#`T?____" + + "'=KqtH_#`TA____'=KqtH_#`TB____'=KqtH_#`TG____'=KqtH_#`TP____#=KqtH_#`U_____" + + "'=KqtH_#`U/____'=KqtH_#`U0____#=KqtH_#`U9____'=KqtH_#aEQ____%=KqtH_#b<)____" + + "'=KqtH_#c9-____%=KqtH_#dxC____%=KqtH_#dxE____%=KqtH_#ev$____'=KqtH_#fBi____" + + "#=KqtH_#fBj____'=KqtH_#fG)____'=KqtH_#fG+____'=KqtH_#g*B____'=KqtH_$>hD____+=J^x0_$?lW____'=KqtH_$?ll____'=KqtH_$?lm____" + + "%=KqtH_$?mi____'=KqtH_$?mx____'=KqtH_$D7]____#=J_#p_$D@T____#=J_#p_$V Cookie.toCookieHeader(new Cookie[0])) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("empty"); + assertThatThrownBy(() -> Cookie.toCookieHeader(ImmutableSet.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("empty"); + } + + @Test + void testEncodingMultipleClientCookies() { + final String c1 = "myCookie=myValue"; + final String c2 = "myCookie2=myValue2"; + final String c3 = "myCookie3=myValue3"; + final Cookie cookie1 = Cookie.builder("myCookie", "myValue") + .domain(".adomainsomewhere") + .maxAge(50) + .path("/apathsomewhere") + .secure(true) + .httpOnly(true) + .sameSite("Strict") + .build(); + final Cookie cookie2 = Cookie.builder("myCookie2", "myValue2") + .domain(".anotherdomainsomewhere") + .path("/anotherpathsomewhere") + .build(); + final Cookie cookie3 = Cookie.of("myCookie3", "myValue3"); + final String encodedCookie = Cookie.toCookieHeader(cookie1, cookie2, cookie3); + // Cookies should be sorted into decreasing order of path length, as per RFC 6265. + // When no path is provided, we assume maximum path length (so cookie3 comes first). + assertThat(encodedCookie).isEqualTo(c3 + "; " + c2 + "; " + c1); + } + + @Test + void testWrappedCookieValue() { + assertThat(Cookie.of("myCookie", "\"foo\"").toCookieHeader()).isEqualTo("myCookie=\"foo\""); + } + + @Test + void testRejectCookieValueWithSemicolon() { + assertThatThrownBy(() -> Cookie.of("myCookie", "foo;bar").toCookieHeader()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Cookie value contains an invalid char: ;"); + } +} diff --git a/core/src/test/java/com/linecorp/armeria/common/DefaultCookieTest.java b/core/src/test/java/com/linecorp/armeria/common/DefaultCookieTest.java new file mode 100644 index 00000000000..99fe5ced953 --- /dev/null +++ b/core/src/test/java/com/linecorp/armeria/common/DefaultCookieTest.java @@ -0,0 +1,42 @@ +/* + * Copyright 2019 LINE Corporation + * + * LINE Corporation 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. + */ +package com.linecorp.armeria.common; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class DefaultCookieTest { + @Test + void toBuilder() { + final Cookie cookie = Cookie.builder("a", "b") + .domain("c") + .path("/d") + .maxAge(1) + .httpOnly(true) + .secure(true) + .sameSite("Strict") + .valueQuoted(true) + .build(); + assertThat(cookie.toBuilder().build()).isEqualTo(cookie); + } + + @Test + void mutation() { + final Cookie cookie = Cookie.of("a", "b").withMutations(mutator -> mutator.name("c").value("d")); + assertThat(cookie).isEqualTo(Cookie.of("c", "d")); + } +} diff --git a/core/src/test/java/com/linecorp/armeria/common/ServerCookieDecoderTest.java b/core/src/test/java/com/linecorp/armeria/common/ServerCookieDecoderTest.java new file mode 100644 index 00000000000..4d453609db7 --- /dev/null +++ b/core/src/test/java/com/linecorp/armeria/common/ServerCookieDecoderTest.java @@ -0,0 +1,214 @@ +/* + * Copyright 2019 LINE Corporation + * + * LINE Corporation 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 2014 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.linecorp.armeria.common; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Iterator; + +import org.junit.jupiter.api.Test; + +public class ServerCookieDecoderTest { + + // Forked from netty-4.1.34. + // https://github.com/netty/netty/blob/f755e584638e20a4ae62466dd4b7a14954650348/codec-http/src/test/java/io/netty/handler/codec/http/cookie/ServerCookieDecoderTest.java + + @Test + public void testDecodingSingleCookie() { + final String cookieString = "myCookie=myValue"; + final Cookies cookies = Cookie.fromCookieHeader(cookieString); + assertThat(cookies).hasSize(1); + final Cookie cookie = cookies.iterator().next(); + assertThat(cookie).isNotNull(); + assertThat(cookie.value()).isEqualTo("myValue"); + } + + @Test + public void testDecodingMultipleCookies() { + final String c1 = "myCookie=myValue;"; + final String c2 = "myCookie2=myValue2;"; + final String c3 = "myCookie3=myValue3;"; + + final Cookies cookies = Cookie.fromCookieHeader(c1 + c2 + c3); + assertThat(cookies).hasSize(3); + final Iterator it = cookies.iterator(); + Cookie cookie = it.next(); + assertThat(cookie).isNotNull(); + assertThat(cookie.value()).isEqualTo("myValue"); + cookie = it.next(); + assertThat(cookie).isNotNull(); + assertThat(cookie.value()).isEqualTo("myValue2"); + cookie = it.next(); + assertThat(cookie).isNotNull(); + assertThat(cookie.value()).isEqualTo("myValue3"); + } + + @Test + public void testDecodingGoogleAnalyticsCookie() { + final String source = + "ARPT=LWUKQPSWRTUN04CKKJI; " + + "kw-2E343B92-B097-442c-BFA5-BE371E0325A2=unfinished_furniture; " + + "__utma=48461872.1094088325.1258140131.1258140131.1258140131.1; " + + "__utmb=48461872.13.10.1258140131; __utmc=48461872; " + + "__utmz=48461872.1258140131.1.1.utmcsr=overstock.com|utmccn=(referral)|" + + "utmcmd=referral|utmcct=/Home-Garden/Furniture/Clearance/clearance/32/dept.html"; + final Cookies cookies = Cookie.fromCookieHeader(source); + final Iterator it = cookies.iterator(); + Cookie c; + + c = it.next(); + assertThat(c.name()).isEqualTo("ARPT"); + assertThat(c.value()).isEqualTo("LWUKQPSWRTUN04CKKJI"); + + c = it.next(); + assertThat(c.name()).isEqualTo("kw-2E343B92-B097-442c-BFA5-BE371E0325A2"); + assertThat(c.value()).isEqualTo("unfinished_furniture"); + + c = it.next(); + assertThat(c.name()).isEqualTo("__utma"); + assertThat(c.value()).isEqualTo("48461872.1094088325.1258140131.1258140131.1258140131.1"); + + c = it.next(); + assertThat(c.name()).isEqualTo("__utmb"); + assertThat(c.value()).isEqualTo("48461872.13.10.1258140131"); + + c = it.next(); + assertThat(c.name()).isEqualTo("__utmc"); + assertThat(c.value()).isEqualTo("48461872"); + + c = it.next(); + assertThat(c.name()).isEqualTo("__utmz"); + assertThat(c.value()).isEqualTo( + "48461872.1258140131.1.1.utmcsr=overstock.com|utmccn=(referral)|utmcmd=referral|" + + "utmcct=/Home-Garden/Furniture/Clearance/clearance/32/dept.html"); + + assertThat(it.hasNext()).isFalse(); + } + + @Test + public void testDecodingLongValue() { + final String longValue = + "b___$Q__$ha______" + + "%=J^wI__3iD____$=HbQW__3iF____#=J^wI__3iH____%=J^wI__3iM____%=J^wI__3iS____" + + "#=J^wI__3iU____%=J^wI__3iZ____#=J^wI__3i]____%=J^wI__3ig____%=J^wI__3ij____" + + "%=J^wI__3ik____#=J^wI__3il____$=HbQW__3in____%=J^wI__3ip____$=HbQW__3iq____" + + "$=HbQW__3it____%=J^wI__3ix____#=J^wI__3j_____$=HbQW__3j%____$=HbQW__3j'____" + + "%=J^wI__3j(____%=J^wI__9mJ____'=KqtH__=SE__M____" + + "'=KqtH__s1X____$=MMyc__s1_____#=MN#O__ypn____'=KqtH__ypr____'=KqtH_#%h_____" + + "%=KqtH_#%o_____'=KqtH_#)H6______'=KqtH_#]9R____$=H/Lt_#]I6____#=KqtH_#]Z#____%=KqtH_#^*N____" + + "#=KqtH_#^:m____#=KqtH_#_*_____%=J^wI_#`-7____#=KqtH_#`T>____'=KqtH_#`T?____" + + "'=KqtH_#`TA____'=KqtH_#`TB____'=KqtH_#`TG____'=KqtH_#`TP____#=KqtH_#`U_____" + + "'=KqtH_#`U/____'=KqtH_#`U0____#=KqtH_#`U9____'=KqtH_#aEQ____%=KqtH_#b<)____" + + "'=KqtH_#c9-____%=KqtH_#dxC____%=KqtH_#dxE____%=KqtH_#ev$____'=KqtH_#fBi____" + + "#=KqtH_#fBj____'=KqtH_#fG)____'=KqtH_#fG+____'=KqtH_#g*B____'=KqtH_$>hD____+=J^x0_$?lW____'=KqtH_$?ll____'=KqtH_$?lm____" + + "%=KqtH_$?mi____'=KqtH_$?mx____'=KqtH_$D7]____#=J_#p_$D@T____#=J_#p_$V it = cookies.iterator(); + Cookie c; + + c = it.next(); + assertThat(c.name()).isEqualTo("Part_Number1"); + assertThat(c.value()).isEqualTo("Riding_Rocket_0023"); + + c = it.next(); + assertThat(c.name()).isEqualTo("Part_Number2"); + assertThat(c.value()).isEqualTo("Rocket_Launcher_0001"); + + assertThat(it.hasNext()).isFalse(); + } + + @Test + public void testRejectCookieValueWithSemicolon() { + final Cookies cookies = Cookie.fromCookieHeader("name=\"foo;bar\";"); + assertThat(cookies).isEmpty(); + } + + @Test + public void testCaseSensitiveNames() { + final Cookies cookies = Cookie.fromCookieHeader("session_id=a; Session_id=b;"); + final Iterator it = cookies.iterator(); + Cookie c; + + c = it.next(); + assertThat(c.name()).isEqualTo("session_id"); + assertThat(c.value()).isEqualTo("a"); + + c = it.next(); + assertThat(c.name()).isEqualTo("Session_id"); + assertThat(c.value()).isEqualTo("b"); + + assertThat(it.hasNext()).isFalse(); + } +} diff --git a/core/src/test/java/com/linecorp/armeria/common/ServerCookieEncoderTest.java b/core/src/test/java/com/linecorp/armeria/common/ServerCookieEncoderTest.java new file mode 100644 index 00000000000..4d8d0903cf5 --- /dev/null +++ b/core/src/test/java/com/linecorp/armeria/common/ServerCookieEncoderTest.java @@ -0,0 +1,163 @@ +/* + * Copyright 2019 LINE Corporation + * + * LINE Corporation 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 2014 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.linecorp.armeria.common; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.text.ParseException; +import java.util.Date; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.junit.jupiter.api.Test; + +import com.google.common.collect.ImmutableSet; + +import io.netty.handler.codec.DateFormatter; + +public class ServerCookieEncoderTest { + + // Forked from netty-4.1.34. + // https://github.com/netty/netty/blob/4c709be1abf6e52c6a5640c1672d259f1de638d1/codec-http/src/test/java/io/netty/handler/codec/http/cookie/ServerCookieEncoderTest.java + + @Test + public void testEncodingSingleCookieV0() throws ParseException { + + final int maxAge = 50; + + final String result = "myCookie=myValue; Max-Age=50; Expires=(.+?); Path=/apathsomewhere; " + + "Domain=.adomainsomewhere; Secure; SameSite=Strict"; + final Cookie cookie = Cookie.builder("myCookie", "myValue") + .domain(".adomainsomewhere") + .maxAge(maxAge) + .path("/apathsomewhere") + .secure(true) + .sameSite("Strict") + .build(); + + final String encodedCookie = cookie.toSetCookieHeader(); + + final Matcher matcher = Pattern.compile(result).matcher(encodedCookie); + assertThat(matcher.find()).isTrue(); + final Date expiresDate = DateFormatter.parseHttpDate(matcher.group(1)); + final long diff = (expiresDate.getTime() - System.currentTimeMillis()) / 1000; + // 2 secs should be fine + assertThat(Math.abs(diff - maxAge)).isLessThanOrEqualTo(2); + } + + @Test + public void testEncodingWithNoCookies() { + assertThat(Cookie.toSetCookieHeaders()).isEmpty(); + assertThat(Cookie.toSetCookieHeaders(ImmutableSet.of())).isEmpty(); + } + + @Test + public void testEncodingMultipleCookies() { + final Cookie cookie1 = Cookie.of("cookie1", "value1"); + final Cookie cookie2 = Cookie.of("cookie2", "value2"); + final Cookie cookie3 = Cookie.of("cookie1", "value3"); + final List encodedCookies = Cookie.toSetCookieHeaders(cookie1, cookie2, cookie3); + assertThat(encodedCookies).containsExactly("cookie1=value1", "cookie2=value2", "cookie1=value3"); + } + + @Test + public void illegalCharInCookieNameMakesStrictEncoderThrowsException() { + final Set illegalChars = new HashSet(); + // CTLs + for (int i = 0x00; i <= 0x1F; i++) { + illegalChars.add((char) i); + } + illegalChars.add((char) 0x7F); + // separators + for (char c : new char[] { '(', ')', '<', '>', '@', ',', ';', ':', '\\', '"', '/', '[', ']', + '?', '=', '{', '}', ' ', '\t' }) { + illegalChars.add(c); + } + + int exceptions = 0; + + for (char c : illegalChars) { + try { + Cookie.of("foo" + c + "bar", "value").toSetCookieHeader(); + } catch (IllegalArgumentException e) { + exceptions++; + } + } + + assertThat(exceptions).isEqualTo(illegalChars.size()); + } + + @Test + public void illegalCharInCookieValueMakesStrictEncoderThrowsException() { + final Set illegalChars = new HashSet(); + // CTLs + for (int i = 0x00; i <= 0x1F; i++) { + illegalChars.add((char) i); + } + illegalChars.add((char) 0x7F); + // whitespace, DQUOTE, comma, semicolon, and backslash + for (char c : new char[] { ' ', '"', ',', ';', '\\' }) { + illegalChars.add(c); + } + + int exceptions = 0; + + for (char c : illegalChars) { + try { + Cookie.of("name", "value" + c).toSetCookieHeader(); + } catch (IllegalArgumentException e) { + exceptions++; + } + } + + assertThat(exceptions).isEqualTo(illegalChars.size()); + } + + @Test + public void illegalCharInWrappedValueAppearsInException() { + assertThatThrownBy(() -> Cookie.of("name", "\"value,\"").toSetCookieHeader()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Cookie value contains an invalid char: ,"); + } + + @Test + public void testEncodingMultipleCookiesLax() { + final Cookie cookie1 = Cookie.of("cookie1", "value1"); + final Cookie cookie2 = Cookie.of("cookie2", "value2"); + final Cookie cookie3 = Cookie.of("cookie1", "value3"); + final List encodedCookies = Cookie.toSetCookieHeaders(cookie1, cookie2, cookie3); + assertThat(encodedCookies).containsExactly("cookie1=value1", "cookie2=value2", "cookie1=value3"); + } +} diff --git a/core/src/test/java/com/linecorp/armeria/internal/annotation/AnnotatedValueResolverTest.java b/core/src/test/java/com/linecorp/armeria/internal/annotation/AnnotatedValueResolverTest.java index 2bf12967d68..13d3792df44 100644 --- a/core/src/test/java/com/linecorp/armeria/internal/annotation/AnnotatedValueResolverTest.java +++ b/core/src/test/java/com/linecorp/armeria/internal/annotation/AnnotatedValueResolverTest.java @@ -30,8 +30,8 @@ import java.util.Set; import java.util.stream.Collectors; -import org.junit.AfterClass; -import org.junit.Test; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Test; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -39,6 +39,8 @@ import com.google.common.collect.ImmutableSet; import com.google.common.collect.Iterables; +import com.linecorp.armeria.common.Cookie; +import com.linecorp.armeria.common.Cookies; import com.linecorp.armeria.common.HttpHeaderNames; import com.linecorp.armeria.common.HttpMethod; import com.linecorp.armeria.common.HttpRequest; @@ -50,7 +52,6 @@ import com.linecorp.armeria.server.RoutingResult; import com.linecorp.armeria.server.RoutingResultBuilder; import com.linecorp.armeria.server.ServiceRequestContext; -import com.linecorp.armeria.server.annotation.Cookies; import com.linecorp.armeria.server.annotation.Default; import com.linecorp.armeria.server.annotation.Get; import com.linecorp.armeria.server.annotation.Header; @@ -59,7 +60,8 @@ import io.netty.util.AsciiString; -public class AnnotatedValueResolverTest { +class AnnotatedValueResolverTest { + private static final Logger logger = LoggerFactory.getLogger(AnnotatedValueResolverTest.class); static final List objectResolvers = toRequestObjectResolvers(ImmutableList.of()); @@ -88,7 +90,7 @@ public class AnnotatedValueResolverTest { .collect(Collectors.joining("&")); final RequestHeadersBuilder headers = RequestHeaders.builder(HttpMethod.GET, path + '?' + query); - headers.set(HttpHeaderNames.COOKIE, "a=1;b=2", "c=3", "a=4"); + headers.set(HttpHeaderNames.COOKIE, "a=1;b=2;c=3;a=4"); existingHttpHeaders.forEach(name -> headers.set(name, headerValues)); originalHeaders = headers.build(); @@ -106,8 +108,8 @@ public class AnnotatedValueResolverTest { resolverContext = new ResolverContext(context, request, null); } - @AfterClass - public static void ensureUnmodifiedHeaders() { + @AfterAll + static void ensureUnmodifiedHeaders() { assertThat(request.headers()).isEqualTo(originalHeaders); } @@ -125,7 +127,7 @@ static boolean shouldPathVariableExist(AnnotatedValueResolver element) { } @Test - public void ofMethods() { + void ofMethods() { getAllMethods(Service.class).forEach(method -> { try { final List elements = @@ -138,7 +140,7 @@ public void ofMethods() { } @Test - public void ofFieldBean() throws NoSuchFieldException { + void ofFieldBean() throws NoSuchFieldException { final FieldBean bean = new FieldBean(); getAllFields(FieldBean.class).forEach(field -> { @@ -160,7 +162,7 @@ public void ofFieldBean() throws NoSuchFieldException { } @Test - public void ofConstructorBean() { + void ofConstructorBean() { @SuppressWarnings("rawtypes") final Set constructors = getAllConstructors(ConstructorBean.class); assertThat(constructors.size()).isOne(); @@ -183,7 +185,7 @@ public void ofConstructorBean() { } @Test - public void ofSetterBean() throws Exception { + void ofSetterBean() throws Exception { final SetterBean bean = SetterBean.class.getDeclaredConstructor().newInstance(); getAllMethods(SetterBean.class).forEach(method -> testMethod(method, bean)); testBean(bean); @@ -191,7 +193,7 @@ public void ofSetterBean() throws Exception { @Test @SuppressWarnings("rawtypes") - public void ofMixedBean() throws Exception { + void ofMixedBean() throws Exception { final Set constructors = getAllConstructors(MixedBean.class); assertThat(constructors.size()).isOne(); final Constructor constructor = Iterables.getFirst(constructors, null); @@ -227,19 +229,12 @@ private static void testResolver(AnnotatedValueResolver resolver) { assertThat(value).isInstanceOf(resolver.elementType()); // Check whether 'Cookie' header is decoded correctly. - // 'a=4' will be ignored because 'a=1' is already in the set. if (resolver.elementType() == Cookies.class) { final Cookies cookies = (Cookies) value; - assertThat(cookies.size()).isEqualTo(3); - cookies.forEach(cookie -> { - if ("a".equals(cookie.name())) { - assertThat(cookie.value()).isEqualTo("1"); - } else if ("b".equals(cookie.name())) { - assertThat(cookie.value()).isEqualTo("2"); - } else if ("c".equals(cookie.name())) { - assertThat(cookie.value()).isEqualTo("3"); - } - }); + assertThat(cookies).containsExactly(Cookie.of("a", "1"), + Cookie.of("b", "2"), + Cookie.of("c", "3"), + Cookie.of("a", "4")); } return; } @@ -365,7 +360,7 @@ enum ValueEnum { } static class Service { - public void method1(@Param String var1, + void method1(@Param String var1, @Param String param1, @Param @Default("1") int param2, @Param @Default("1") List param3, @@ -383,15 +378,15 @@ public void method1(@Param String var1, @RequestObject OuterBean outerBean, Cookies cookies) {} - public void dummy1() {} + void dummy1() {} - public void redundant1(@Param @Default("defaultValue") Optional value) {} + void redundant1(@Param @Default("defaultValue") Optional value) {} @Get("/r2/:var1") - public void redundant2(@Param @Default("defaultValue") String var1) {} + void redundant2(@Param @Default("defaultValue") String var1) {} @Get("/r3/:var1") - public void redundant3(@Param Optional var1) {} + void redundant3(@Param Optional var1) {} } interface Bean { @@ -758,7 +753,7 @@ void setHeader1(@Header List header1) { this.header1 = header1; } - public void setOptionalHeader1(@Header("header1") Optional> optionalHeader1) { + void setOptionalHeader1(@Header("header1") Optional> optionalHeader1) { this.optionalHeader1 = optionalHeader1; } diff --git a/examples/annotated-http-service/src/main/java/example/armeria/server/annotated/InjectionService.java b/examples/annotated-http-service/src/main/java/example/armeria/server/annotated/InjectionService.java index 1a457250515..f957ea5a349 100644 --- a/examples/annotated-http-service/src/main/java/example/armeria/server/annotated/InjectionService.java +++ b/examples/annotated-http-service/src/main/java/example/armeria/server/annotated/InjectionService.java @@ -7,18 +7,17 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +import com.linecorp.armeria.common.Cookie; +import com.linecorp.armeria.common.Cookies; import com.linecorp.armeria.common.HttpResponse; import com.linecorp.armeria.common.HttpStatus; import com.linecorp.armeria.common.MediaType; import com.linecorp.armeria.common.logging.LogLevel; -import com.linecorp.armeria.server.annotation.Cookies; import com.linecorp.armeria.server.annotation.Get; import com.linecorp.armeria.server.annotation.Header; import com.linecorp.armeria.server.annotation.Param; import com.linecorp.armeria.server.annotation.decorator.LoggingDecorator; -import io.netty.handler.codec.http.cookie.Cookie; - /** * Examples how to use {@link Param}, {@link Header} and {@link Cookies}. * diff --git a/examples/saml-service-provider/src/main/java/example/armeria/server/saml/sp/MyAuthHandler.java b/examples/saml-service-provider/src/main/java/example/armeria/server/saml/sp/MyAuthHandler.java index 8a178dd54f8..d0ada3d2c3b 100644 --- a/examples/saml-service-provider/src/main/java/example/armeria/server/saml/sp/MyAuthHandler.java +++ b/examples/saml-service-provider/src/main/java/example/armeria/server/saml/sp/MyAuthHandler.java @@ -16,6 +16,7 @@ import com.google.common.base.Strings; import com.linecorp.armeria.common.AggregatedHttpRequest; +import com.linecorp.armeria.common.Cookie; import com.linecorp.armeria.common.HttpData; import com.linecorp.armeria.common.HttpHeaderNames; import com.linecorp.armeria.common.HttpRequest; @@ -28,11 +29,6 @@ import com.linecorp.armeria.server.saml.SamlNameIdFormat; import com.linecorp.armeria.server.saml.SamlSingleSignOnHandler; -import io.netty.handler.codec.http.cookie.Cookie; -import io.netty.handler.codec.http.cookie.DefaultCookie; -import io.netty.handler.codec.http.cookie.ServerCookieDecoder; -import io.netty.handler.codec.http.cookie.ServerCookieEncoder; - /** * An example of an {@link Authorizer} and a {@link SamlSingleSignOnHandler}. * @@ -54,7 +50,7 @@ public CompletionStage authorize(ServiceRequestContext ctx, HttpRequest return CompletableFuture.completedFuture(false); } - final boolean authenticated = ServerCookieDecoder.LAX.decode(cookie).stream().anyMatch( + final boolean authenticated = Cookie.fromCookieHeader(cookie).stream().anyMatch( c -> "username".equals(c.name()) && !Strings.isNullOrEmpty(c.value())); return CompletableFuture.completedFuture(authenticated); } @@ -79,15 +75,16 @@ public HttpResponse loginSucceeded(ServiceRequestContext ctx, AggregatedHttpRequ logger.info("{} user '{}' has been logged in.", ctx, username); - final Cookie cookie = new DefaultCookie("username", username); - cookie.setHttpOnly(true); - cookie.setDomain("localhost"); - cookie.setMaxAge(60); - cookie.setPath("/"); + final Cookie cookie = Cookie.builder("username", username) + .httpOnly(true) + .domain("localhost") + .maxAge(60) + .path("/") + .build(); return HttpResponse.of( ResponseHeaders.of(HttpStatus.OK, HttpHeaderNames.CONTENT_TYPE, MediaType.HTML_UTF_8, - HttpHeaderNames.SET_COOKIE, ServerCookieEncoder.LAX.encode(cookie)), + HttpHeaderNames.SET_COOKIE, cookie.toSetCookieHeader(false)), HttpData.ofUtf8("")); } diff --git a/examples/saml-service-provider/src/main/java/example/armeria/server/saml/sp/MyService.java b/examples/saml-service-provider/src/main/java/example/armeria/server/saml/sp/MyService.java index e4d6f832fa1..48fa4582ca3 100644 --- a/examples/saml-service-provider/src/main/java/example/armeria/server/saml/sp/MyService.java +++ b/examples/saml-service-provider/src/main/java/example/armeria/server/saml/sp/MyService.java @@ -1,13 +1,12 @@ package example.armeria.server.saml.sp; +import com.linecorp.armeria.common.Cookie; +import com.linecorp.armeria.common.Cookies; import com.linecorp.armeria.common.HttpResponse; import com.linecorp.armeria.common.HttpStatus; import com.linecorp.armeria.common.MediaType; -import com.linecorp.armeria.server.annotation.Cookies; import com.linecorp.armeria.server.annotation.Get; -import io.netty.handler.codec.http.cookie.Cookie; - final class MyService { @Get("/welcome") public HttpResponse welcome(Cookies cookies) { diff --git a/saml/src/test/java/com/linecorp/armeria/server/saml/SamlServiceProviderTest.java b/saml/src/test/java/com/linecorp/armeria/server/saml/SamlServiceProviderTest.java index a09b0e13d59..624b77faf93 100644 --- a/saml/src/test/java/com/linecorp/armeria/server/saml/SamlServiceProviderTest.java +++ b/saml/src/test/java/com/linecorp/armeria/server/saml/SamlServiceProviderTest.java @@ -92,6 +92,7 @@ import com.linecorp.armeria.client.WebClient; import com.linecorp.armeria.common.AggregatedHttpRequest; import com.linecorp.armeria.common.AggregatedHttpResponse; +import com.linecorp.armeria.common.Cookie; import com.linecorp.armeria.common.HttpHeaderNames; import com.linecorp.armeria.common.HttpMethod; import com.linecorp.armeria.common.HttpRequest; @@ -107,10 +108,6 @@ import io.netty.handler.codec.http.QueryStringDecoder; import io.netty.handler.codec.http.QueryStringEncoder; -import io.netty.handler.codec.http.cookie.Cookie; -import io.netty.handler.codec.http.cookie.DefaultCookie; -import io.netty.handler.codec.http.cookie.ServerCookieDecoder; -import io.netty.handler.codec.http.cookie.ServerCookieEncoder; import io.netty.handler.ssl.util.SelfSignedCertificate; public class SamlServiceProviderTest { @@ -231,7 +228,7 @@ public CompletionStage authorize(ServiceRequestContext ctx, HttpRequest } // Authentication will be succeeded only if both the specified cookie name and value are matched. - final Set cookies = ServerCookieDecoder.STRICT.decode(value); + final Set cookies = Cookie.fromCookieHeader(value); final boolean result = cookies.stream().anyMatch( cookie -> cookieName.equals(cookie.name()) && cookieValue.equals(cookie.value())); return CompletableFuture.completedFuture(result); @@ -245,11 +242,12 @@ static class CookieBasedSsoHandler implements SamlSingleSignOnHandler { requireNonNull(cookieName, "cookieName"); requireNonNull(cookieValue, "cookieValue"); - final Cookie cookie = new DefaultCookie(cookieName, cookieValue); - cookie.setDomain(spHostname); - cookie.setPath("/"); - cookie.setHttpOnly(true); - setCookie = ServerCookieEncoder.STRICT.encode(cookie); + final Cookie cookie = Cookie.builder(cookieName, cookieValue) + .domain(spHostname) + .path("/") + .httpOnly(true) + .build(); + setCookie = cookie.toSetCookieHeader(); } @Override diff --git a/site/src/sphinx/advanced-saml.rst b/site/src/sphinx/advanced-saml.rst index addfa3ce723..7d0fc1f6fd9 100644 --- a/site/src/sphinx/advanced-saml.rst +++ b/site/src/sphinx/advanced-saml.rst @@ -145,15 +145,16 @@ like ``MyAuthorizer`` in this example. // Note that you MUST NOT use this example in a real world application. You may consider encoding // the value using JSON Web Tokens to prevent tempering. - final Cookie cookie = new DefaultCookie("username", username); - cookie.setHttpOnly(true); - cookie.setDomain("localhost"); - cookie.setMaxAge(60); - cookie.setPath("/"); + final Cookie cookie = Cookie.builder("username", username) + .httpOnly(true) + .domain("localhost") + .maxAge(60) + .path("/") + .build(); return HttpResponse.of( ResponseHeaders,of(HttpStatus.OK, HttpHeaderNames.CONTENT_TYPE, MediaType.HTML_UTF_8, - HttpHeaderNames.SET_COOKIE, ServerCookieEncoder.LAX.encode(cookie)), + HttpHeaderNames.SET_COOKIE, cookie.toSetCookieHeader()), HttpData.ofUtf8("")); } @@ -175,7 +176,7 @@ like ``MyAuthorizer`` in this example. return CompletableFuture.completedFuture(false); } - final boolean authenticated = ServerCookieDecoder.LAX.decode(cookie).stream().anyMatch( + final boolean authenticated = Cookie.fromCookieHeader(cookie).stream().anyMatch( c -> "username".equals(c.name()) && !Strings.isNullOrEmpty(c.value())); return CompletableFuture.completedFuture(authenticated); } diff --git a/spring/boot-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/ArmeriaServerHttpResponse.java b/spring/boot-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/ArmeriaServerHttpResponse.java index 568284bbea9..f8182f77053 100644 --- a/spring/boot-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/ArmeriaServerHttpResponse.java +++ b/spring/boot-webflux-autoconfigure/src/main/java/com/linecorp/armeria/spring/web/reactive/ArmeriaServerHttpResponse.java @@ -49,6 +49,8 @@ import com.google.common.base.MoreObjects; import com.google.common.base.Strings; +import com.linecorp.armeria.common.Cookie; +import com.linecorp.armeria.common.CookieBuilder; import com.linecorp.armeria.common.HttpData; import com.linecorp.armeria.common.HttpHeaderNames; import com.linecorp.armeria.common.HttpObject; @@ -61,9 +63,6 @@ import com.linecorp.armeria.server.ServiceRequestContext; import io.netty.channel.EventLoop; -import io.netty.handler.codec.http.cookie.Cookie; -import io.netty.handler.codec.http.cookie.DefaultCookie; -import io.netty.handler.codec.http.cookie.ServerCookieEncoder; import io.netty.util.ReferenceCountUtil; import io.netty.util.ReferenceCounted; import reactor.core.publisher.Flux; @@ -210,8 +209,8 @@ private Mono doCommit(@Nullable Supplier> writeAction final List cookieValues = getCookies().values().stream() .flatMap(Collection::stream) - .map(ArmeriaServerHttpResponse::toNettyCookie) - .map(ServerCookieEncoder.LAX::encode) + .map(ArmeriaServerHttpResponse::toArmeriaCookie) + .map(c -> c.toSetCookieHeader(false)) .collect(toImmutableList()); if (!cookieValues.isEmpty()) { armeriaHeaders.add(HttpHeaderNames.SET_COOKIE, cookieValues); @@ -283,20 +282,23 @@ public String toString() { /** * Converts the specified {@link ResponseCookie} to Netty's {@link Cookie} interface. */ - private static Cookie toNettyCookie(ResponseCookie resCookie) { - final DefaultCookie cookie = new DefaultCookie(resCookie.getName(), resCookie.getValue()); + private static Cookie toArmeriaCookie(ResponseCookie resCookie) { + final CookieBuilder builder = Cookie.builder(resCookie.getName(), resCookie.getValue()); if (!resCookie.getMaxAge().isNegative()) { - cookie.setMaxAge(resCookie.getMaxAge().getSeconds()); + builder.maxAge(resCookie.getMaxAge().getSeconds()); } if (resCookie.getDomain() != null) { - cookie.setDomain(resCookie.getDomain()); + builder.domain(resCookie.getDomain()); } if (resCookie.getPath() != null) { - cookie.setPath(resCookie.getPath()); + builder.path(resCookie.getPath()); } - cookie.setSecure(resCookie.isSecure()); - cookie.setHttpOnly(resCookie.isHttpOnly()); - return cookie; + builder.secure(resCookie.isSecure()); + builder.httpOnly(resCookie.isHttpOnly()); + if (resCookie.getSameSite() != null) { + builder.sameSite(resCookie.getSameSite()); + } + return builder.build(); } /** diff --git a/spring/boot-webflux-autoconfigure/src/test/java/com/linecorp/armeria/spring/web/reactive/ArmeriaServerHttpResponseTest.java b/spring/boot-webflux-autoconfigure/src/test/java/com/linecorp/armeria/spring/web/reactive/ArmeriaServerHttpResponseTest.java index b3efa068939..854d91d5bf5 100644 --- a/spring/boot-webflux-autoconfigure/src/test/java/com/linecorp/armeria/spring/web/reactive/ArmeriaServerHttpResponseTest.java +++ b/spring/boot-webflux-autoconfigure/src/test/java/com/linecorp/armeria/spring/web/reactive/ArmeriaServerHttpResponseTest.java @@ -25,7 +25,7 @@ import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; -import org.junit.Test; +import org.junit.jupiter.api.Test; import org.reactivestreams.Subscriber; import org.reactivestreams.Subscription; import org.springframework.core.io.buffer.DataBuffer; @@ -34,6 +34,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseCookie; +import com.linecorp.armeria.common.Cookie; import com.linecorp.armeria.common.HttpHeaderNames; import com.linecorp.armeria.common.HttpHeaders; import com.linecorp.armeria.common.HttpMethod; @@ -44,13 +45,11 @@ import com.linecorp.armeria.server.ServiceRequestContext; import io.netty.buffer.PooledByteBufAllocator; -import io.netty.handler.codec.http.cookie.ClientCookieDecoder; -import io.netty.handler.codec.http.cookie.Cookie; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; -public class ArmeriaServerHttpResponseTest { +class ArmeriaServerHttpResponseTest { static final ServiceRequestContext ctx = ServiceRequestContext.of(HttpRequest.of(HttpMethod.GET, "/")); @@ -60,7 +59,7 @@ private static ArmeriaServerHttpResponse response( } @Test - public void returnHeadersOnly() throws Exception { + void returnHeadersOnly() throws Exception { final CompletableFuture future = new CompletableFuture<>(); final ArmeriaServerHttpResponse response = response(ctx, future); @@ -93,8 +92,10 @@ public void returnHeadersOnly() throws Exception { assertThat(o).isInstanceOf(ResponseHeaders.class); final ResponseHeaders headers = (ResponseHeaders) o; assertThat(headers.status().code()).isEqualTo(404); - final Cookie setCookie = - ClientCookieDecoder.LAX.decode(headers.get(HttpHeaderNames.SET_COOKIE)); + final String setCookieValue = headers.get(HttpHeaderNames.SET_COOKIE); + assertThat(setCookieValue).isNotNull(); + final Cookie setCookie = Cookie.fromSetCookieHeader(setCookieValue); + assertThat(setCookie).isNotNull(); assertThat(setCookie.name()).isEqualTo("a"); assertThat(setCookie.value()).isEqualTo("1"); assertThat(setCookie.domain()).isEqualTo("localhost"); @@ -110,7 +111,7 @@ public void returnHeadersOnly() throws Exception { } @Test - public void returnHeadersAndBody() throws Exception { + void returnHeadersAndBody() throws Exception { final CompletableFuture future = new CompletableFuture<>(); final ArmeriaServerHttpResponse response = response(ctx, future); @@ -147,8 +148,10 @@ public void returnHeadersAndBody() throws Exception { final ResponseHeaders headers = (ResponseHeaders) o; assertThat(headers.status().code()).isEqualTo(200); assertThat(headers.get(HttpHeaderNames.of("Armeria"))).isEqualTo("awesome"); - final Cookie setCookie = - ClientCookieDecoder.LAX.decode(headers.get(HttpHeaderNames.SET_COOKIE)); + final String setCookieValue = headers.get(HttpHeaderNames.SET_COOKIE); + assertThat(setCookieValue).isNotNull(); + final Cookie setCookie = Cookie.fromSetCookieHeader(setCookieValue); + assertThat(setCookie).isNotNull(); assertThat(setCookie.name()).isEqualTo("a"); assertThat(setCookie.value()).isEqualTo("1"); assertThat(setCookie.domain()).isEqualTo("localhost"); @@ -169,7 +172,7 @@ public void returnHeadersAndBody() throws Exception { } @Test - public void controlBackpressure() throws Exception { + void controlBackpressure() throws Exception { final CompletableFuture future = new CompletableFuture<>(); final ArmeriaServerHttpResponse response = response(ctx, future); @@ -215,7 +218,7 @@ public void controlBackpressure() throws Exception { } @Test - public void returnHeadersAndBodyWithMultiplePublisher() throws Exception { + void returnHeadersAndBodyWithMultiplePublisher() throws Exception { final CompletableFuture future = new CompletableFuture<>(); final ArmeriaServerHttpResponse response = response(ctx, future); @@ -263,7 +266,7 @@ public void returnHeadersAndBodyWithMultiplePublisher() throws Exception { } @Test - public void requestInvalidDemand() throws Exception { + void requestInvalidDemand() throws Exception { final ConcurrentLinkedQueue allocatedBuffers = new ConcurrentLinkedQueue<>(); final DataBufferFactoryWrapper factoryWrapper = new DataBufferFactoryWrapper<>( new NettyDataBufferFactory(PooledByteBufAllocator.DEFAULT) {