diff --git a/docs/03-how-to-use-session-affinity.md b/docs/03-how-to-use-session-affinity.md index ba2aadc02..7ff709eba 100644 --- a/docs/03-how-to-use-session-affinity.md +++ b/docs/03-how-to-use-session-affinity.md @@ -106,6 +106,38 @@ 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://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 diff --git a/src/code.cloudfoundry.org/gorouter/handlers/helpers.go b/src/code.cloudfoundry.org/gorouter/handlers/helpers.go index 6af350000..eabbaefa0 100644 --- a/src/code.cloudfoundry.org/gorouter/handlers/helpers.go +++ b/src/code.cloudfoundry.org/gorouter/handlers/helpers.go @@ -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 } 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..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 @@ -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 +} + // 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