Skip to content

Commit 0b87cf8

Browse files
adamwclaude
andauthored
Carry cookies across redirects via an opt-in CookieStorage (#2671) (#2897)
Closes #2671. Supersedes #2672 (which added a failing test demonstrating the problem, but no fix). ## Problem The `Cookie` header is a sensitive header, so it is stripped when following a redirect. As a result, cookies set via `Set-Cookie` during a redirect chain were never sent to subsequent requests in that chain (see #2671). ## Approach Implements the design @adamw outlined in #2671: an opt-in cookie jar. - New `sttp.client4.wrappers.CookieStorage` — an immutable cookie jar. `set(setBy, cookies)` collects `Set-Cookie` cookies (rejecting ones whose `Domain` doesn't match the setting host); `forUri(uri)` returns the cookies to send to a URI. Matching follows a subset of [RFC 6265](https://www.rfc-editor.org/rfc/rfc6265): domain-matching, path-matching and the `Secure` attribute. Time-based expiry isn't tracked, but `Max-Age` <= 0 removes a cookie. - `RequestBuilder.cookieStorage(storage)` attaches a storage to a request (via a request attribute). - `FollowRedirectsBackend` (applied to all backends by default), when a storage is attached, sends the matching stored cookies with each request in a redirect chain and threads an updated storage through to the next request. **Default behaviour is unchanged** unless a `CookieStorage` is explicitly attached. ## Tests - `CookieStorageTest` — domain/host-only isolation, subdomain matching, cross-domain rejection, path, `Secure`, overwrite and `Max-Age` deletion. - `FollowRedirectsBackendTest` — cookies set across a redirect chain reach subsequent requests when a storage is attached, and are not carried when it isn't. Full `core` suite passes (631 tests), cross-compiled for Scala 2.12 / 2.13 / 3. Credit to @Zerkath for the original report and test case in #2671 / #2672. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8e5efd8 commit 0b87cf8

6 files changed

Lines changed: 339 additions & 5 deletions

File tree

core/src/main/scala/sttp/client4/requestBuilder.scala

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import sttp.client4.internal.SttpFile
44
import sttp.client4.internal.Utf8
55
import sttp.client4.internal.contentTypeWithCharset
66
import sttp.client4.logging.LoggingOptions
7+
import sttp.client4.wrappers.CookieStorage
78
import sttp.client4.wrappers.DigestAuthenticationBackend
89
import sttp.model.HasHeaders
910
import sttp.model.Header
@@ -161,6 +162,14 @@ trait PartialRequestBuilder[+PR <: PartialRequestBuilder[PR, R], +R]
161162
onDuplicate = DuplicateHeaderBehavior.Combine
162163
)
163164

