diff --git a/context.go b/context.go index 1c76c0f6..09af91c5 100644 --- a/context.go +++ b/context.go @@ -6,9 +6,11 @@ package gin import ( "errors" + "fmt" "io" "io/fs" "log" + "log/slog" "math" "mime/multipart" "net" @@ -47,6 +49,20 @@ const ContextKey = "_gin-gonic/gin/contextkey" type ContextKeyType int +type CookieOption func(*http.Cookie) + +func WithPartitionedCookie(partitioned bool) CookieOption { + return func(cookie *http.Cookie) { + cookie.Partitioned = partitioned + } +} + +func WithSameSiteCookie(sameSite http.SameSite) CookieOption { + return func(cookie *http.Cookie) { + cookie.SameSite = sameSite + } +} + const ContextRequestKey ContextKeyType = 0 // abortIndex represents a typical value used in abort functions. @@ -87,9 +103,9 @@ type Context struct { // or PUT body parameters. formCache url.Values - // SameSite allows a server to define a cookie attribute making it impossible for - // the browser to send this cookie along with cross-site requests. - sameSite http.SameSite + // cookieOptions set up additional cookie parameters to be used with http.Cookie + // this is needed to prevent breaking existing implementations + cookieOptions []CookieOption } /************************************/ @@ -108,7 +124,7 @@ func (c *Context) reset() { c.Accepted = nil c.queryCache = nil c.formCache = nil - c.sameSite = 0 + c.cookieOptions = make([]CookieOption, 0) *c.params = (*c.params)[:0] *c.skippedNodes = (*c.skippedNodes)[:0] } @@ -1005,26 +1021,38 @@ func (c *Context) GetRawData() ([]byte, error) { // SetSameSite with cookie func (c *Context) SetSameSite(samesite http.SameSite) { - c.sameSite = samesite + c.cookieOptions = append(c.cookieOptions, WithSameSiteCookie(samesite)) } // SetCookie adds a Set-Cookie header to the ResponseWriter's headers. // The provided cookie must have a valid Name. Invalid cookies may be // silently dropped. -func (c *Context) SetCookie(name, value string, maxAge int, path, domain string, secure, httpOnly bool) { +func (c *Context) SetCookie(name, value string, maxAge int, path, domain string, secure, httpOnly bool, options ...CookieOption) { if path == "" { path = "/" } - http.SetCookie(c.Writer, &http.Cookie{ + + cookie := &http.Cookie{ Name: name, Value: url.QueryEscape(value), MaxAge: maxAge, Path: path, Domain: domain, - SameSite: c.sameSite, Secure: secure, HttpOnly: httpOnly, - }) + } + + for _, option := range c.cookieOptions { + option(cookie) + } + for _, option := range options { + option(cookie) + } + if err := cookie.Valid(); err != nil { + slog.Error(fmt.Sprintf("invalid cookie: %v", err)) + } + + http.SetCookie(c.Writer, cookie) } // Cookie returns the named cookie provided in the request or diff --git a/context_test.go b/context_test.go index ef0cfccd..325768c4 100644 --- a/context_test.go +++ b/context_test.go @@ -206,6 +206,7 @@ func TestContextReset(t *testing.T) { c.Params = Params{Param{}} c.Error(errors.New("test")) //nolint: errcheck c.Set("foo", "bar") + c.SetSameSite(http.SameSiteLaxMode) c.reset() assert.False(t, c.IsAborted()) @@ -215,6 +216,7 @@ func TestContextReset(t *testing.T) { assert.Empty(t, c.Errors.Errors()) assert.Empty(t, c.Errors.ByType(ErrorTypeAny)) assert.Empty(t, c.Params) + assert.Empty(t, c.cookieOptions) assert.EqualValues(t, c.index, -1) assert.Equal(t, c.Writer.(*responseWriter), &c.writermem) } @@ -866,6 +868,18 @@ func TestContextSetCookie(t *testing.T) { assert.Equal(t, "user=gin; Path=/; Domain=localhost; Max-Age=1; HttpOnly; Secure; SameSite=Lax", c.Writer.Header().Get("Set-Cookie")) } +func TestContextSetCookieWithOptions(t *testing.T) { + c, _ := CreateTestContext(httptest.NewRecorder()) + c.SetCookie("user", "gin", 1, "/", "localhost", true, true, WithSameSiteCookie(http.SameSiteLaxMode), WithPartitionedCookie(true)) + assert.Equal(t, "user=gin; Path=/; Domain=localhost; Max-Age=1; HttpOnly; Secure; SameSite=Lax; Partitioned", c.Writer.Header().Get("Set-Cookie")) +} + +func TestContextSetCookieWithNonSecurePartition(t *testing.T) { + c, _ := CreateTestContext(httptest.NewRecorder()) + c.SetCookie("user", "gin", 1, "/", "localhost", false, true, WithPartitionedCookie(true)) + assert.Equal(t, "user=gin; Path=/; Domain=localhost; Max-Age=1; HttpOnly; Partitioned", c.Writer.Header().Get("Set-Cookie")) +} + func TestContextSetCookiePathEmpty(t *testing.T) { c, _ := CreateTestContext(httptest.NewRecorder()) c.SetSameSite(http.SameSiteLaxMode)