Skip to content

Commit 5b65041

Browse files
committed
Add multi-part session cookie support to fix long URL issue
This fixes #348 The problem: When a user visits a protected URL that is very long (e.g., a Grafana explore URL with a complex query), the session cookie exceeds the securecookie MaxLength of 4096 bytes, causing a "securecookie: the value is too long" error. This breaks the OAuth flow - the session isn't saved, so when the OAuth callback returns, the state validation fails with "Invalid session state". The solution: Implement a MultiPartCookieStore that: 1. Removes the securecookie MaxLength limit (we handle size ourselves) 2. Automatically splits large session cookies into multiple parts (e.g., VouchSession_1of3, VouchSession_2of3, VouchSession_3of3) 3. Reassembles the parts when reading the session back This approach mirrors how Vouch already handles large JWT cookies in pkg/cookie/cookie.go. The change is backwards compatible - existing session cookies will continue to work because: - The store first tries to read a single cookie before looking for parts - The same securecookie encoding is used - Small sessions are still written as single cookies Dockerfile: Updated Go from 1.23 to 1.24, required by dependencies (golang.org/x/net requires go >= 1.24.0).
1 parent c220a5e commit 5b65041

File tree

3 files changed

+457
-7
lines changed

3 files changed

+457
-7
lines changed

handlers/handlers.go

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import (
3131
"github.com/vouch/vouch-proxy/pkg/providers/nextcloud"
3232
"github.com/vouch/vouch-proxy/pkg/providers/openid"
3333
"github.com/vouch/vouch-proxy/pkg/providers/openstax"
34+
"github.com/vouch/vouch-proxy/pkg/session"
3435
"github.com/vouch/vouch-proxy/pkg/structs"
3536
)
3637

@@ -45,7 +46,7 @@ const (
4546
)
4647