165+
/** Attaches a [[sttp.client4.wrappers.CookieStorage CookieStorage]] to this request. When following redirects (see
166+
* [[sttp.client4.wrappers.FollowRedirectsBackend]], applied to all backends by default), cookies set via
167+
* `Set-Cookie` in a redirect chain are then collected into the storage and sent with subsequent requests that they
168+
* domain/path-match. Without a storage, cookies are not carried across redirects (the `Cookie` header is sensitive
169+
* and stripped). Start with [[sttp.client4.wrappers.CookieStorage.empty]].
170+
*/
171+
def cookieStorage(storage: CookieStorage): PR = attribute(CookieStorage.attributeKey, storage)
172+
164173
private[client4] def hasContentType: Boolean = headers.exists(_.is(HeaderNames.ContentType))
165174
private[client4] def setContentTypeIfMissing(mt: MediaType): PR =
166175
if (hasContentType) this else contentType(mt)
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
package sttp.client4.wrappers
2+
3+
import sttp.attributes.AttributeKey
4+
import sttp.model.Uri
5+
6+
/** An immutable cookie jar.
7+
*
8+
* Collects cookies received in `Set-Cookie` response headers and determines which of them should be sent with a
9+
* request to a given URI, applying a subset of the [[https://www.rfc-editor.org/rfc/rfc6265 RFC 6265]] rules:
10+
* domain-matching, path-matching and the `Secure` attribute.
11+
*
12+
* Intended use: attach a storage to a request using [[sttp.client4.PartialRequestBuilder.cookieStorage]]. The
13+
* [[FollowRedirectsBackend]] (applied to all backends by default) then, for each request in a redirect chain, sends
14+
* the matching stored cookies and threads an updated storage through to the next request. This makes cookies set via
15+
* `Set-Cookie` during a redirect chain visible to subsequent requests in that chain - which otherwise doesn't happen,
16+
* as the `Cookie` header is a sensitive header, stripped when following redirects.
17+
*
18+
* Cookies are represented as plain name/value pairs rather than [[sttp.model.headers.CookieWithMeta]]. That type is
19+
* available on all platforms, but its `Set-Cookie` rendering and parsing reach `java.time` date formatting (for
20+
* `Expires`, via `ZoneId`/`DateTimeFormatter`), a subset of `java.time` not supported on Scala Native; referencing it
21+
* from this shared code pulls those symbols in and breaks the Native link. Time-based expiry (`Max-Age` > 0, `Expires`)
22+
* is not tracked anyway, as the storage has no notion of the current time; a `Set-Cookie` with `Max-Age` <= 0 removes a
23+
* matching cookie, so a server can still clear cookies within a chain.
24+
*/
25+
final class CookieStorage private (private val entries: Map[CookieStorage.Key, CookieStorage.Stored]) {
26+
import CookieStorage._
27+
28+
/** A new storage updated with the cookies parsed from the `Set-Cookie` header values received from `setBy`.
29+
* Following RFC 6265, a cookie whose `Domain` attribute does not domain-match `setBy` is rejected (to prevent a
30+
* host setting cookies for unrelated domains). A cookie with `Max-Age` <= 0 removes a matching stored cookie.
31+
*/
32+
def setFromSetCookieHeaders(setBy: Uri, setCookieHeaders: Iterable[String]): CookieStorage = {
33+
val host = hostOf(setBy)
34+
val updated = setCookieHeaders.flatMap(parseSetCookie).foldLeft(entries) { (acc, c) =>
35+
val hostOnly = c.domain.isEmpty
36+
val domain = c.domain.map(normalizeDomain).getOrElse(host)
37+
if (domain.isEmpty || !domainMatches(host, domain)) acc
38+
else {
39+
val key = Key(c.name, domain, c.path.getOrElse(defaultPathOf(setBy)))
40+
if (c.removed) acc - key
41+
else acc.updated(key, Stored(c.value, c.secure, hostOnly))
42+
}
43+
}
44+
new CookieStorage(updated)
45+
}
46+
47+
/** The cookies, as `name -> value` pairs, that should be sent with a request to `uri`, according to domain-matching,
48+
* path-matching and the `Secure` attribute (secure cookies are only sent over `https`).
49+
*/
50+
def cookiesFor(uri: Uri): Seq[(String, String)] = {
51+
val host = hostOf(uri)
52+
val path = pathOf(uri)
53+
val secure = uri.scheme.exists(_.equalsIgnoreCase("https"))
54+
entries.collect { case (key, stored) if matches(key, stored, host, path, secure) => key.name -> stored.value }.toSeq
55+
}
56+
57+
private def matches(key: Key, stored: Stored, host: String, path: String, secure: Boolean): Boolean = {
58+
val domainMatch = if (stored.hostOnly) host == key.domain else domainMatches(host, key.domain)
59+
domainMatch && pathMatches(path, key.path) && (!stored.secure || secure)
60+
}
61+
62+
def isEmpty: Boolean = entries.isEmpty
63+
}
64+
65+
object CookieStorage {
66+
67+
/** An empty storage. */
68+
val empty: CookieStorage = new CookieStorage(Map.empty)
69+
70+
/** The attribute key under which a [[CookieStorage]] is attached to a request; see
71+
* [[sttp.client4.PartialRequestBuilder.cookieStorage]].
72+
*/
73+
val attributeKey: AttributeKey[CookieStorage] =
74+
new AttributeKey[CookieStorage]("sttp.client4.wrappers.CookieStorage")
75+
76+
private val DefaultPath = "/"
77+
78+
// a cookie is identified by its name, the domain it's scoped to and its path
79+
private case class Key(name: String, domain: String, path: String)
80+
private case class Stored(value: String, secure: Boolean, hostOnly: Boolean)
81+
82+
// a `Set-Cookie` cookie before its domain is resolved against the setting host; `removed` marks a Max-Age <= 0
83+
// deletion
84+
private case class Parsed(
85+
name: String,
86+
value: String,
87+
domain: Option[String],
88+
path: Option[String],
89+
secure: Boolean,
90+
removed: Boolean
91+
)
92+
93+
private def hostOf(uri: Uri): String = uri.host.getOrElse("").toLowerCase
94+
private def pathOf(uri: Uri): String = "/" + uri.path.mkString("/")
95+
private def normalizeDomain(d: String): String = d.stripPrefix(".").toLowerCase
96+
97+
// RFC 6265, 5.1.4: the default-path of a cookie without a `Path` attribute is the setting request's directory - the
98+
// path up to, but not including, the rightmost "/" (or "/" if there is none beyond the leading one)
99+
private def defaultPathOf(uri: Uri): String = {
100+
val p = pathOf(uri)
101+
val lastSlash = p.lastIndexOf('/')
102+
if (lastSlash <= 0) DefaultPath else p.substring(0, lastSlash)
103+
}
104+
105+
// RFC 6265, 5.1.3: equal, or `host` is a subdomain of `domain`
106+
private def domainMatches(host: String, domain: String): Boolean =
107+
host == domain || host.endsWith("." + domain)
108+
109+
// RFC 6265, 5.1.4
110+
private def pathMatches(requestPath: String, cookiePath: String): Boolean =
111+
requestPath == cookiePath ||
112+
(requestPath.startsWith(cookiePath) &&
113+
(cookiePath.endsWith("/") || requestPath.charAt(cookiePath.length) == '/'))
114+
115+
// A minimal `Set-Cookie` parser reading only the attributes used for storage. CookieWithMeta.parse isn't reused
116+
// because its `Expires` handling relies on java.time date formatting, which doesn't work on Scala Native; `Expires`
117+
// is ignored here anyway, as the storage doesn't track time-based expiry.
118+
private def parseSetCookie(headerValue: String): Option[Parsed] =
119+
headerValue.split(";").iterator.map(_.trim).filter(_.nonEmpty).toList match {
120+
case nameValue :: directives =>
121+
val eq = nameValue.indexOf('=')
122+
val name = if (eq < 0) nameValue else nameValue.substring(0, eq).trim
123+
if (name.isEmpty) None
124+
else {
125+
val value = if (eq < 0) "" else nameValue.substring(eq + 1).trim
126+
val attrs = directives.map { d =>
127+
val i = d.indexOf('=')
128+
if (i < 0) (d.toLowerCase, "") else (d.substring(0, i).trim.toLowerCase, d.substring(i + 1).trim)
129+
}.toMap
130+
val maxAge = attrs.get("max-age").flatMap(s => scala.util.Try(s.toLong).toOption)
131+
Some(
132+
Parsed(
133+
name = name,
134+
value = value,
135+
domain = attrs.get("domain").filter(_.nonEmpty),
136+
path = attrs.get("path").filter(_.nonEmpty),
137+
secure = attrs.contains("secure"),
138+
removed = maxAge.exists(_ <= 0)
139+
)
140+
)
141+
}
142+
case Nil => None
143+
}
144+
}

