diff --git a/contributors.md b/contributors.md
index 6edffe9131..815e9153d0 100755
--- a/contributors.md
+++ b/contributors.md
@@ -284,3 +284,9 @@ heharkon@iki.fi
### Email: ###
it dot blackdog at gmail dot com
+
+### Name: ###
+Carlos Saltos
+
+### Email: ###
+dev@csaltos.com
diff --git a/web/webkit/src/main/scala/net/liftweb/http/provider/HTTPCookie.scala b/web/webkit/src/main/scala/net/liftweb/http/provider/HTTPCookie.scala
index c69ec3eb37..6f96eed512 100644
--- a/web/webkit/src/main/scala/net/liftweb/http/provider/HTTPCookie.scala
+++ b/web/webkit/src/main/scala/net/liftweb/http/provider/HTTPCookie.scala
@@ -20,6 +20,10 @@ package provider
import net.liftweb.common.{Box, Empty, Full}
+object SameSite extends Enumeration {
+ val LAX, STRICT, NONE = Value
+}
+
/**
* Companion module for creating HTTPCookie objects
*/
@@ -37,7 +41,8 @@ case class HTTPCookie(name: String,
maxAge: Box[Int],
version: Box[Int],
secure_? : Box[Boolean],
- httpOnly: Box[Boolean] = Empty) extends java.lang.Cloneable {
+ httpOnly: Box[Boolean] = Empty,
+ sameSite : Box[SameSite.Value] = Empty) extends java.lang.Cloneable {
override def clone(): HTTPCookie = {
super.clone()
copy()
@@ -45,11 +50,18 @@ case class HTTPCookie(name: String,
/**
* Returns a new HTTPCookie that preserve existing member values but sets the httpOnly attribute
- * @param flagHttpOnly - should the cookie be flagged as HTTP Only (only works in Servlet 3.0 containers)
+ * @param flagHttpOnly - should the cookie be flagged as HTTP Only
* @return HTTPCookie
*/
def setHttpOnly(flagHttpOnly: Boolean): HTTPCookie = copy(httpOnly = Full(flagHttpOnly))
+ /**
+ * Returns a new HTTPCookie that preserve existing member values but sets the sameSite attribute
+ * @param newSameSite - the new cookie SameSite value
+ * @return HTTPCookie
+ */
+ def setSameSite(newSameSite: SameSite.Value): HTTPCookie = copy(sameSite = Full(newSameSite))
+
/**
* Returns a new HTTPCookie that preserve existing member values but sets the cookie value to newValue
* @param newValue - the new cookie value
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 3b0f6ec4d4..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,33 +22,19 @@ package servlet
import scala.collection.mutable.{ListBuffer}
import java.io.{OutputStream}
import javax.servlet.http.{HttpServletResponse, Cookie}
+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 javax.servlet.http.Cookie(c.name, c.value openOr null)
- c.domain map (cookie.setDomain(_))
- c.path map (cookie.setPath(_))
- c.maxAge map (cookie.setMaxAge(_))
- c.version map (cookie.setVersion(_))
- c.secure_? map (cookie.setSecure(_))
- c.httpOnly.foreach { bv =>
- import scala.language.reflectiveCalls
-
- try {
- val cook30 = cookie.asInstanceOf[{def setHttpOnly(b: Boolean): Unit}]
- cook30.setHttpOnly(bv)
- } catch {
- case e: Exception => // swallow.. the exception will be thrown for Servlet 2.5 containers but work for servlet
- // 3.0 containers
- }
- }
- resp.addCookie(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: ="
+ )
+ }
+ }
+
+}