Skip to content

Commit fcb327e

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 fcb327e

3 files changed

Lines changed: 659 additions & 7 deletions

File tree

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

0 commit comments

Comments
 (0)