core/src/main/scala/sttp/client4/wrappers/FollowRedirectsBackend.scala

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,19 @@ abstract class FollowRedirectsBackend[F[_], P] private (
1616
override def send[T](request: GenericRequest[T, R]): F[Response[T]] = sendWithCounter(request, 0)
1717

1818
protected def sendWithCounter[T](request: GenericRequest[T, R], redirects: Int): F[Response[T]] = {
19+
// if a cookie storage is attached, send the cookies matching this request's URI (cookies are otherwise lost
20+
// across redirects, as the `Cookie` header is sensitive and stripped)
21+
val requestWithCookies = applyStoredCookies(request)
22+
1923
// if there are nested follow redirect backends, disabling them and handling redirects here
2024
// using a def instead of a val so that errors are properly caught
21-
def resp = delegate.send(request.followRedirects(false))
25+
def resp = delegate.send(requestWithCookies.followRedirects(false))
2226

2327
if (request.options.followRedirects) {
2428
resp
2529
.flatMap { (response: Response[T]) =>
2630
if (response.isRedirect) {
27-
followRedirect(request, response, redirects)
31+
followRedirect(updateStoredCookies(request, response), response, redirects)
2832
} else {
2933
monad.unit(response)
3034
}
@@ -34,7 +38,7 @@ abstract class FollowRedirectsBackend[F[_], P] private (
3438
case Some(re) if re.response.isRedirect =>
3539
re.response.header(HeaderNames.Location) match {
3640
case None => monad.error(e) // no location header, propagating the exception
37-
case Some(loc) => followRedirect(request, re.response, redirects, loc)
41+
case Some(loc) => followRedirect(updateStoredCookies(request, re.response), re.response, redirects, loc)
3842
}
3943
case _ => monad.error(e)
4044
}
@@ -44,6 +48,28 @@ abstract class FollowRedirectsBackend[F[_], P] private (
4448
}
4549
}
4650

51+
/** If a [[CookieStorage]] is attached, adds the cookies matching the request's URI as a `Cookie` header. No-op
52+
* otherwise, so the default behaviour is unchanged unless a storage is explicitly attached.
53+
*/
54+
private def applyStoredCookies[T](request: GenericRequest[T, R]): GenericRequest[T, R] =
55+
request.attribute(CookieStorage.attributeKey) match {
56+
case Some(storage) =>
57+
val cookies = storage.cookiesFor(request.uri)
58+
if (cookies.isEmpty) request else request.cookies(cookies: _*)
59+
case None => request
60+
}
61+
62+
/** If a [[CookieStorage]] is attached, returns the request with the storage updated with the response's `Set-Cookie`
63+
* cookies, so that the next request in the redirect chain can send them. No-op otherwise.
64+
*/
65+
private def updateStoredCookies[T](request: GenericRequest[T, R], response: ResponseMetadata): GenericRequest[T, R] =
66+
request.attribute(CookieStorage.attributeKey) match {
67+
case Some(storage) =>
68+
val updated = storage.setFromSetCookieHeaders(request.uri, response.headers(HeaderNames.SetCookie))
69+
request.attribute(CookieStorage.attributeKey, updated)
70+
case None => request
71+
}
72+
4773
private def followRedirect[T](
4874
request: GenericRequest[T, R],
4975
response: Response[T],

core/src/test/scala/sttp/client4/FollowRedirectsBackendTest.scala

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ import org.scalatest.EitherValues
44
import org.scalatest.funsuite.AnyFunSuite
55
import org.scalatest.matchers.should.Matchers
66
import sttp.client4.testing.{BackendStub, ResponseStub}
7-
import sttp.client4.wrappers.{FollowRedirectsBackend, FollowRedirectsConfig}
7+
import sttp.client4.wrappers.{CookieStorage, FollowRedirectsBackend, FollowRedirectsConfig}
88
import sttp.model.internal.Rfc3986
9-
import sttp.model.{Header, StatusCode, Uri}
9+
import sttp.model.{Header, HeaderNames, ResponseMetadata, StatusCode, Uri}
1010

1111
class FollowRedirectsBackendTest extends AnyFunSuite with Matchers with EitherValues {
1212
val testData = List(
@@ -53,4 +53,73 @@ class FollowRedirectsBackendTest extends AnyFunSuite with Matchers with EitherVa
5353
result.body.value shouldBe "All good!"
5454
}
5555

56+
private def cookiesIn(r: GenericRequest[_, _]): Set[String] =
57+
r.header(HeaderNames.Cookie).map(_.split("; ").toSet).getOrElse(Set.empty)
58+
59+
// a redirect chain example.com/0 -> /1 -> ... -> /n, where each hop sets a cookie `c<id>`; records the cookies
60+
// (name=value) seen in the `Cookie` header of each request, by target id
61+
private def redirectChainSettingCookies(n: Int): (SyncBackend, collection.Map[Int, Set[String]]) = {
62+
def url(id: Int) = uri"https://example.com/$id"
63+
val seen = scala.collection.mutable.Map[Int, Set[String]]()
64+
val stub = BackendStub.synchronous.whenRequestMatchesPartial {
65+
case r if r.uri.host.contains("example.com") =>
66+
val id = r.uri.path.last.toInt
67+
seen(id) = cookiesIn(r)
68+
if (id < n)
69+
ResponseStub.adjust(
70+
"",
71+
StatusCode.Found,
72+
Vector(Header.location(url(id + 1)), Header(HeaderNames.SetCookie, s"c$id=$id"))
73+
)
74+
else ResponseStub.adjust("done", StatusCode.Ok)
75+
}
76+
(FollowRedirectsBackend(stub), seen)
77+
}
78+
79+
test("should send cookies set during a redirect chain to subsequent requests when a cookie storage is attached") {
80+
val (backend, seen) = redirectChainSettingCookies(3)
81+
82+
val result = basicRequest.get(uri"https://example.com/0").cookieStorage(CookieStorage.empty).send(backend)
83+
84+
result.code shouldBe StatusCode.Ok
85+
seen(0) shouldBe empty // nothing set yet
86+
seen(1) shouldBe Set("c0=0")
87+
seen(2) shouldBe Set("c0=0", "c1=1")
88+
seen(3) shouldBe Set("c0=0", "c1=1", "c2=2")
89+
}
90+
91+
test("should not carry cookies across redirects when no cookie storage is attached") {
92+
val (backend, seen) = redirectChainSettingCookies(3)
93+
94+
val result = basicRequest.get(uri"https://example.com/0").send(backend)
95+
96+
result.code shouldBe StatusCode.Ok
97+
seen.values.foreach(_ shouldBe empty)
98+
}
99+
100+
test("should harvest cookies from a redirect that a backend signals by throwing") {
101+
var atTarget = Set.empty[String]
102+
val stub = BackendStub.synchronous.whenRequestMatchesPartial {
103+
// first hop: signal the redirect (with a Set-Cookie) by throwing, as some backends do
104+
case r if r.uri == uri"https://example.com/0" =>
105+
throw ResponseException.UnexpectedStatusCode(
106+
"",
107+
ResponseMetadata(
108+
StatusCode.Found,
109+
"",
110+
Vector(Header.location(uri"https://example.com/1"), Header(HeaderNames.SetCookie, "s=1"))
111+
)
112+
)
113+
case r if r.uri == uri"https://example.com/1" =>
114+
atTarget = cookiesIn(r)
115+
ResponseStub.adjust("done", StatusCode.Ok)
116+
}
117+
val backend = FollowRedirectsBackend(stub)
118+
119+
val result = basicRequest.get(uri"https://example.com/0").cookieStorage(CookieStorage.empty).send(backend)
120+
121+
result.code shouldBe StatusCode.Ok
122+
atTarget shouldBe Set("s=1")
123+
}
124+
56125
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package sttp.client4.wrappers
2+
3+
import org.scalatest.funsuite.AnyFunSuite
4+
import org.scalatest.matchers.should.Matchers
5+
import sttp.client4._
6+
7+
class CookieStorageTest extends AnyFunSuite with Matchers {
8+
private def names(cs: Seq[(String, String)]) = cs.map(_._1).toSet
9+
10+
test("stores a host-only cookie and sends it back to the same host") {
11+
val storage = CookieStorage.empty.setFromSetCookieHeaders(uri"https://example.com/a", List("s=1"))
12+
names(storage.cookiesFor(uri"https://example.com/b")) shouldBe Set("s")
13+
}
14+
15+
test("does not send a host-only cookie to a subdomain") {
16+
val storage = CookieStorage.empty.setFromSetCookieHeaders(uri"https://example.com/", List("s=1"))
17+
storage.cookiesFor(uri"https://sub.example.com/") shouldBe empty
18+
}
19+
20+
test("sends a domain cookie to matching subdomains, but not to unrelated domains") {
21+
val storage = CookieStorage.empty.setFromSetCookieHeaders(uri"https://example.com/", List("s=1; Domain=example.com"))
22+
names(storage.cookiesFor(uri"https://sub.example.com/")) shouldBe Set("s")
23+
storage.cookiesFor(uri"https://other.com/") shouldBe empty
24+
}
25+
26+
test("rejects a cookie whose domain does not match the setting host") {
27+
val storage = CookieStorage.empty.setFromSetCookieHeaders(uri"https://example.com/", List("s=1; Domain=evil.com"))
28+
storage.isEmpty shouldBe true
29+
}
30+
31+
test("normalizes the cookie domain (leading dot, case)") {
32+
val storage =
33+
CookieStorage.empty.setFromSetCookieHeaders(uri"https://example.com/", List("s=1; Domain=.EXAMPLE.com"))
34+
names(storage.cookiesFor(uri"https://sub.example.com/")) shouldBe Set("s")
35+
}
36+
37+
test("respects the cookie path, requiring a path-segment boundary") {
38+
val storage = CookieStorage.empty.setFromSetCookieHeaders(uri"https://example.com/admin", List("s=1; Path=/admin"))
39+
names(storage.cookiesFor(uri"https://example.com/admin/x")) shouldBe Set("s")
40+
storage.cookiesFor(uri"https://example.com/administrator") shouldBe empty // prefix, but not a segment boundary
41+
storage.cookiesFor(uri"https://example.com/public") shouldBe empty
42+
}
43+
44+
test("defaults a path-less cookie to the setting request's directory (RFC 6265 5.1.4)") {
45+
val storage = CookieStorage.empty.setFromSetCookieHeaders(uri"https://example.com/admin/page", List("s=1"))
46+
names(storage.cookiesFor(uri"https://example.com/admin/other")) shouldBe Set("s")
47+
storage.cookiesFor(uri"https://example.com/elsewhere") shouldBe empty
48+
}
49+
50+
test("does not send a secure cookie over http") {
51+
val storage = CookieStorage.empty.setFromSetCookieHeaders(uri"https://example.com/", List("s=1; Secure"))
52+
storage.cookiesFor(uri"http://example.com/") shouldBe empty
53+
names(storage.cookiesFor(uri"https://example.com/")) shouldBe Set("s")
54+
}
55+
56+
test("a later cookie with the same name/domain/path overwrites the earlier one") {
57+
val storage = CookieStorage.empty
58+
.setFromSetCookieHeaders(uri"https://example.com/", List("s=1"))
59+
.setFromSetCookieHeaders(uri"https://example.com/", List("s=2"))
60+
storage.cookiesFor(uri"https://example.com/") shouldBe Seq("s" -> "2")
61+
}
62+
63+
test("a cookie with Max-Age <= 0 removes a matching stored cookie") {
64+
val storage = CookieStorage.empty
65+
.setFromSetCookieHeaders(uri"https://example.com/", List("s=1"))
66+
.setFromSetCookieHeaders(uri"https://example.com/", List("s=; Max-Age=0"))
67+
storage.isEmpty shouldBe true
68+
}
69+
}

0 commit comments

Comments
 (0)