implement partitioned cookies

implement cookie validation with error logging
refactor c.SetCookie to accept option pattern modifications as vararg
refactor c.SetSameSite to use option pattern under the hood
This commit is contained in:
bound2 2025-03-28 09:09:45 +02:00
parent 8763f33c65
commit 4eecebf8bf
2 changed files with 51 additions and 9 deletions

View File

@ -6,9 +6,11 @@ package gin
import ( import (
"errors" "errors"
"fmt"
"io" "io"
"io/fs" "io/fs"
"log" "log"
"log/slog"
"math" "math"
"mime/multipart" "mime/multipart"
"net" "net"
@ -47,6 +49,20 @@ const ContextKey = "_gin-gonic/gin/contextkey"
type ContextKeyType int 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 const ContextRequestKey ContextKeyType = 0
// abortIndex represents a typical value used in abort functions. // abortIndex represents a typical value used in abort functions.
@ -87,9 +103,9 @@ type Context struct {
// or PUT body parameters. // or PUT body parameters.
formCache url.Values formCache url.Values
// SameSite allows a server to define a cookie attribute making it impossible for // cookieOptions set up additional cookie parameters to be used with http.Cookie
// the browser to send this cookie along with cross-site requests. // this is needed to prevent breaking existing implementations
sameSite http.SameSite cookieOptions []CookieOption
} }
/************************************/ /************************************/
@ -108,7 +124,7 @@ func (c *Context) reset() {
c.Accepted = nil c.Accepted = nil
c.queryCache = nil c.queryCache = nil
c.formCache = nil c.formCache = nil
c.sameSite = 0 c.cookieOptions = make([]CookieOption, 0)
*c.params = (*c.params)[:0] *c.params = (*c.params)[:0]
*c.skippedNodes = (*c.skippedNodes)[:0] *c.skippedNodes = (*c.skippedNodes)[:0]
} }
@ -1005,26 +1021,38 @@ func (c *Context) GetRawData() ([]byte, error) {
// SetSameSite with cookie // SetSameSite with cookie
func (c *Context) SetSameSite(samesite http.SameSite) { 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. // SetCookie adds a Set-Cookie header to the ResponseWriter's headers.
// The provided cookie must have a valid Name. Invalid cookies may be // The provided cookie must have a valid Name. Invalid cookies may be
// silently dropped. // 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 == "" { if path == "" {
path = "/" path = "/"
} }
http.SetCookie(c.Writer, &http.Cookie{
cookie := &http.Cookie{
Name: name, Name: name,
Value: url.QueryEscape(value), Value: url.QueryEscape(value),
MaxAge: maxAge, MaxAge: maxAge,
Path: path, Path: path,
Domain: domain, Domain: domain,
SameSite: c.sameSite,
Secure: secure, Secure: secure,
HttpOnly: httpOnly, 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 // Cookie returns the named cookie provided in the request or

View File

@ -206,6 +206,7 @@ func TestContextReset(t *testing.T) {
c.Params = Params{Param{}} c.Params = Params{Param{}}
c.Error(errors.New("test")) //nolint: errcheck c.Error(errors.New("test")) //nolint: errcheck
c.Set("foo", "bar") c.Set("foo", "bar")
c.SetSameSite(http.SameSiteLaxMode)
c.reset() c.reset()
assert.False(t, c.IsAborted()) 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.Errors())
assert.Empty(t, c.Errors.ByType(ErrorTypeAny)) assert.Empty(t, c.Errors.ByType(ErrorTypeAny))
assert.Empty(t, c.Params) assert.Empty(t, c.Params)
assert.Empty(t, c.cookieOptions)
assert.EqualValues(t, c.index, -1) assert.EqualValues(t, c.index, -1)
assert.Equal(t, c.Writer.(*responseWriter), &c.writermem) 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")) 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) { func TestContextSetCookiePathEmpty(t *testing.T) {
c, _ := CreateTestContext(httptest.NewRecorder()) c, _ := CreateTestContext(httptest.NewRecorder())
c.SetSameSite(http.SameSiteLaxMode) c.SetSameSite(http.SameSiteLaxMode)