From f4f80f6bc2bf31522fa12682aa950a06b310ce42 Mon Sep 17 00:00:00 2001 From: Clemens Hoffmann Date: Wed, 25 Mar 2026 15:49:07 +0100 Subject: [PATCH 1/3] feat: Implement support for __HOST- prefixed session cookies --- docs/03-how-to-use-session-affinity.md | 15 ++ .../gorouter/handlers/helpers.go | 12 +- .../round_tripper/proxy_round_tripper.go | 15 +- .../round_tripper/proxy_round_tripper_test.go | 235 ++++++++++++++++++ 4 files changed, 273 insertions(+), 4 deletions(-) diff --git a/docs/03-how-to-use-session-affinity.md b/docs/03-how-to-use-session-affinity.md index ba2aadc02..bb885b1d8 100644 --- a/docs/03-how-to-use-session-affinity.md +++ b/docs/03-how-to-use-session-affinity.md @@ -106,6 +106,21 @@ for the old non-partitioned `__VCAP_ID__` cookie alongside the new partitioned o Sticky Sessions - CHIPS migration sequence +### Does Gorouter support `__Host-` prefixed session cookies? +Yes. [RFC 6265bis](https://www.rfc-editor.org/rfc/draft-ietf-httpbis-rfc6265bis-19.html#name-the-__host-prefix) 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 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 diff --git a/src/code.cloudfoundry.org/gorouter/handlers/helpers.go b/src/code.cloudfoundry.org/gorouter/handlers/helpers.go index 6af350000..e15a46724 100644 --- a/src/code.cloudfoundry.org/gorouter/handlers/helpers.go +++ b/src/code.cloudfoundry.org/gorouter/handlers/helpers.go @@ -87,9 +87,15 @@ 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 := c.Name + if strings.HasPrefix(name, "__Host-") { + name = name[7:] + } + if _, ok := stickySessionCookieNames[name]; ok { if sticky, err := request.Cookie(VcapCookieId); err == nil { return sticky.Value, false } diff --git a/src/code.cloudfoundry.org/gorouter/proxy/round_tripper/proxy_round_tripper.go b/src/code.cloudfoundry.org/gorouter/proxy/round_tripper/proxy_round_tripper.go index 76dddba98..61bbe23ca 100644 --- a/src/code.cloudfoundry.org/gorouter/proxy/round_tripper/proxy_round_tripper.go +++ b/src/code.cloudfoundry.org/gorouter/proxy/round_tripper/proxy_round_tripper.go @@ -597,13 +597,26 @@ 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 { + if _, ok := stickySessionCookieNames[cookieName]; ok { + return true + } + if strings.HasPrefix(cookieName, "__Host-") { + _, ok := stickySessionCookieNames[cookieName[7:]] + return ok + } + return false +} + // 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 { diff --git a/src/code.cloudfoundry.org/gorouter/proxy/round_tripper/proxy_round_tripper_test.go b/src/code.cloudfoundry.org/gorouter/proxy/round_tripper/proxy_round_tripper_test.go index 3ab6b8c65..e4187b506 100644 --- a/src/code.cloudfoundry.org/gorouter/proxy/round_tripper/proxy_round_tripper_test.go +++ b/src/code.cloudfoundry.org/gorouter/proxy/round_tripper/proxy_round_tripper_test.go @@ -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 @@ -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 From fe7df1d3b1b086a429e67f7cdc684ab5ffe037d7 Mon Sep 17 00:00:00 2001 From: Clemens Hoffmann Date: Tue, 31 Mar 2026 15:54:12 +0200 Subject: [PATCH 2/3] docs: Add FAQ entry for __Host- prefixed session cookie behavior --- docs/03-how-to-use-session-affinity.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/03-how-to-use-session-affinity.md b/docs/03-how-to-use-session-affinity.md index bb885b1d8..96ed12c1e 100644 --- a/docs/03-how-to-use-session-affinity.md +++ b/docs/03-how-to-use-session-affinity.md @@ -121,6 +121,22 @@ 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. + ### 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 From 9cc5e5cc3682db31f3fe98647f85bf552e37c174 Mon Sep 17 00:00:00 2001 From: Clemens Hoffmann Date: Fri, 10 Apr 2026 16:31:35 +0200 Subject: [PATCH 3/3] Adress review comments --- docs/03-how-to-use-session-affinity.md | 5 +++-- .../gorouter/handlers/helpers.go | 5 +---- .../proxy/round_tripper/proxy_round_tripper.go | 11 +++-------- 3 files changed, 7 insertions(+), 14 deletions(-) diff --git a/docs/03-how-to-use-session-affinity.md b/docs/03-how-to-use-session-affinity.md index 96ed12c1e..7ff709eba 100644 --- a/docs/03-how-to-use-session-affinity.md +++ b/docs/03-how-to-use-session-affinity.md @@ -107,7 +107,7 @@ for the old non-partitioned `__VCAP_ID__` cookie alongside the new partitioned o Sticky Sessions - CHIPS migration sequence ### Does Gorouter support `__Host-` prefixed session cookies? -Yes. [RFC 6265bis](https://www.rfc-editor.org/rfc/draft-ietf-httpbis-rfc6265bis-19.html#name-the-__host-prefix) defines +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 `/`). @@ -135,7 +135,8 @@ browser's jar, the expected migration path is for the application to simply stop 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. +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. diff --git a/src/code.cloudfoundry.org/gorouter/handlers/helpers.go b/src/code.cloudfoundry.org/gorouter/handlers/helpers.go index e15a46724..eabbaefa0 100644 --- a/src/code.cloudfoundry.org/gorouter/handlers/helpers.go +++ b/src/code.cloudfoundry.org/gorouter/handlers/helpers.go @@ -91,10 +91,7 @@ func GetStickySession(request *http.Request, stickySessionCookieNames config.Str // 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 := c.Name - if strings.HasPrefix(name, "__Host-") { - name = name[7:] - } + name := strings.TrimPrefix(c.Name, "__Host-") if _, ok := stickySessionCookieNames[name]; ok { if sticky, err := request.Cookie(VcapCookieId); err == nil { return sticky.Value, false diff --git a/src/code.cloudfoundry.org/gorouter/proxy/round_tripper/proxy_round_tripper.go b/src/code.cloudfoundry.org/gorouter/proxy/round_tripper/proxy_round_tripper.go index 61bbe23ca..cbe223136 100644 --- a/src/code.cloudfoundry.org/gorouter/proxy/round_tripper/proxy_round_tripper.go +++ b/src/code.cloudfoundry.org/gorouter/proxy/round_tripper/proxy_round_tripper.go @@ -607,14 +607,9 @@ func getSessionCookies(response *http.Response, stickySessionCookieNames config. // 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 { - if _, ok := stickySessionCookieNames[cookieName]; ok { - return true - } - if strings.HasPrefix(cookieName, "__Host-") { - _, ok := stickySessionCookieNames[cookieName[7:]] - return ok - } - return false + name := strings.TrimPrefix(cookieName, "__Host-") + _, ok := stickySessionCookieNames[name] + return ok } // getAttributesFromMetaCookie returns the __VCAP_ID_META__ cookie from the request cookies, when it exists