diff --git a/web/webkit/src/main/scala/net/liftweb/http/provider/encoder/CookieEncoder.scala b/web/webkit/src/main/scala/net/liftweb/http/provider/encoder/CookieEncoder.scala new file mode 100644 index 0000000000..787bb45eda --- /dev/null +++ b/web/webkit/src/main/scala/net/liftweb/http/provider/encoder/CookieEncoder.scala @@ -0,0 +1,241 @@ +/* + * Copyright 2009-2011 WorldWide Conferencing, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.liftweb +package http +package provider +package encoder + +import java.util._ +import net.liftweb.http.provider.{HTTPCookie, SameSite} +import net.liftweb.common.{Full} + +/** + * Converts an HTTPCookie into a string to used as header cookie value. + * + * The string representation follows the RFC6265 + * standard with the added field of SameSite to support secure browsers as explained at + * MDN SameSite Cookies + * + * This code is based on the Netty's HTTP cookie encoder. + * + * Multiple cookies are supported just sending separate "Set-Cookie" headers for each cookie. + * + */ +object CookieEncoder { + + private val VALID_COOKIE_NAME_OCTETS = validCookieNameOctets(); + + private val VALID_COOKIE_VALUE_OCTETS = validCookieValueOctets(); + + private val VALID_COOKIE_ATTRIBUTE_VALUE_OCTETS = validCookieAttributeValueOctets(); + + private val PATH = "Path" + + private val EXPIRES = "Expires" + + private val MAX_AGE = "Max-Age" + + private val DOMAIN = "Domain" + + private val SECURE = "Secure" + + private val HTTPONLY = "HTTPOnly" + + private val SAMESITE = "SameSite" + + private val DAY_OF_WEEK_TO_SHORT_NAME = Array("Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat") + + private val CALENDAR_MONTH_TO_SHORT_NAME = Array("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", + "Sep", "Oct", "Nov", "Dec") + + def encode(cookie: HTTPCookie): String = { + val name = cookie.name + val value = cookie.value.getOrElse("") + val skipValidation = isOldVersionCookie(cookie) + if (!skipValidation) { + validateCookie(name, value) + } + val buf = new StringBuilder() + add(buf, name, value); + cookie.maxAge foreach { maxAge => + add(buf, MAX_AGE, maxAge); + val expires = new Date(maxAge * 1000 + System.currentTimeMillis()); + buf.append(EXPIRES); + buf.append('='); + appendDate(expires, buf); + buf.append(';'); + buf.append(' '); + } + cookie.path foreach { path => + add(buf, PATH, path); + } + cookie.domain foreach { domain => + add(buf, DOMAIN, domain); + } + cookie.secure_? foreach { isSecure => + if (isSecure) add(buf, SECURE); + } + cookie.httpOnly foreach { isHttpOnly => + if (isHttpOnly) add(buf, HTTPONLY) + } + cookie.sameSite foreach { + case SameSite.LAX => + add(buf, SAMESITE, "Lax") + case SameSite.STRICT => + add(buf, SAMESITE, "Strict") + case SameSite.NONE => + add(buf, SAMESITE, "None") + } + stripTrailingSeparator(buf) + } + + private def validateCookie(name: String, value: String): Unit = { + val posFirstInvalidCookieNameOctet = firstInvalidCookieNameOctet(name) + if (posFirstInvalidCookieNameOctet >= 0) { + throw new IllegalArgumentException("Cookie name contains an invalid char: " + + name.charAt(posFirstInvalidCookieNameOctet)) + } + val unwrappedValue = unwrapValue(value); + if (unwrappedValue == null) { + throw new IllegalArgumentException("Cookie value wrapping quotes are not balanced: " + + value); + } + val postFirstInvalidCookieValueOctet = firstInvalidCookieValueOctet(unwrappedValue) + if (postFirstInvalidCookieValueOctet >= 0) { + throw new IllegalArgumentException("Cookie value contains an invalid char: " + + unwrappedValue.charAt(postFirstInvalidCookieValueOctet)); + } + } + + /** + * Checks if the cookie is set with an old version 0. + * + * More info about the cookie version at https://javadoc.io/static/jakarta.servlet/jakarta.servlet-api/5.0.0/jakarta/servlet/http/Cookie.html#setVersion-int- + * + * @param cookie + * @return true if the cookie version is 0, false if it has no value or a different value than 0 + */ + private def isOldVersionCookie(cookie: HTTPCookie): Boolean = { + cookie.version map (_ == 0) getOrElse false + } + + private def appendDate(date: Date, sb: StringBuilder): StringBuilder = { + val cal = new GregorianCalendar(TimeZone.getTimeZone("UTC")) + cal.setTime(date) + sb.append(DAY_OF_WEEK_TO_SHORT_NAME(cal.get(Calendar.DAY_OF_WEEK) - 1)).append(", ") + appendZeroLeftPadded(cal.get(Calendar.DAY_OF_MONTH), sb).append(' ') + sb.append(CALENDAR_MONTH_TO_SHORT_NAME(cal.get(Calendar.MONTH))).append(' ') + sb.append(cal.get(Calendar.YEAR)).append(' ') + appendZeroLeftPadded(cal.get(Calendar.HOUR_OF_DAY), sb).append(':') + appendZeroLeftPadded(cal.get(Calendar.MINUTE), sb).append(':') + appendZeroLeftPadded(cal.get(Calendar.SECOND), sb).append(" GMT") + } + + private def appendZeroLeftPadded(value: Int, sb: StringBuilder): StringBuilder = { + if (value < 10) { + sb.append('0'); + } + return sb.append(value); + } + + private def validCookieNameOctets() = { + val bits = new BitSet() + (32 until 127) foreach bits.set + val separators = Array('(', ')', '<', '>', '@', ',', ';', ':', '\\', '"', '/', '[', ']', '?', '=', '{', + '}', ' ', '\t' ) + separators.foreach(separator => bits.set(separator, false)) + bits + } + + private def validCookieValueOctets() = { + val bits = new BitSet() + bits.set(0x21); + (0x23 to 0x2B) foreach bits.set + (0x2D to 0x3A) foreach bits.set + (0x3C to 0x5B) foreach bits.set + (0x5D to 0x7E) foreach bits.set + bits + } + + private def validCookieAttributeValueOctets() = { + val bits = new BitSet() + (32 until 127) foreach bits.set + bits.set(';', false) + bits + } + + private def stripTrailingSeparator(buf: StringBuilder) = { + if (buf.length() > 0) { + buf.setLength(buf.length() - 2); + } + buf.toString() + } + + private def add(sb: StringBuilder, name: String, value: Long) = { + sb.append(name); + sb.append('='); + sb.append(value); + sb.append(';'); + sb.append(' '); + } + + private def add(sb: StringBuilder, name: String, value: String) = { + sb.append(name); + sb.append('='); + sb.append(value); + sb.append(';'); + sb.append(' '); + } + + private def add(sb: StringBuilder, name: String) = { + sb.append(name); + sb.append(';'); + sb.append(' '); + } + + private def firstInvalidCookieNameOctet(cs: CharSequence): Int = { + return firstInvalidOctet(cs, VALID_COOKIE_NAME_OCTETS); + } + + private def firstInvalidCookieValueOctet(cs: CharSequence): Int = { + return firstInvalidOctet(cs, VALID_COOKIE_VALUE_OCTETS); + } + + private def firstInvalidOctet(cs: CharSequence, bits: BitSet): Int = { + (0 until cs.length()).foreach { i => + val c = cs.charAt(i) + if (!bits.get(c)) { + return i; + } + } + -1; + } + + private def unwrapValue(cs: CharSequence): CharSequence = { + val len = cs.length() + if (len > 0 && cs.charAt(0) == '"') { + if (len >= 2 && cs.charAt(len - 1) == '"') { + if (len == 2) "" else cs.subSequence(1, len - 1) + } else { + null + } + } else { + cs + } + } + +} diff --git a/web/webkit/src/main/scala/net/liftweb/http/provider/servlet/HTTPResponseServlet.scala b/web/webkit/src/main/scala/net/liftweb/http/provider/servlet/HTTPResponseServlet.scala index 64f3e1bd08..4a3a2b68d0 100644 --- a/web/webkit/src/main/scala/net/liftweb/http/provider/servlet/HTTPResponseServlet.scala +++ b/web/webkit/src/main/scala/net/liftweb/http/provider/servlet/HTTPResponseServlet.scala @@ -22,34 +22,19 @@ package servlet import scala.collection.mutable.{ListBuffer} import java.io.{OutputStream} import javax.servlet.http.{HttpServletResponse, Cookie} -import io.netty.handler.codec.http.cookie.{ DefaultCookie => NettyCookie } -import io.netty.handler.codec.http.cookie.{ ServerCookieEncoder => NettyCookieEncoder } -import io.netty.handler.codec.http.cookie.CookieHeaderNames.{ SameSite => NettySameSite } +import net.liftweb.http.provider.encoder.CookieEncoder import net.liftweb.common._ import net.liftweb.util._ import Helpers._ class HTTPResponseServlet(resp: HttpServletResponse) extends HTTPResponse { private var _status = 0; + + private val SET_COOKIE_HEADER = "Set-Cookie" def addCookies(cookies: List[HTTPCookie]) = cookies.foreach { - case c => - val cookie = new NettyCookie(c.name, c.value openOr null) - c.domain map cookie.setDomain - c.path map cookie.setPath - c.maxAge map (_.toLong) map cookie.setMaxAge - c.secure_? map cookie.setSecure - c.sameSite map { - case SameSite.LAX => - cookie.setSameSite(NettySameSite.Lax) - case SameSite.STRICT => - cookie.setSameSite(NettySameSite.Strict) - case SameSite.NONE => - cookie.setSameSite(NettySameSite.None) - } - c.httpOnly map cookie.setHttpOnly - val encoder = if (c.version == Full(0)) NettyCookieEncoder.LAX else NettyCookieEncoder.STRICT - resp.addHeader("Set-Cookie", encoder.encode(cookie)) + case cookie => + resp.addHeader(SET_COOKIE_HEADER, CookieEncoder.encode(cookie)) } private val shouldEncodeUrl = LiftRules.encodeJSessionIdInUrl_? diff --git a/web/webkit/src/test/scala/net/liftweb/http/provider/encoder/CookieEncoderSpec.scala b/web/webkit/src/test/scala/net/liftweb/http/provider/encoder/CookieEncoderSpec.scala new file mode 100644 index 0000000000..32f3cbccce --- /dev/null +++ b/web/webkit/src/test/scala/net/liftweb/http/provider/encoder/CookieEncoderSpec.scala @@ -0,0 +1,247 @@ +/* + * Copyright 2010-2011 WorldWide Conferencing, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.liftweb.http.provider.encoder + +import net.liftweb.http.provider._ +import net.liftweb.common._ +import org.specs2.mutable.Specification + +object CookieEncoderSpec extends Specification { + + "CookieEncoder" should { + "convert a simple cookie" in { + val cookie = HTTPCookie("test-name", "test-value") + CookieEncoder.encode(cookie) must_== "test-name=test-value" + } + + "convert a secure cookie" in { + val cookie = HTTPCookie(name = "test-name", + value = Full("test-value"), + domain = Empty, + path = Empty, + maxAge = Empty, + version = Empty, + secure_? = Full(true), + httpOnly = Empty, + sameSite = Empty) + CookieEncoder.encode(cookie) must_== "test-name=test-value; Secure" + } + + "convert a cookie with a domain" in { + val cookie = HTTPCookie(name = "test-name", + value = Full("test-value"), + domain = Full("test-domain.com"), + path = Empty, + maxAge = Empty, + version = Empty, + secure_? = Empty, + httpOnly = Empty, + sameSite = Empty) + CookieEncoder.encode(cookie) must_== "test-name=test-value; Domain=test-domain.com" + } + + "convert a cookie with a path" in { + val cookie = HTTPCookie(name = "test-name", + value = Full("test-value"), + domain = Empty, + path = Full("/test-path"), + maxAge = Empty, + version = Empty, + secure_? = Empty, + httpOnly = Empty, + sameSite = Empty) + CookieEncoder.encode(cookie) must_== "test-name=test-value; Path=/test-path" + } + + "convert a cookie with a max age" in { + val cookie = HTTPCookie(name = "test-name", + value = Full("test-value"), + domain = Empty, + path = Empty, + maxAge = Full(10), + version = Empty, + secure_? = Empty, + httpOnly = Empty, + sameSite = Empty) + val encodedCookie = CookieEncoder.encode(cookie) + encodedCookie.startsWith("test-name=test-value; ") must_== true + encodedCookie.contains("Max-Age=10; Expires=") must_== true + } + + "convert an HTTP only cookie" in { + val cookie = HTTPCookie(name = "test-name", + value = Full("test-value"), + domain = Empty, + path = Empty, + maxAge = Empty, + version = Empty, + secure_? = Empty, + httpOnly = Full(true), + sameSite = Empty) + CookieEncoder.encode(cookie) must_== "test-name=test-value; HTTPOnly" + } + + "convert a same site LAX cookie" in { + val cookie = HTTPCookie(name = "test-name", + value = Full("test-value"), + domain = Empty, + path = Empty, + maxAge = Empty, + version = Empty, + secure_? = Empty, + httpOnly = Empty, + sameSite = Full(SameSite.LAX)) + CookieEncoder.encode(cookie) must_== "test-name=test-value; SameSite=Lax" + } + + "convert a same site NONE cookie" in { + val cookie = HTTPCookie(name = "test-name", + value = Full("test-value"), + domain = Empty, + path = Empty, + maxAge = Empty, + version = Empty, + secure_? = Empty, + httpOnly = Empty, + sameSite = Full(SameSite.NONE)) + CookieEncoder.encode(cookie) must_== "test-name=test-value; SameSite=None" + } + + "convert a same site STRICT cookie" in { + val cookie = HTTPCookie(name = "test-name", + value = Full("test-value"), + domain = Empty, + path = Empty, + maxAge = Empty, + version = Empty, + secure_? = Empty, + httpOnly = Empty, + sameSite = Full(SameSite.STRICT)) + CookieEncoder.encode(cookie) must_== "test-name=test-value; SameSite=Strict" + } + + "convert a secure same site none cookie" in { + val cookie = HTTPCookie(name = "test-name", + value = Full("test-value"), + domain = Empty, + path = Empty, + maxAge = Empty, + version = Empty, + secure_? = Full(true), + httpOnly = Empty, + sameSite = Full(SameSite.NONE)) + CookieEncoder.encode(cookie) must_== "test-name=test-value; Secure; SameSite=None" + } + + "convert a secure same site strict cookie with max age" in { + val cookie = HTTPCookie(name = "test-name", + value = Full("test-value"), + domain = Empty, + path = Empty, + maxAge = Full(10), + version = Empty, + secure_? = Full(true), + httpOnly = Empty, + sameSite = Full(SameSite.NONE)) + val encodedCookie = CookieEncoder.encode(cookie) + encodedCookie.startsWith("test-name=test-value; Max-Age=10; Expires=") must_== true + encodedCookie.endsWith("; Secure; SameSite=None") must_== true + } + + "convert a secure same site lax cookie with max age, domain and path" in { + val cookie = HTTPCookie(name = "test-name", + value = Full("test-value"), + domain = Full("test-domain.com"), + path = Full("/test-path"), + maxAge = Full(10), + version = Empty, + secure_? = Full(true), + httpOnly = Full(false), + sameSite = Full(SameSite.LAX)) + val encodedCookie = CookieEncoder.encode(cookie) + encodedCookie.startsWith("test-name=test-value; Max-Age=10; Expires=") must_== true + encodedCookie.endsWith("; Path=/test-path; Domain=test-domain.com; Secure; SameSite=Lax") must_== true + } + + "convert a secure HTTP only cookie" in { + val cookie = HTTPCookie(name = "test-name", + value = Full("test-value"), + domain = Empty, + path = Empty, + maxAge = Empty, + version = Empty, + secure_? = Full(true), + httpOnly = Full(true), + sameSite = Empty) + CookieEncoder.encode(cookie) must_== "test-name=test-value; Secure; HTTPOnly" + } + + "convert a cookie with only the name" in { + val cookie = HTTPCookie(name = "test-name", + value = Empty, + domain = Empty, + path = Empty, + maxAge = Empty, + version = Empty, + secure_? = Empty, + httpOnly = Empty, + sameSite = Empty) + CookieEncoder.encode(cookie) must_== "test-name=" + } + + "must fail trying to convert an invalid name cookie" in { + val cookie = HTTPCookie("invalid-name=", "test-value") + CookieEncoder.encode(cookie) must throwA[IllegalArgumentException]( + "Cookie name contains an invalid char: =") + } + + "must fail trying to convert an invalid value cookie" in { + val cookie = HTTPCookie("test-name", "invalid-value\t") + CookieEncoder.encode(cookie) must throwA[IllegalArgumentException]( + "Cookie value contains an invalid char: \t") + } + + "must skip validation for old version cookies" in { + val cookie = HTTPCookie(name = "invalid-name=", + value = Full("invalid-value\t"), + domain = Empty, + path = Empty, + maxAge = Empty, + version = Full(0), + secure_? = Empty, + httpOnly = Empty, + sameSite = Empty) + CookieEncoder.encode(cookie) must_== "invalid-name==invalid-value\t" + } + + "must validate new version cookies" in { + val cookie = HTTPCookie(name = "invalid-name=", + value = Full("invalid-value\t"), + domain = Empty, + path = Empty, + maxAge = Empty, + version = Full(1), + secure_? = Empty, + httpOnly = Empty, + sameSite = Empty) + CookieEncoder.encode(cookie) must throwA[IllegalArgumentException]( + "Cookie name contains an invalid char: =" + ) + } + } + +}