diff --git a/deployments/common/crds/k8s.nginx.org_virtualserverroutes.yaml b/deployments/common/crds/k8s.nginx.org_virtualserverroutes.yaml index 4584c3e8dc..679df933c3 100644 --- a/deployments/common/crds/k8s.nginx.org_virtualserverroutes.yaml +++ b/deployments/common/crds/k8s.nginx.org_virtualserverroutes.yaml @@ -588,6 +588,8 @@ spec: type: string path: type: string + samesite: + type: string secure: type: boolean slow-start: diff --git a/deployments/common/crds/k8s.nginx.org_virtualservers.yaml b/deployments/common/crds/k8s.nginx.org_virtualservers.yaml index 8b06969a91..e15e804297 100644 --- a/deployments/common/crds/k8s.nginx.org_virtualservers.yaml +++ b/deployments/common/crds/k8s.nginx.org_virtualservers.yaml @@ -675,6 +675,8 @@ spec: type: string path: type: string + samesite: + type: string secure: type: boolean slow-start: diff --git a/deployments/helm-chart/crds/k8s.nginx.org_virtualserverroutes.yaml b/deployments/helm-chart/crds/k8s.nginx.org_virtualserverroutes.yaml index 4584c3e8dc..679df933c3 100644 --- a/deployments/helm-chart/crds/k8s.nginx.org_virtualserverroutes.yaml +++ b/deployments/helm-chart/crds/k8s.nginx.org_virtualserverroutes.yaml @@ -588,6 +588,8 @@ spec: type: string path: type: string + samesite: + type: string secure: type: boolean slow-start: diff --git a/deployments/helm-chart/crds/k8s.nginx.org_virtualservers.yaml b/deployments/helm-chart/crds/k8s.nginx.org_virtualservers.yaml index 8b06969a91..e15e804297 100644 --- a/deployments/helm-chart/crds/k8s.nginx.org_virtualservers.yaml +++ b/deployments/helm-chart/crds/k8s.nginx.org_virtualservers.yaml @@ -675,6 +675,8 @@ spec: type: string path: type: string + samesite: + type: string secure: type: boolean slow-start: diff --git a/docs/content/configuration/virtualserver-and-virtualserverroute-resources.md b/docs/content/configuration/virtualserver-and-virtualserverroute-resources.md index c5453dcdd0..0134d0b716 100644 --- a/docs/content/configuration/virtualserver-and-virtualserverroute-resources.md +++ b/docs/content/configuration/virtualserver-and-virtualserverroute-resources.md @@ -460,6 +460,7 @@ sessionCookie: domain: .example.com httpOnly: false secure: true + samesite: strict ``` See the [`sticky`](https://nginx.org/en/docs/http/ngx_http_upstream_module.html?#sticky) directive for additional information. The session cookie corresponds to the `sticky cookie` method. @@ -475,6 +476,7 @@ Note: This feature is supported only in NGINX Plus. |``domain`` | The domain for which the cookie is set. | ``string`` | No | |``httpOnly`` | Adds the ``HttpOnly`` attribute to the cookie. | ``boolean`` | No | |``secure`` | Adds the ``Secure`` attribute to the cookie. | ``boolean`` | No | +|``samesite`` | Adds the ``SameSite`` attribute to the cookie. The allowed values are: ``strict``, ``lax``, ``none`` | ``string`` | No | {{% /table %}} ### Header diff --git a/internal/configs/version2/http.go b/internal/configs/version2/http.go index 2a8e5c934d..daa83f9dd9 100644 --- a/internal/configs/version2/http.go +++ b/internal/configs/version2/http.go @@ -283,6 +283,7 @@ type SessionCookie struct { Domain string HTTPOnly bool Secure bool + SameSite string } // Distribution maps weight to a value in a SplitClient. diff --git a/internal/configs/version2/nginx-plus.virtualserver.tmpl b/internal/configs/version2/nginx-plus.virtualserver.tmpl index dc2ebf1b81..c4c4b0e1f3 100644 --- a/internal/configs/version2/nginx-plus.virtualserver.tmpl +++ b/internal/configs/version2/nginx-plus.virtualserver.tmpl @@ -18,7 +18,7 @@ upstream {{ $u.Name }} { {{ with $u.SessionCookie }} {{ if .Enable }} - sticky cookie {{ .Name }}{{ if .Expires }} expires={{ .Expires }}{{ end }}{{ if .Domain }} domain={{ .Domain }}{{ end }}{{ if .HTTPOnly }} httponly{{ end }}{{ if .Secure }} secure{{ end }}{{ if .Path }} path={{ .Path }}{{ end }}; + sticky cookie {{ .Name }}{{ if .Expires }} expires={{ .Expires }}{{ end }}{{ if .Domain }} domain={{ .Domain }}{{ end }}{{ if .HTTPOnly }} httponly{{ end }}{{ if .SameSite}} samesite={{.SameSite | toLower }}{{ end }}{{ if .Secure }} secure{{ end }}{{ if .Path }} path={{ .Path }}{{ end }}; {{ end }} {{ end }} diff --git a/internal/configs/version2/template_helper.go b/internal/configs/version2/template_helper.go index 35c31a4f71..86767fa6ae 100644 --- a/internal/configs/version2/template_helper.go +++ b/internal/configs/version2/template_helper.go @@ -20,7 +20,17 @@ func hasCIKey(key string, d map[string]string) bool { return ok } +// toLower takes a string and make it lowercase. +// +// Example: +// +// {{ if .SameSite}} samesite={{.SameSite | toLower }}{{ end }} +func toLower(s string) string { + return strings.ToLower(s) +} + var helperFunctions = template.FuncMap{ "headerListToCIMap": headerListToCIMap, "hasCIKey": hasCIKey, + "toLower": toLower, } diff --git a/internal/configs/version2/templates_test.go b/internal/configs/version2/templates_test.go index f56315880d..4e5fca873a 100644 --- a/internal/configs/version2/templates_test.go +++ b/internal/configs/version2/templates_test.go @@ -77,6 +77,22 @@ func TestExecuteVirtualServerTemplate_RendersTemplateWithServerGunzipNotSet(t *t t.Log(string(got)) } +func TestExecuteVirtualServerTemplate_RendersTemplateWithSessionCookieSameSite(t *testing.T) { + t.Parallel() + executor, err := NewTemplateExecutor(nginxPlusVirtualServerTmpl, nginxPlusTransportServerTmpl) + if err != nil { + t.Fatal(err) + } + got, err := executor.ExecuteVirtualServerTemplate(&virtualServerCfgWithSessionCookieSameSite) + if err != nil { + t.Error(err) + } + if !bytes.Contains(got, []byte("samesite=strict")) { + t.Error("want `samesite=strict` in generated template") + } + t.Log(string(got)) +} + func TestVirtualServerForNginxPlusWithWAFApBundle(t *testing.T) { t.Parallel() executor, err := NewTemplateExecutor(nginxPlusVirtualServerTmpl, nginxPlusTransportServerTmpl) @@ -1890,6 +1906,358 @@ var ( }, } + virtualServerCfgWithSessionCookieSameSite = VirtualServerConfig{ + LimitReqZones: []LimitReqZone{ + { + ZoneName: "pol_rl_test_test_test", Rate: "10r/s", ZoneSize: "10m", Key: "$url", + }, + }, + Upstreams: []Upstream{ + { + Name: "test-upstream", + Servers: []UpstreamServer{ + { + Address: "10.0.0.20:8001", + }, + }, + LBMethod: "random", + Keepalive: 32, + MaxFails: 4, + FailTimeout: "10s", + MaxConns: 31, + SlowStart: "10s", + UpstreamZoneSize: "256k", + Queue: &Queue{Size: 10, Timeout: "60s"}, + // SessionCookie set for test: + SessionCookie: &SessionCookie{ + Enable: true, + Name: "test", + Path: "/tea", + Expires: "25s", + SameSite: "STRICT", + }, + NTLM: true, + }, + { + Name: "coffee-v1", + Servers: []UpstreamServer{ + { + Address: "10.0.0.31:8001", + }, + }, + MaxFails: 8, + FailTimeout: "15s", + MaxConns: 2, + UpstreamZoneSize: "256k", + }, + { + Name: "coffee-v2", + Servers: []UpstreamServer{ + { + Address: "10.0.0.32:8001", + }, + }, + MaxFails: 12, + FailTimeout: "20s", + MaxConns: 4, + UpstreamZoneSize: "256k", + }, + }, + SplitClients: []SplitClient{ + { + Source: "$request_id", + Variable: "$split_0", + Distributions: []Distribution{ + { + Weight: "50%", + Value: "@loc0", + }, + { + Weight: "50%", + Value: "@loc1", + }, + }, + }, + }, + Maps: []Map{ + { + Source: "$match_0_0", + Variable: "$match", + Parameters: []Parameter{ + { + Value: "~^1", + Result: "@match_loc_0", + }, + { + Value: "default", + Result: "@match_loc_default", + }, + }, + }, + { + Source: "$http_x_version", + Variable: "$match_0_0", + Parameters: []Parameter{ + { + Value: "v2", + Result: "1", + }, + { + Value: "default", + Result: "0", + }, + }, + }, + }, + HTTPSnippets: []string{"# HTTP snippet"}, + Server: Server{ + ServerName: "example.com", + StatusZone: "example.com", + ProxyProtocol: true, + SSL: &SSL{ + HTTP2: true, + Certificate: "cafe-secret.pem", + CertificateKey: "cafe-secret.pem", + }, + TLSRedirect: &TLSRedirect{ + BasedOn: "$scheme", + Code: 301, + }, + ServerTokens: "off", + SetRealIPFrom: []string{"0.0.0.0/0"}, + RealIPHeader: "X-Real-IP", + RealIPRecursive: true, + Allow: []string{"127.0.0.1"}, + Deny: []string{"127.0.0.1"}, + LimitReqs: []LimitReq{ + { + ZoneName: "pol_rl_test_test_test", + Delay: 10, + Burst: 5, + }, + }, + LimitReqOptions: LimitReqOptions{ + LogLevel: "error", + RejectCode: 503, + }, + JWTAuth: &JWTAuth{ + Realm: "My Api", + Secret: "jwk-secret", + }, + IngressMTLS: &IngressMTLS{ + ClientCert: "ingress-mtls-secret", + VerifyClient: "on", + VerifyDepth: 2, + }, + WAF: &WAF{ + ApPolicy: "/etc/nginx/waf/nac-policies/default-dataguard-alarm", + ApSecurityLogEnable: true, + ApLogConf: []string{"/etc/nginx/waf/nac-logconfs/default-logconf"}, + }, + Snippets: []string{"# server snippet"}, + InternalRedirectLocations: []InternalRedirectLocation{ + { + Path: "/split", + Destination: "@split_0", + }, + { + Path: "/coffee", + Destination: "@match", + }, + }, + HealthChecks: []HealthCheck{ + { + Name: "coffee", + URI: "/", + Interval: "5s", + Jitter: "0s", + Fails: 1, + Passes: 1, + Port: 50, + ProxyPass: "http://coffee-v2", + Mandatory: true, + Persistent: true, + }, + { + Name: "tea", + Interval: "5s", + Jitter: "0s", + Fails: 1, + Passes: 1, + Port: 50, + ProxyPass: "http://tea-v2", + GRPCPass: "grpc://tea-v3", + GRPCStatus: createPointerFromInt(12), + GRPCService: "tea-servicev2", + }, + }, + Locations: []Location{ + { + Path: "/", + Snippets: []string{"# location snippet"}, + Allow: []string{"127.0.0.1"}, + Deny: []string{"127.0.0.1"}, + LimitReqs: []LimitReq{ + { + ZoneName: "loc_pol_rl_test_test_test", + }, + }, + ProxyConnectTimeout: "30s", + ProxyReadTimeout: "31s", + ProxySendTimeout: "32s", + ClientMaxBodySize: "1m", + ProxyBuffering: true, + ProxyBuffers: "8 4k", + ProxyBufferSize: "4k", + ProxyMaxTempFileSize: "1024m", + ProxyPass: "http://test-upstream", + ProxyNextUpstream: "error timeout", + ProxyNextUpstreamTimeout: "5s", + Internal: true, + ProxyPassRequestHeaders: false, + ProxyPassHeaders: []string{"Host"}, + ProxyPassRewrite: "$request_uri", + ProxyHideHeaders: []string{"Header"}, + ProxyIgnoreHeaders: "Cache", + Rewrites: []string{"$request_uri $request_uri", "$request_uri $request_uri"}, + AddHeaders: []AddHeader{ + { + Header: Header{ + Name: "Header-Name", + Value: "Header Value", + }, + Always: true, + }, + }, + EgressMTLS: &EgressMTLS{ + Certificate: "egress-mtls-secret.pem", + CertificateKey: "egress-mtls-secret.pem", + VerifyServer: true, + VerifyDepth: 1, + Ciphers: "DEFAULT", + Protocols: "TLSv1.3", + TrustedCert: "trusted-cert.pem", + SessionReuse: true, + ServerName: true, + }, + }, + { + Path: "@loc0", + ProxyConnectTimeout: "30s", + ProxyReadTimeout: "31s", + ProxySendTimeout: "32s", + ClientMaxBodySize: "1m", + ProxyPass: "http://coffee-v1", + ProxyNextUpstream: "error timeout", + ProxyNextUpstreamTimeout: "5s", + ProxyInterceptErrors: true, + ErrorPages: []ErrorPage{ + { + Name: "@error_page_1", + Codes: "400 500", + ResponseCode: 200, + }, + { + Name: "@error_page_2", + Codes: "500", + ResponseCode: 0, + }, + }, + }, + { + Path: "@loc1", + ProxyConnectTimeout: "30s", + ProxyReadTimeout: "31s", + ProxySendTimeout: "32s", + ClientMaxBodySize: "1m", + ProxyPass: "http://coffee-v2", + ProxyNextUpstream: "error timeout", + ProxyNextUpstreamTimeout: "5s", + }, + { + Path: "@loc2", + ProxyConnectTimeout: "30s", + ProxyReadTimeout: "31s", + ProxySendTimeout: "32s", + ClientMaxBodySize: "1m", + ProxyPass: "http://coffee-v2", + GRPCPass: "grpc://coffee-v3", + }, + { + Path: "@match_loc_0", + ProxyConnectTimeout: "30s", + ProxyReadTimeout: "31s", + ProxySendTimeout: "32s", + ClientMaxBodySize: "1m", + ProxyPass: "http://coffee-v2", + ProxyNextUpstream: "error timeout", + ProxyNextUpstreamTimeout: "5s", + }, + { + Path: "@match_loc_default", + ProxyConnectTimeout: "30s", + ProxyReadTimeout: "31s", + ProxySendTimeout: "32s", + ClientMaxBodySize: "1m", + ProxyPass: "http://coffee-v1", + ProxyNextUpstream: "error timeout", + ProxyNextUpstreamTimeout: "5s", + }, + { + Path: "/return", + ProxyInterceptErrors: true, + ErrorPages: []ErrorPage{ + { + Name: "@return_0", + Codes: "418", + ResponseCode: 200, + }, + }, + InternalProxyPass: "http://unix:/var/lib/nginx/nginx-418-server.sock", + }, + }, + ErrorPageLocations: []ErrorPageLocation{ + { + Name: "@vs_cafe_cafe_vsr_tea_tea_tea__tea_error_page_0", + DefaultType: "application/json", + Return: &Return{ + Code: 200, + Text: "Hello World", + }, + Headers: nil, + }, + { + Name: "@vs_cafe_cafe_vsr_tea_tea_tea__tea_error_page_1", + DefaultType: "", + Return: &Return{ + Code: 200, + Text: "Hello World", + }, + Headers: []Header{ + { + Name: "Set-Cookie", + Value: "cookie1=test", + }, + { + Name: "Set-Cookie", + Value: "cookie2=test; Secure", + }, + }, + }, + }, + ReturnLocations: []ReturnLocation{ + { + Name: "@return_0", + DefaultType: "text/html", + Return: Return{ + Code: 200, + Text: "Hello!", + }, + }, + }, + }, + } + transportServerCfg = TransportServerConfig{ Upstreams: []StreamUpstream{ { diff --git a/internal/configs/virtualserver.go b/internal/configs/virtualserver.go index c399b910d1..a01fe24eaa 100644 --- a/internal/configs/virtualserver.go +++ b/internal/configs/virtualserver.go @@ -1537,6 +1537,7 @@ func generateSessionCookie(sc *conf_v1.SessionCookie) *version2.SessionCookie { Domain: sc.Domain, HTTPOnly: sc.HTTPOnly, Secure: sc.Secure, + SameSite: sc.SameSite, } } diff --git a/internal/configs/virtualserver_test.go b/internal/configs/virtualserver_test.go index 2e40ed2385..8bafb39849 100644 --- a/internal/configs/virtualserver_test.go +++ b/internal/configs/virtualserver_test.go @@ -9308,12 +9308,19 @@ func TestGenerateSessionCookie(t *testing.T) { expected: nil, msg: "session cookie not enabled", }, + { + sc: &conf_v1.SessionCookie{Enable: true, Name: "testcookie", SameSite: "lax"}, + expected: &version2.SessionCookie{Enable: true, Name: "testcookie", SameSite: "lax"}, + msg: "session cookie with samesite param", + }, } for _, test := range tests { - result := generateSessionCookie(test.sc) - if !reflect.DeepEqual(result, test.expected) { - t.Errorf("generateSessionCookie() returned %v, but expected %v for the case of: %v", result, test.expected, test.msg) - } + t.Run(test.msg, func(t *testing.T) { + result := generateSessionCookie(test.sc) + if !cmp.Equal(test.expected, result) { + t.Error(cmp.Diff(test.expected, result)) + } + }) } } diff --git a/pkg/apis/configuration/v1/types.go b/pkg/apis/configuration/v1/types.go index adc519df13..36b857e2d7 100644 --- a/pkg/apis/configuration/v1/types.go +++ b/pkg/apis/configuration/v1/types.go @@ -162,6 +162,7 @@ type SessionCookie struct { Domain string `json:"domain"` HTTPOnly bool `json:"httpOnly"` Secure bool `json:"secure"` + SameSite string `json:"samesite"` } // Route defines a route. diff --git a/pkg/apis/configuration/validation/virtualserver.go b/pkg/apis/configuration/validation/virtualserver.go index 434c49223c..a5f6bb5291 100644 --- a/pkg/apis/configuration/validation/virtualserver.go +++ b/pkg/apis/configuration/validation/virtualserver.go @@ -368,6 +368,9 @@ func validateGrpcStatus(i *int, fieldPath *field.Path) field.ErrorList { return allErrs } +// validateSessionCookie implements validation rules for session cookies. +// +// [Ref.]: https://nginx.org/en/docs/http/ngx_http_upstream_module.html#sticky func validateSessionCookie(sc *v1.SessionCookie, fieldPath *field.Path) field.ErrorList { if sc == nil { return nil @@ -398,6 +401,13 @@ func validateSessionCookie(sc *v1.SessionCookie, fieldPath *field.Path) field.Er } } + if sc.SameSite != "" { + switch strings.ToLower(sc.SameSite) { + case "strict", "lax", "none": + default: + allErrs = append(allErrs, field.Invalid(fieldPath.Child("samesite"), sc.SameSite, "must be one of: `strict`, `lax`, `none`")) + } + } return allErrs } diff --git a/pkg/apis/configuration/validation/virtualserver_test.go b/pkg/apis/configuration/validation/virtualserver_test.go index a694fa76d2..60e7470706 100644 --- a/pkg/apis/configuration/validation/virtualserver_test.go +++ b/pkg/apis/configuration/validation/virtualserver_test.go @@ -1823,19 +1823,8 @@ func TestValidateConditionFails(t *testing.T) { } } -func TestIsCookieName(t *testing.T) { +func TestIsCookieName_ErrorsOnInvalidInput(t *testing.T) { t.Parallel() - validCookieNames := []string{ - "123", - "my_cookie", - } - - for _, name := range validCookieNames { - errs := isCookieName(name) - if len(errs) > 0 { - t.Errorf("isCookieName(%q) returned errors %v for valid input", name, errs) - } - } invalidCookieNames := []string{ "", @@ -1851,6 +1840,22 @@ func TestIsCookieName(t *testing.T) { } } +func TestIsCookieName_IsValidOnValidInput(t *testing.T) { + t.Parallel() + + validCookieNames := []string{ + "123", + "my_cookie", + } + + for _, name := range validCookieNames { + errs := isCookieName(name) + if len(errs) > 0 { + t.Errorf("isCookieName(%q) returned errors %v for valid input", name, errs) + } + } +} + func TestIsArgumentName(t *testing.T) { t.Parallel() validArgumentNames := []string{ @@ -2959,7 +2964,6 @@ func TestValidateSessionCookie(t *testing.T) { sc: &v1.SessionCookie{ Enable: true, Name: "test", Path: "/tea", Expires: "1", Domain: ".example.com", HTTPOnly: false, Secure: true, }, - msg: "max valid config", }, } @@ -2971,7 +2975,30 @@ func TestValidateSessionCookie(t *testing.T) { } } -func TestValidateSessionCookieFails(t *testing.T) { +func TestValidateSessionCookie_IsValidOnValidSameSiteInput(t *testing.T) { + t.Parallel() + + samesites := []string{ + "strict", + "Strict", + "STRICT", + "lax", + "Lax", + "LAX", + "none", + "None", + "NONE", + } + for _, samesite := range samesites { + sc := &v1.SessionCookie{Enable: true, Name: "ValidCookie", SameSite: samesite} + allErr := validateSessionCookie(sc, field.NewPath("sessionCookie")) + if len(allErr) != 0 { + t.Errorf("validateSessionCookie() returned errors for valid input: %s", samesite) + } + } +} + +func TestValidateSessionCookie_FailsOnInvalidInput(t *testing.T) { t.Parallel() tests := []struct { sc *v1.SessionCookie @@ -2997,12 +3024,18 @@ func TestValidateSessionCookieFails(t *testing.T) { sc: &v1.SessionCookie{Enable: true, Name: "test", Path: "/ coffee"}, msg: "invalid path format", }, + { + sc: &v1.SessionCookie{Enable: true, Name: "ValidCookie", SameSite: "bogus_value"}, + msg: "invalid samesite value", + }, } for _, test := range tests { - allErrs := validateSessionCookie(test.sc, field.NewPath("sessionCookie")) - if len(allErrs) == 0 { - t.Errorf("validateSessionCookie() returned no errors for invalid input for the case of: %v", test.msg) - } + t.Run(test.msg, func(t *testing.T) { + allErrs := validateSessionCookie(test.sc, field.NewPath("sessionCookie")) + if len(allErrs) == 0 { + t.Errorf("validateSessionCookie() did not return errors for invalid input for the case of: %s", test.msg) + } + }) } } diff --git a/tests/suite/test_virtual_server_upstream_options.py b/tests/suite/test_virtual_server_upstream_options.py index 491942fd3e..29049153a2 100644 --- a/tests/suite/test_virtual_server_upstream_options.py +++ b/tests/suite/test_virtual_server_upstream_options.py @@ -501,6 +501,7 @@ class TestOptionsSpecificForPlus: "domain": "virtual-server-route.example.com", "httpOnly": True, "secure": True, + "samesite": "strict", }, }, [ @@ -511,7 +512,65 @@ class TestOptionsSpecificForPlus: "slow_start=3h", "queue 100 timeout=60s;", "ntlm;", - "sticky cookie TestCookie expires=max domain=virtual-server-route.example.com httponly secure path=/some-valid/path;", + "sticky cookie TestCookie expires=max domain=virtual-server-route.example.com httponly samesite=strict secure path=/some-valid/path;", + ], + ), + ( + { + "lb-method": "least_conn", + "healthCheck": {"enable": True, "mandatory": True, "persistent": True}, + "slow-start": "3h", + "queue": {"size": 100}, + "ntlm": True, + "sessionCookie": { + "enable": True, + "name": "TestCookie", + "path": "/some-valid/path", + "expires": "max", + "domain": "virtual-server-route.example.com", + "httpOnly": True, + "secure": True, + "samesite": "lax", + }, + }, + [ + "health_check uri=/ interval=5s jitter=0s", + "fails=1 passes=1", + "mandatory persistent", + "keepalive_time=60s;", + "slow_start=3h", + "queue 100 timeout=60s;", + "ntlm;", + "sticky cookie TestCookie expires=max domain=virtual-server-route.example.com httponly samesite=lax secure path=/some-valid/path;", + ], + ), + ( + { + "lb-method": "least_conn", + "healthCheck": {"enable": True, "mandatory": True, "persistent": True}, + "slow-start": "3h", + "queue": {"size": 100}, + "ntlm": True, + "sessionCookie": { + "enable": True, + "name": "TestCookie", + "path": "/some-valid/path", + "expires": "max", + "domain": "virtual-server-route.example.com", + "httpOnly": True, + "secure": True, + "samesite": "none", + }, + }, + [ + "health_check uri=/ interval=5s jitter=0s", + "fails=1 passes=1", + "mandatory persistent", + "keepalive_time=60s;", + "slow_start=3h", + "queue 100 timeout=60s;", + "ntlm;", + "sticky cookie TestCookie expires=max domain=virtual-server-route.example.com httponly samesite=none secure path=/some-valid/path;", ], ), (