4748
var (
48-
sessstore *sessions.CookieStore
49+
sessstore sessions.Store
4950
log *zap.SugaredLogger
5051
fastlog *zap.Logger
5152
provider Provider
@@ -55,12 +56,14 @@ var (
5556
func Configure() {
5657
log = cfg.Logging.Logger
5758
fastlog = cfg.Logging.FastLogger
58-
// http://www.gorillatoolkit.org/pkg/sessions
59-
sessstore = sessions.NewCookieStore([]byte(cfg.Cfg.Session.Key))
60-
sessstore.Options.HttpOnly = cfg.Cfg.Cookie.HTTPOnly
61-
sessstore.Options.Secure = cfg.Cfg.Cookie.Secure
62-
sessstore.Options.SameSite = cookie.SameSite()
63-
sessstore.Options.MaxAge = cfg.Cfg.Session.MaxAge * 60 // convert minutes to seconds
59+
// Use MultiPartCookieStore to support large session cookies (long URLs)
60+
// This fixes https://github.com/vouch/vouch-proxy/issues/348
61+
multiPartStore := session.NewMultiPartCookieStore([]byte(cfg.Cfg.Session.Key))
62+
multiPartStore.Options.HttpOnly = cfg.Cfg.Cookie.HTTPOnly
63+
multiPartStore.Options.Secure = cfg.Cfg.Cookie.Secure
64+
multiPartStore.Options.SameSite = cookie.SameSite()
65+
multiPartStore.Options.MaxAge = cfg.Cfg.Session.MaxAge * 60 // convert minutes to seconds
66+
sessstore = multiPartStore
6467

6568
provider = getProvider()
6669
provider.Configure()

pkg/session/multipart_store.go

Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
/*
2+
3+
Copyright 2020 The Vouch Proxy Authors.
4+
Use of this source code is governed by The MIT License (MIT) that
5+
can be found in the LICENSE file. Software distributed under The
6+
MIT License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES
7+
OR CONDITIONS OF ANY KIND, either express or implied.
8+
9+
*/
10+
11+
package session
12+
13+
import (
14+
"fmt"
15+
"net/http"
16+
"regexp"
17+
"strconv"
18+
"strings"
19+
"unicode/utf8"
20+
21+
"github.com/gorilla/securecookie"
22+
"github.com/gorilla/sessions"
23+
)
24+
25+
// maxCookieSize is the maximum size of a single Set-Cookie header value.
26+
// Browsers typically limit cookies to 4096 bytes, but this includes the
27+
// cookie name, path, domain, and other attributes - not just the value.
28+
// We use 3800 to leave ~300 bytes of headroom for this metadata.
29+
const maxCookieSize = 3800
30+
31+
// MultiPartCookieStore is a session store that splits large session cookies
32+
// into multiple parts, similar to how Vouch handles JWT cookies.
33+
// This fixes https://github.com/vouch/vouch-proxy/issues/348
34+
type MultiPartCookieStore struct {
35+
Codecs []securecookie.Codec
36+
Options *sessions.Options
37+
}
38+
39+
// NewMultiPartCookieStore creates a new MultiPartCookieStore with the given key pairs.
40+
func NewMultiPartCookieStore(keyPairs ...[]byte) *MultiPartCookieStore {
41+
codecs := securecookie.CodecsFromPairs(keyPairs...)
42+
// Increase the max length for the securecookie encoder
43+
// We'll handle splitting into multiple cookies ourselves
44+
for _, codec := range codecs {
45+
if sc, ok := codec.(*securecookie.SecureCookie); ok {
46+
// Set a very high limit - we'll split the result into multiple cookies
47+
sc.MaxLength(0) // 0 means unlimited
48+
}
49+
}
50+
return &MultiPartCookieStore{
51+
Codecs: codecs,
52+
Options: &sessions.Options{
53+
Path: "/",
54+
MaxAge: 86400,
55+
},
56+
}
57+
}
58+
59+
// Get returns a session for the given name after adding it to the registry.
60+
func (s *MultiPartCookieStore) Get(r *http.Request, name string) (*sessions.Session, error) {
61+
return sessions.GetRegistry(r).Get(s, name)
62+
}
63+
64+
// New returns a session for the given name without adding it to the registry.
65+
func (s *MultiPartCookieStore) New(r *http.Request, name string) (*sessions.Session, error) {
66+
session := sessions.NewSession(s, name)
67+
opts := *s.Options
68+
session.Options = &opts
69+
session.IsNew = true
70+
71+
// Try to load existing session from cookies
72+
value, err := s.readMultiPartCookie(r, name)
73+
if err == nil && value != "" {
74+
err = securecookie.DecodeMulti(name, value, &session.Values, s.Codecs...)
75+
if err == nil {
76+
session.IsNew = false
77+
}
78+
}
79+
return session, nil
80+
}
81+
82+
// Save adds a single session to the response.
83+
func (s *MultiPartCookieStore) Save(r *http.Request, w http.ResponseWriter, session *sessions.Session) error {
84+
// Delete if max-age is <= 0
85+
if session.Options.MaxAge <= 0 {
86+
s.deleteMultiPartCookie(w, r, session.Name(), session.Options)
87+
return nil
88+
}
89+
90+
// Encode the session
91+
encoded, err := securecookie.EncodeMulti(session.Name(), session.Values, s.Codecs...)
92+
if err != nil {
93+
return err
94+
}
95+
96+
// Write the cookie(s)
97+
return s.writeMultiPartCookie(w, r, session.Name(), encoded, session.Options)
98+
}
99+
100+
// readMultiPartCookie reads a potentially multi-part cookie value
101+
func (s *MultiPartCookieStore) readMultiPartCookie(r *http.Request, name string) (string, error) {
102+
// First try to find a single cookie
103+
if cookie, err := r.Cookie(name); err == nil {
104+
return cookie.Value, nil
105+
}
106+
107+
// Look for multi-part cookies
108+
cookies := r.Cookies()
109+
parts := make(map[int]string)
110+
var totalParts int
111+
112+
partPattern := regexp.MustCompile(fmt.Sprintf(`^%s_(\d+)of(\d+)$`, regexp.QuoteMeta(name)))
113+
114+
for _, cookie := range cookies {
115+
matches := partPattern.FindStringSubmatch(cookie.Name)
116+
if matches != nil {
117+
partNum, err := strconv.Atoi(matches[1])
118+
if err != nil {
119+
return "", fmt.Errorf("invalid part number in cookie %q: %w", cookie.Name, err)
120+
}
121+
total, err := strconv.Atoi(matches[2])
122+
if err != nil {
123+
return "", fmt.Errorf("invalid total in cookie %q: %w", cookie.Name, err)
124+
}
125+
if totalParts == 0 {
126+
totalParts = total
127+
}
128+
parts[partNum] = cookie.Value
129+
}
130+
}
131+
132+
if totalParts == 0 {
133+
return "", http.ErrNoCookie
134+
}
135+
136+
// Reassemble parts in order
137+
var combined strings.Builder
138+
for i := 1; i <= totalParts; i++ {
139+
if part, ok := parts[i]; ok {
140+
combined.WriteString(part)
141+
} else {
142+
return "", fmt.Errorf("missing cookie part %d of %d", i, totalParts)
143+
}
144+
}
145+
146+
return combined.String(), nil
147+
}
148+
149+
// writeMultiPartCookie writes a cookie, splitting into multiple parts if necessary
150+
func (s *MultiPartCookieStore) writeMultiPartCookie(w http.ResponseWriter, r *http.Request, name, value string, options *sessions.Options) error {
151+
// First, clear any existing multi-part cookies (only the parts, not the main cookie)
152+
s.clearMultiPartCookieParts(w, r, name, options)
153+
154+
// Calculate if we need to split
155+
testCookie := &http.Cookie{
156+
Name: name,
157+
Value: value,
158+
Path: options.Path,
159+
Domain: options.Domain,
160+
MaxAge: options.MaxAge,
161+
Secure: options.Secure,
162+
HttpOnly: options.HttpOnly,
163+
SameSite: options.SameSite,
164+
}
165+
166+
if len(testCookie.String()) <= maxCookieSize {
167+
// Single cookie is fine
168+
http.SetCookie(w, testCookie)
169+
return nil
170+
}
171+
172+
// Need to split - calculate available space for value per cookie
173+
emptyCookie := &http.Cookie{
174+
Name: name + "_99of99", // Use longest possible name format
175+
Value: "",
176+
Path: options.Path,
177+
Domain: options.Domain,
178+
MaxAge: options.MaxAge,
179+
Secure: options.Secure,
180+
HttpOnly: options.HttpOnly,
181+
SameSite: options.SameSite,
182+
}
183+
maxValueLen := maxCookieSize - len(emptyCookie.String())
184+
if maxValueLen <= 0 {
185+
return fmt.Errorf("cookie metadata too large, no room for value")
186+
}
187+
188+
// Split the value
189+
parts := splitString(value, maxValueLen)
190+
191+
// Write each part
192+
for i, part := range parts {
193+
partName := fmt.Sprintf("%s_%dof%d", name, i+1, len(parts))
194+
http.SetCookie(w, &http.Cookie{
195+
Name: partName,
196+
Value: part,
197+
Path: options.Path,
198+
Domain: options.Domain,
199+
MaxAge: options.MaxAge,
200+
Secure: options.Secure,
201+
HttpOnly: options.HttpOnly,
202+
SameSite: options.SameSite,
203+
})
204+
}
205+
206+
return nil
207+
}
208+
209+
// clearMultiPartCookieParts clears only the multi-part cookie parts (not the main cookie)
210+
// This is used when writing a new value to avoid leaving stale parts
211+
func (s *MultiPartCookieStore) clearMultiPartCookieParts(w http.ResponseWriter, r *http.Request, name string, options *sessions.Options) {
212+
cookies := r.Cookies()
213+
partPattern := regexp.MustCompile(fmt.Sprintf(`^%s_\d+of\d+$`, regexp.QuoteMeta(name)))
214+
215+
for _, cookie := range cookies {
216+
if partPattern.MatchString(cookie.Name) {
217+
http.SetCookie(w, &http.Cookie{
218+
Name: cookie.Name,
219+
Value: "",
220+
Path: options.Path,
221+
Domain: options.Domain,
222+
MaxAge: -1,
223+
Secure: options.Secure,
224+
HttpOnly: options.HttpOnly,
225+
SameSite: options.SameSite,
226+
})
227+
}
228+
}
229+
}
230+
231+
// deleteMultiPartCookie deletes a cookie and any multi-part variants
232+
func (s *MultiPartCookieStore) deleteMultiPartCookie(w http.ResponseWriter, r *http.Request, name string, options *sessions.Options) {
233+
// Delete the main cookie
234+
http.SetCookie(w, &http.Cookie{
235+
Name: name,
236+
Value: "",
237+
Path: options.Path,
238+
Domain: options.Domain,
239+
MaxAge: -1,
240+
Secure: options.Secure,
241+
HttpOnly: options.HttpOnly,
242+
SameSite: options.SameSite,
243+
})
244+
245+
// Also delete any multi-part cookies
246+
s.clearMultiPartCookieParts(w, r, name, options)
247+
}
248+
249+
// splitString splits a string into parts of at most maxLen bytes,
250+
// respecting UTF-8 character boundaries
251+
func splitString(s string, maxLen int) []string {
252+
if len(s) == 0 {
253+
return []string{""}
254+
}
255+
if maxLen <= 0 {
256+
return []string{s}
257+
}
258+
259+
var parts []string
260+
for len(s) > 0 {
261+
if len(s) <= maxLen {
262+
parts = append(parts, s)
263+
break
264+
}
265+
266+
// Find a safe split point that doesn't break UTF-8
267+
splitAt := maxLen
268+
for splitAt > 0 && !utf8.RuneStart(s[splitAt]) {
269+
splitAt--
270+
}
271+
if splitAt == 0 {
272+
// Shouldn't happen with valid UTF-8, but fallback
273+
splitAt = maxLen
274+
}
275+
276+
parts = append(parts, s[:splitAt])
277+
s = s[splitAt:]
278+
}
279+
return parts
280+
}

0 commit comments

Comments
 (0)