Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions docs/03-how-to-use-session-affinity.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,38 @@ for the old non-partitioned `__VCAP_ID__` cookie alongside the new partitioned o

<img src="images/sticky_sessions_chips_migration.png" alt="Sticky Sessions - CHIPS migration sequence" width="800">

### Does Gorouter support `__Host-` prefixed session cookies?
Yes. [RFC 6265bis](https://datatracker.ietf.org/doc/draft-ietf-httpbis-rfc6265bis/) defines
the `__Host-` cookie prefix, which instructs browsers to enforce additional security constraints
(the cookie must be `Secure`, must not specify a `Domain`, and the `Path` must be `/`).

Gorouter recognises cookies that use the exact `__Host-` prefix (case-sensitive, matching the
canonical casing mandated by the RFC) in front of a configured sticky session cookie name. For
example, if `JSESSIONID` is configured as a sticky session cookie name, Gorouter will also
recognise `__Host-JSESSIONID` as a sticky session cookie — both in application responses (to
create the `__VCAP_ID__` + `__VCAP_ID_META__` pair) and in client requests (to route to the
sticky backend).

No additional configuration is required; the `__Host-` prefix is handled automatically for every
name listed in `router.sticky_session_cookie_names`.

### What happens when both `JSESSIONID` and `__Host-JSESSIONID` are in the same response?
Gorouter creates a `__VCAP_ID__` + `__VCAP_ID_META__` pair for each session cookie — the same
behaviour as [CHIPS migration](#how-does-gorouter-support-chips-cookie-migration). Since both
`__VCAP_ID__` cookies share the same name and (unless one is `Partitioned`) the same browser
cookie jar slot, the browser will only retain the last one.

In practice this is not a concern: unlike CHIPS migration, there is no need to set both cookies in
the same response. Because `JSESSIONID` and `__Host-JSESSIONID` are distinct cookie names in the
browser's jar, the expected migration path is for the application to simply stop setting
`JSESSIONID` and start setting `__Host-JSESSIONID` — the old cookie expires naturally.

Note: if an application were to set a new `__Host-JSESSIONID` alongside a delete (`Max-Age=0`) for
the old `JSESSIONID` in the same response, both would produce a `__VCAP_ID__` in the same cookie
jar partition. Depending on processing order, the browser could apply the delete `__VCAP_ID__`
after the new one, effectively removing it. Developers should therefore avoid setting both cookies
in the same response to prevent temporarily losing session stickiness.

### What happens if only one of `JSESSIONID` or `__VCAP_ID__` cookies is set on a request?
Gorouter requires both `JSESSIONID` and `__VCAP_ID__` to be present for sticky session routing.
If only one of them is present, Gorouter will route the request to a random available application
Expand Down
9 changes: 6 additions & 3 deletions src/code.cloudfoundry.org/gorouter/handlers/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,12 @@ func GetStickySession(request *http.Request, stickySessionCookieNames config.Str
}
}
}
// Try choosing a backend using sticky session
for stickyCookieName := range stickySessionCookieNames {
if _, err := request.Cookie(stickyCookieName); err == nil {

// Try choosing a backend using sticky session.
// Also match the "__Host-" prefixed variant of each configured cookie name (RFC 6265bis).
for _, c := range request.Cookies() {
name := strings.TrimPrefix(c.Name, "__Host-")
if _, ok := stickySessionCookieNames[name]; ok {
if sticky, err := request.Cookie(VcapCookieId); err == nil {
return sticky.Value, false
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -597,13 +597,21 @@ func getSessionCookies(response *http.Response, stickySessionCookieNames config.
if cookie.Name == VcapCookieId {
return nil, cookie
}
if _, ok := stickySessionCookieNames[cookie.Name]; ok {
if IsSessionCookie(cookie.Name, stickySessionCookieNames) {
sessionCookies = append(sessionCookies, cookie)
}
}
return sessionCookies, nil
}

// IsSessionCookie reports whether cookieName matches a configured sticky session cookie name,
// either directly or after stripping the "__Host-" prefix (RFC 6265bis).
func IsSessionCookie(cookieName string, stickySessionCookieNames config.StringSet) bool {
name := strings.TrimPrefix(cookieName, "__Host-")
_, ok := stickySessionCookieNames[name]
return ok
}

Comment thread
a18e marked this conversation as resolved.
// getAttributesFromMetaCookie returns the __VCAP_ID_META__ cookie from the request cookies, when it exists
func getAttributesFromMetaCookie(cookies []*http.Cookie, logger *slog.Logger) *vcapAttributes {
for _, c := range cookies {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1252,6 +1252,34 @@ var _ = Describe("ProxyRoundTripper", func() {
}
}

Describe("isSessionCookie", func() {
It("matches an exact cookie name", func() {
Expect(round_tripper.IsSessionCookie("JSESSIONID", cfg.StickySessionCookieNames)).To(BeTrue())
})

It("matches a __Host- prefixed cookie name", func() {
Expect(round_tripper.IsSessionCookie("__Host-JSESSIONID", cfg.StickySessionCookieNames)).To(BeTrue())
})

It("does not match an unknown cookie name", func() {
Expect(round_tripper.IsSessionCookie("UNKNOWN", cfg.StickySessionCookieNames)).To(BeFalse())
})

It("does not match a __Host- prefixed unknown cookie name", func() {
Expect(round_tripper.IsSessionCookie("__Host-UNKNOWN", cfg.StickySessionCookieNames)).To(BeFalse())
})

It("does not match partial prefix like __Host without dash", func() {
Expect(round_tripper.IsSessionCookie("__HostJSESSIONID", cfg.StickySessionCookieNames)).To(BeFalse())
})

It("does not match other casings of the __Host- prefix", func() {
Expect(round_tripper.IsSessionCookie("__HOST-JSESSIONID", cfg.StickySessionCookieNames)).To(BeFalse())
Expect(round_tripper.IsSessionCookie("__host-JSESSIONID", cfg.StickySessionCookieNames)).To(BeFalse())
Expect(round_tripper.IsSessionCookie("__HoSt-JSESSIONID", cfg.StickySessionCookieNames)).To(BeFalse())
})
})

Context("Early Return: when the backend already sets VCAP_ID on the response", func() {
// Gorouter must never overwrite a __VCAP_ID__ cookie that the backend sets itself.
// This is an early-return guard in setupStickySession that applies regardless of
Expand Down Expand Up @@ -1930,9 +1958,216 @@ var _ = Describe("ProxyRoundTripper", func() {
})
})

Context("when a __Host- prefixed session cookie is on the response", func() {
Context("when the response contains __Host-JSESSIONID", func() {
BeforeEach(func() {
transport.RoundTripStub = func(req *http.Request) (*http.Response, error) {
resp := &http.Response{StatusCode: http.StatusTeapot, Header: make(map[string][]string)}

hostCookie := &http.Cookie{
Name: "__Host-" + StickyCookieKey,
Value: "host-session-value",
Secure: true,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
}
resp.Header.Add(round_tripper.CookieHeader, hostCookie.String())

return resp, nil
}
})

It("recognizes the cookie and adds VCAP_ID and VCAP_ID_META", func() {
resp, err := proxyRoundTripper.RoundTrip(req)
Expect(err).ToNot(HaveOccurred())

cookies := resp.Cookies()
// __Host-JSESSIONID + VCAP_ID + VCAP_ID_META = 3
Expect(cookies).To(HaveLen(3))

Expect(cookies[0].Name).To(Equal("__Host-" + StickyCookieKey))

expectVcapIdCookie(cookies[1], "id-1", "id-2")
Expect(cookies[1].Secure).To(BeTrue())
Expect(cookies[1].SameSite).To(Equal(http.SameSiteStrictMode))

expectMetaCookie(cookies[2], func(value string) {
Expect(value).To(ContainSubstring("secure"))
})
})
})

Context("when the response contains both JSESSIONID and __Host-JSESSIONID", func() {
BeforeEach(func() {
transport.RoundTripStub = func(req *http.Request) (*http.Response, error) {
resp := &http.Response{StatusCode: http.StatusTeapot, Header: make(map[string][]string)}

plainCookie := &http.Cookie{
Name: StickyCookieKey,
Value: "plain-session",
HttpOnly: true,
}
resp.Header.Add(round_tripper.CookieHeader, plainCookie.String())

hostCookie := &http.Cookie{
Name: "__Host-" + StickyCookieKey,
Value: "host-session",
Secure: true,
HttpOnly: true,
SameSite: http.SameSiteNoneMode,
}
resp.Header.Add(round_tripper.CookieHeader, hostCookie.String())

return resp, nil
}
})

It("creates a VCAP_ID + META pair for each session cookie", func() {
resp, err := proxyRoundTripper.RoundTrip(req)
Expect(err).ToNot(HaveOccurred())

cookies := resp.Cookies()
// 2x session cookie + 2x VCAP_ID + 2x VCAP_ID_META = 6
Expect(cookies).To(HaveLen(6))

Expect(cookies[0].Name).To(Equal(StickyCookieKey))
Expect(cookies[1].Name).To(Equal("__Host-" + StickyCookieKey))

// First VCAP_ID — from plain JSESSIONID (not Secure)
expectVcapIdCookie(cookies[2], "id-1", "id-2")
Expect(cookies[2].Secure).To(BeFalse())

// First META
expectMetaCookie(cookies[3], nil)

// Second VCAP_ID — from __Host-JSESSIONID (Secure, SameSite=None)
expectVcapIdCookie(cookies[4], "id-1", "id-2")
Expect(cookies[4].Secure).To(BeTrue())
Expect(cookies[4].SameSite).To(Equal(http.SameSiteNoneMode))

// Second META
expectMetaCookie(cookies[5], func(value string) {
Expect(value).To(ContainSubstring("secure"))
})
})
})

Context("when SecureCookies is enforced with __Host-JSESSIONID", func() {
BeforeEach(func() {
cfg.SecureCookies = true
transport.RoundTripStub = func(req *http.Request) (*http.Response, error) {
resp := &http.Response{StatusCode: http.StatusTeapot, Header: make(map[string][]string)}

hostCookie := &http.Cookie{
Name: "__Host-" + StickyCookieKey,
Value: "host-session",
HttpOnly: true,
}
resp.Header.Add(round_tripper.CookieHeader, hostCookie.String())

return resp, nil
}
})

It("sets Secure on VCAP_ID and VCAP_ID_META", func() {
resp, err := proxyRoundTripper.RoundTrip(req)
Expect(err).ToNot(HaveOccurred())

cookies := resp.Cookies()
Expect(cookies).To(HaveLen(3))

Expect(cookies[1].Name).To(Equal(round_tripper.VcapCookieId))
Expect(cookies[1].Secure).To(BeTrue())
Expect(cookies[2].Name).To(Equal(round_tripper.VcapMetaCookieId))
Expect(cookies[2].Secure).To(BeTrue())
})
})
})

Context("when there is a JSESSIONID and a VCAP_ID on the response", func() {
BeforeEach(func() {
transport.RoundTripStub = responseContainsJSESSIONIDAndVCAPID
})

It("leaves VCAP_ID alone and does not overwrite it", func() {
resp, err := proxyRoundTripper.RoundTrip(req)
Expect(err).ToNot(HaveOccurred())

cookies := resp.Cookies()
Expect(cookies).To(HaveLen(2))
Expect(cookies[0].Raw).To(Equal(sessionCookie.String()))
Expect(cookies[1].Name).To(Equal(round_tripper.VcapCookieId))
Expect(cookies[1].Value).To(Equal("vcap-id-property-already-on-the-response"))
})
})

Context("when there is only a VCAP_ID set on the response", func() {
BeforeEach(func() {
transport.RoundTripStub = responseContainsVCAPID
})

It("leaves VCAP_ID alone and does not overwrite it", func() {
resp, err := proxyRoundTripper.RoundTrip(req)
Expect(err).ToNot(HaveOccurred())

cookies := resp.Cookies()
Expect(cookies).To(HaveLen(1))
Expect(cookies[0].Name).To(Equal(round_tripper.VcapCookieId))
Expect(cookies[0].Value).To(Equal("vcap-id-property-already-on-the-response"))
})
})

})

Context("Existing Session Scenario", func() {
Context("when the request contains a __Host- prefixed session cookie", func() {
JustBeforeEach(func() {
// First request: app responds with __Host-JSESSIONID
transport.RoundTripStub = func(req *http.Request) (*http.Response, error) {
resp := &http.Response{StatusCode: http.StatusTeapot, Header: make(map[string][]string)}
hostCookie := &http.Cookie{
Name: "__Host-" + StickyCookieKey,
Value: "host-session-value",
Secure: true,
HttpOnly: true,
}
resp.Header.Add(round_tripper.CookieHeader, hostCookie.String())
return resp, nil
}
resp, err := proxyRoundTripper.RoundTrip(req)
Expect(err).ToNot(HaveOccurred())

firstCookies := resp.Cookies()
Expect(firstCookies).To(HaveLen(3)) // __Host-JSESSIONID + VCAP_ID + VCAP_ID_META
for _, cookie := range firstCookies {
req.AddCookie(cookie)
}
})

It("recognizes the __Host- prefixed cookie for sticky routing and refreshes VCAP_ID", func() {
transport.RoundTripStub = func(req *http.Request) (*http.Response, error) {
resp := &http.Response{StatusCode: http.StatusTeapot, Header: make(map[string][]string)}
hostCookie := &http.Cookie{
Name: "__Host-" + StickyCookieKey,
Value: "host-session-value-refreshed",
Secure: true,
HttpOnly: true,
}
resp.Header.Add(round_tripper.CookieHeader, hostCookie.String())
return resp, nil
}

resp, err := proxyRoundTripper.RoundTrip(req)
Expect(err).ToNot(HaveOccurred())

cookies := resp.Cookies()
Expect(cookies).To(HaveLen(3)) // __Host-JSESSIONID + VCAP_ID + VCAP_ID_META

Expect(cookies[0].Name).To(Equal("__Host-" + StickyCookieKey))
expectVcapIdCookie(cookies[1])
expectMetaCookie(cookies[2], nil)
})
})

Context("when the sticky endpoint still exists (no stale session)", func() {
var firstCookies []*http.Cookie
Expand Down