git.iko.soy/typeable/oauth2-proxy
on master help sign in
Add flags to define CSRF cookie expiration time and to allow CSRF cookies per request (#1708)

* Add start of state to CSRF cookie name * Update CHANGELOG.md * Update CHANGELOG.md * Support optional flags * Update CHANGELOG.md * Update CHANGELOG.md * Update CHANGELOG.md * Update overview.md Add new CSRF flags * Update overview.md Describe new CSRF flags Co-authored-by: Nuno Borges <Nuno.Borges@ctw.bmwgroup.com>

commita1ff878fdc98541b9c14ab19714fd0bd239758c8 authorNuno Miguel Micaelo Borges <miguelborges99@gmail.com> dateWed 31 Aug 2022, 23:27 (4 years ago) parentf8bd853
6 files changed+287−35
CHANGELOG.md+10 docs/docs/configuration/overview.md+3−1 pkg/apis/options/cookie.go+24−19 pkg/cookies/csrf.go+46−7 pkg/cookies/csrf_per_request_test.go+195 pkg/cookies/csrf_test.go+9−8
CHANGELOG.md+10
@@ -3,9 +3,15 @@
33## Release Highlights
44
55## Important Notes
6+- [#1708](https://github.com/oauth2-proxy/oauth2-proxy/pull/1708) Enable different CSRF cookies per request (@miguelborges99)
7+ - Since the CSRF cookie name is now longer it could potentially break long cookie names (around 1000 characters).
8+ - Having a unique CSRF cookie per request can lead to quite a number of cookies, in case an application performs a high number of parallel authentication requests. Each call will redirect to /oauth2/start, if the user is not authenticated, and a new cookie will be set. The successfully authenticated requests will have its CSRF cookies immediatly expired, however the failed ones will mantain its CSRF cookies until they expire (by default in 15 minutes).
9+ - The user may redefine the CSRF cookie expiration time using flag "--cookie-csrf-expire" (e.g. --cookie-csrf-expire=5m). By default, it is 15 minutes, but you can fine tune to your environment.
610
711## Breaking Changes
812
13+N/A
14+
915## Changes since v7.3.0
1016- [#1691](https://github.com/oauth2-proxy/oauth2-proxy/pull/1691) Fix Redis IdleTimeout when Redis timeout option is set to non-zero (@dimss)
1117
@@ -17,6 +23,10 @@
1723
1824- [#1774](https://github.com/oauth2-proxy/oauth2-proxy/pull/1774) Fix vulnerabilities CVE-2022-27191, CVE-2021-44716 and CVE-2022-29526
1925
26+- [#1708](https://github.com/oauth2-proxy/oauth2-proxy/pull/1708) Enable different CSRF cookies per request (@miguelborges99)
27+ - Add flag "--cookie-csrf-per-request" which activates an algorithm to name CSRF cookies differently per request.
28+ This feature allows parallel callbacks and by default it is disabled.
29+ - Add flag "--cookie-csrf-expire" to define a different expiration time for the CSRF cookie. By default, it is 15 minutes.
2030# V7.3.0
2131
2232## Release Highlights
docs/docs/configuration/overview.md+3−1
@@ -95,6 +95,8 @@ An example [oauth2-proxy.cfg](https://github.com/oauth2-proxy/oauth2-proxy/blob/
9595| `--cookie-secret` | string | the seed string for secure cookies (optionally base64 encoded) | |
9696| `--cookie-secure` | bool | set [secure (HTTPS only) cookie flag](https://owasp.org/www-community/controls/SecureFlag) | true |
9797| `--cookie-samesite` | string | set SameSite cookie attribute (`"lax"`, `"strict"`, `"none"`, or `""`). | `""` |
98+| `--cookie-csrf-per-request` | bool | Enable having different CSRF cookies per request, making it possible to have parallel requests. | false |
99+| `--cookie-csrf-expire` | duration | expire timeframe for CSRF cookie | 15m |
98100| `--custom-templates-dir` | string | path to custom html templates | |
99101| `--custom-sign-in-logo` | string | path or a URL to an custom image for the sign_in page logo. Use \"-\" to disable default logo. |
100102| `--display-htpasswd-form` | bool | display username / password login form if an htpasswd file is provided | true |
@@ -595,4 +597,4 @@ http:
595597
596598:::note
597599If you set up your OAuth2 provider to rotate your client secret, you can use the `client-secret-file` option to reload the secret when it is updated.
598−:::
600+:::
pkg/apis/options/cookie.go+24−19
@@ -8,15 +8,17 @@ import (
88
99// Cookie contains configuration options relating to Cookie configuration
1010type Cookie struct {
11− Name string `flag:"cookie-name" cfg:"cookie_name"`
12− Secret string `flag:"cookie-secret" cfg:"cookie_secret"`
13− Domains []string `flag:"cookie-domain" cfg:"cookie_domains"`
14− Path string `flag:"cookie-path" cfg:"cookie_path"`
15− Expire time.Duration `flag:"cookie-expire" cfg:"cookie_expire"`
16− Refresh time.Duration `flag:"cookie-refresh" cfg:"cookie_refresh"`
17− Secure bool `flag:"cookie-secure" cfg:"cookie_secure"`
18− HTTPOnly bool `flag:"cookie-httponly" cfg:"cookie_httponly"`
19− SameSite string `flag:"cookie-samesite" cfg:"cookie_samesite"`
11+ Name string `flag:"cookie-name" cfg:"cookie_name"`
12+ Secret string `flag:"cookie-secret" cfg:"cookie_secret"`
13+ Domains []string `flag:"cookie-domain" cfg:"cookie_domains"`
14+ Path string `flag:"cookie-path" cfg:"cookie_path"`
15+ Expire time.Duration `flag:"cookie-expire" cfg:"cookie_expire"`
16+ Refresh time.Duration `flag:"cookie-refresh" cfg:"cookie_refresh"`
17+ Secure bool `flag:"cookie-secure" cfg:"cookie_secure"`
18+ HTTPOnly bool `flag:"cookie-httponly" cfg:"cookie_httponly"`
19+ SameSite string `flag:"cookie-samesite" cfg:"cookie_samesite"`
20+ CSRFPerRequest bool `flag:"cookie-csrf-per-request" cfg:"cookie_csrf_per_request"`
21+ CSRFExpire time.Duration `flag:"cookie-csrf-expire" cfg:"cookie_csrf_expire"`
2022}
2123
2224func cookieFlagSet() *pflag.FlagSet {
@@ -31,21 +33,24 @@ func cookieFlagSet() *pflag.FlagSet {
3133 flagSet.Bool("cookie-secure", true, "set secure (HTTPS) cookie flag")
3234 flagSet.Bool("cookie-httponly", true, "set HttpOnly cookie flag")
3335 flagSet.String("cookie-samesite", "", "set SameSite cookie attribute (ie: \"lax\", \"strict\", \"none\", or \"\"). ")
34−
36+ flagSet.Bool("cookie-csrf-per-request", false, "When this property is set to true, then the CSRF cookie name is built based on the state and varies per request. If property is set to false, then CSRF cookie has the same name for all requests.")
37+ flagSet.Duration("cookie-csrf-expire", time.Duration(15)*time.Minute, "expire timeframe for CSRF cookie")
3538 return flagSet
3639}
3740
3841// cookieDefaults creates a Cookie populating each field with its default value
3942func cookieDefaults() Cookie {
4043 return Cookie{
41− Name: "_oauth2_proxy",
42− Secret: "",
43− Domains: nil,
44− Path: "/",
45− Expire: time.Duration(168) * time.Hour,
46− Refresh: time.Duration(0),
47− Secure: true,
48− HTTPOnly: true,
49− SameSite: "",
44+ Name: "_oauth2_proxy",
45+ Secret: "",
46+ Domains: nil,
47+ Path: "/",
48+ Expire: time.Duration(168) * time.Hour,
49+ Refresh: time.Duration(0),
50+ Secure: true,
51+ HTTPOnly: true,
52+ SameSite: "",
53+ CSRFPerRequest: false,
54+ CSRFExpire: time.Duration(15) * time.Minute,
5055 }
5156}
pkg/cookies/csrf.go+46−7
@@ -48,6 +48,9 @@ type csrf struct {
4848 time clock.Clock
4949}
5050
51+// csrtStateTrim will indicate the length of the state trimmed for the name of the csrf cookie
52+const csrfStateLength int = 9
53+
5154// NewCSRF creates a CSRF with random nonces
5255func NewCSRF(opts *options.Cookie, codeVerifier string) (CSRF, error) {
5356 state, err := encryption.Nonce(32)
@@ -70,7 +73,10 @@ func NewCSRF(opts *options.Cookie, codeVerifier string) (CSRF, error) {
7073
7174// LoadCSRFCookie loads a CSRF object from a request's CSRF cookie
7275func LoadCSRFCookie(req *http.Request, opts *options.Cookie) (CSRF, error) {
73− cookie, err := req.Cookie(csrfCookieName(opts))
76+
77+ cookieName := GenerateCookieName(req, opts)
78+
79+ cookie, err := req.Cookie(cookieName)
7480 if err != nil {
7581 return nil, err
7682 }
@@ -78,6 +84,18 @@ func LoadCSRFCookie(req *http.Request, opts *options.Cookie) (CSRF, error) {
7884 return decodeCSRFCookie(cookie, opts)
7985}
8086
87+// GenerateCookieName in case cookie options state that CSRF cookie has fixed name then set fixed name, otherwise
88+// build name based on the state
89+func GenerateCookieName(req *http.Request, opts *options.Cookie) string {
90+ stateSubstring := ""
91+ if opts.CSRFPerRequest {
92+ // csrfCookieName will include a substring of the state to enable multiple csrf cookies
93+ // in case of parallel requests
94+ stateSubstring = ExtractStateSubstring(req)
95+ }
96+ return csrfCookieName(opts, stateSubstring)
97+}
98+
8199func (c *csrf) GetCodeVerifier() string {
82100 return c.CodeVerifier
83101}
@@ -120,7 +138,7 @@ func (c *csrf) SetCookie(rw http.ResponseWriter, req *http.Request) (*http.Cooki
120138 c.cookieName(),
121139 encoded,
122140 c.cookieOpts,
123− c.cookieOpts.Expire,
141+ c.cookieOpts.CSRFExpire,
124142 c.time.Now(),
125143 )
126144 http.SetCookie(rw, cookie)
@@ -179,14 +197,35 @@ func decodeCSRFCookie(cookie *http.Cookie, opts *options.Cookie) (*csrf, error)
179197 return csrf, nil
180198}
181199
182−// cookieName returns the CSRF cookie's name derived from the base
183−// session cookie name
200+// cookieName returns the CSRF cookie's name
184201func (c *csrf) cookieName() string {
185− return csrfCookieName(c.cookieOpts)
202+ stateSubstring := ""
203+ if c.cookieOpts.CSRFPerRequest {
204+ stateSubstring = encryption.HashNonce(c.OAuthState)[0 : csrfStateLength-1]
205+ }
206+ return csrfCookieName(c.cookieOpts, stateSubstring)
207+}
208+
209+func csrfCookieName(opts *options.Cookie, stateSubstring string) string {
210+ if stateSubstring == "" {
211+ return fmt.Sprintf("%v_csrf", opts.Name)
212+ }
213+ return fmt.Sprintf("%v_csrf_%v", opts.Name, stateSubstring)
186214}
187215
188−func csrfCookieName(opts *options.Cookie) string {
189− return fmt.Sprintf("%v_csrf", opts.Name)
216+// ExtractStateSubstring extract the initial state characters, to add it to the CSRF cookie name
217+func ExtractStateSubstring(req *http.Request) string {
218+ lastChar := csrfStateLength - 1
219+ stateSubstring := ""
220+
221+ state := req.URL.Query()["state"]
222+ if state[0] != "" {
223+ state := state[0]
224+ if lastChar <= len(state) {
225+ stateSubstring = state[0:lastChar]
226+ }
227+ }
228+ return stateSubstring
190229}
191230
192231func encrypt(data []byte, opts *options.Cookie) ([]byte, error) {
pkg/cookies/csrf_per_request_test.gonew file+195
@@ -0,0 +1,195 @@
1+package cookies
2+
3+import (
4+ "fmt"
5+ "net/http"
6+ "net/http/httptest"
7+ "net/url"
8+ "time"
9+
10+ "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options"
11+ "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions"
12+ "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/encryption"
13+ . "github.com/onsi/ginkgo"
14+ . "github.com/onsi/gomega"
15+)
16+
17+var _ = Describe("CSRF Cookie with non-fixed name Tests", func() {
18+ var (
19+ cookieOpts *options.Cookie
20+ publicCSRF CSRF
21+ privateCSRF *csrf
22+ )
23+
24+ BeforeEach(func() {
25+ cookieOpts = &options.Cookie{
26+ Name: cookieName,
27+ Secret: cookieSecret,
28+ Domains: []string{cookieDomain},
29+ Path: cookiePath,
30+ Expire: time.Hour,
31+ Secure: true,
32+ HTTPOnly: true,
33+ CSRFPerRequest: true,
34+ CSRFExpire: time.Duration(5) * time.Minute,
35+ }
36+
37+ var err error
38+ publicCSRF, err = NewCSRF(cookieOpts, "verifier")
39+ Expect(err).ToNot(HaveOccurred())
40+
41+ privateCSRF = publicCSRF.(*csrf)
42+ })
43+
44+ Context("NewCSRF", func() {
45+ It("makes unique nonces for OAuth and OIDC", func() {
46+ Expect(privateCSRF.OAuthState).ToNot(BeEmpty())
47+ Expect(privateCSRF.OIDCNonce).ToNot(BeEmpty())
48+ Expect(privateCSRF.OAuthState).ToNot(Equal(privateCSRF.OIDCNonce))
49+ Expect(privateCSRF.CodeVerifier).To(Equal("verifier"))
50+ })
51+
52+ It("makes unique nonces between multiple CSRFs", func() {
53+ other, err := NewCSRF(cookieOpts, "verifier")
54+ Expect(err).ToNot(HaveOccurred())
55+
56+ Expect(privateCSRF.OAuthState).ToNot(Equal(other.(*csrf).OAuthState))
57+ Expect(privateCSRF.OIDCNonce).ToNot(Equal(other.(*csrf).OIDCNonce))
58+ Expect(privateCSRF.CodeVerifier).To(Equal("verifier"))
59+ })
60+ })
61+
62+ Context("CheckOAuthState and CheckOIDCNonce", func() {
63+ It("checks that hashed versions match", func() {
64+ privateCSRF.OAuthState = []byte(csrfState)
65+ privateCSRF.OIDCNonce = []byte(csrfNonce)
66+
67+ stateHashed := encryption.HashNonce([]byte(csrfState))
68+ nonceHashed := encryption.HashNonce([]byte(csrfNonce))
69+
70+ Expect(publicCSRF.CheckOAuthState(stateHashed)).To(BeTrue())
71+ Expect(publicCSRF.CheckOIDCNonce(nonceHashed)).To(BeTrue())
72+
73+ Expect(publicCSRF.CheckOAuthState(csrfNonce)).To(BeFalse())
74+ Expect(publicCSRF.CheckOIDCNonce(csrfState)).To(BeFalse())
75+ Expect(publicCSRF.CheckOAuthState(csrfState + csrfNonce)).To(BeFalse())
76+ Expect(publicCSRF.CheckOIDCNonce(csrfNonce + csrfState)).To(BeFalse())
77+ Expect(publicCSRF.CheckOAuthState("")).To(BeFalse())
78+ Expect(publicCSRF.CheckOIDCNonce("")).To(BeFalse())
79+ Expect(publicCSRF.GetCodeVerifier()).To(Equal("verifier"))
80+ })
81+ })
82+
83+ Context("SetSessionNonce", func() {
84+ It("sets the session.Nonce", func() {
85+ session := &sessions.SessionState{}
86+ publicCSRF.SetSessionNonce(session)
87+ Expect(session.Nonce).To(Equal(privateCSRF.OIDCNonce))
88+ })
89+ })
90+
91+ Context("encodeCookie and decodeCSRFCookie", func() {
92+ It("encodes and decodes to the same nonces", func() {
93+ privateCSRF.OAuthState = []byte(csrfState)
94+ privateCSRF.OIDCNonce = []byte(csrfNonce)
95+
96+ encoded, err := privateCSRF.encodeCookie()
97+ Expect(err).ToNot(HaveOccurred())
98+
99+ cookie := &http.Cookie{
100+ Name: privateCSRF.cookieName(),
101+ Value: encoded,
102+ }
103+ decoded, err := decodeCSRFCookie(cookie, cookieOpts)
104+ Expect(err).ToNot(HaveOccurred())
105+
106+ Expect(decoded).ToNot(BeNil())
107+ Expect(decoded.OAuthState).To(Equal([]byte(csrfState)))
108+ Expect(decoded.OIDCNonce).To(Equal([]byte(csrfNonce)))
109+ })
110+
111+ It("signs the encoded cookie value", func() {
112+ encoded, err := privateCSRF.encodeCookie()
113+ Expect(err).ToNot(HaveOccurred())
114+
115+ cookie := &http.Cookie{
116+ Name: privateCSRF.cookieName(),
117+ Value: encoded,
118+ }
119+
120+ _, _, valid := encryption.Validate(cookie, cookieOpts.Secret, cookieOpts.Expire)
121+ Expect(valid).To(BeTrue())
122+ })
123+ })
124+
125+ Context("Cookie Management", func() {
126+ var req *http.Request
127+
128+ testNow := time.Unix(nowEpoch, 0)
129+
130+ BeforeEach(func() {
131+ privateCSRF.time.Set(testNow)
132+
133+ req = &http.Request{
134+ Method: http.MethodGet,
135+ Proto: "HTTP/1.1",
136+ Host: cookieDomain,
137+
138+ URL: &url.URL{
139+ Scheme: "https",
140+ Host: cookieDomain,
141+ Path: cookiePath,
142+ },
143+ }
144+ })
145+
146+ AfterEach(func() {
147+ privateCSRF.time.Reset()
148+ })
149+
150+ Context("SetCookie", func() {
151+ It("adds the encoded CSRF cookie to a ResponseWriter", func() {
152+ rw := httptest.NewRecorder()
153+
154+ _, err := publicCSRF.SetCookie(rw, req)
155+ Expect(err).ToNot(HaveOccurred())
156+
157+ Expect(rw.Header().Get("Set-Cookie")).To(ContainSubstring(
158+ fmt.Sprintf("%s=", privateCSRF.cookieName()),
159+ ))
160+ Expect(rw.Header().Get("Set-Cookie")).To(ContainSubstring(
161+ fmt.Sprintf(
162+ "; Path=%s; Domain=%s; Expires=%s; HttpOnly; Secure",
163+ cookiePath,
164+ cookieDomain,
165+ testCookieExpires(testNow.Add(cookieOpts.CSRFExpire)),
166+ ),
167+ ))
168+ })
169+ })
170+
171+ Context("ClearCookie", func() {
172+ It("sets a cookie with an empty value in the past", func() {
173+ rw := httptest.NewRecorder()
174+
175+ publicCSRF.ClearCookie(rw, req)
176+
177+ Expect(rw.Header().Get("Set-Cookie")).To(Equal(
178+ fmt.Sprintf(
179+ "%s=; Path=%s; Domain=%s; Expires=%s; HttpOnly; Secure",
180+ privateCSRF.cookieName(),
181+ cookiePath,
182+ cookieDomain,
183+ testCookieExpires(testNow.Add(time.Hour*-1)),
184+ ),
185+ ))
186+ })
187+ })
188+
189+ Context("cookieName", func() {
190+ It("has the cookie options name as a base", func() {
191+ Expect(privateCSRF.cookieName()).To(ContainSubstring(cookieName))
192+ })
193+ })
194+ })
195+})
pkg/cookies/csrf_test.go+9−8
@@ -23,13 +23,14 @@ var _ = Describe("CSRF Cookie Tests", func() {
2323
2424 BeforeEach(func() {
2525 cookieOpts = &options.Cookie{
26− Name: cookieName,
27− Secret: cookieSecret,
28− Domains: []string{cookieDomain},
29− Path: cookiePath,
30− Expire: time.Hour,
31− Secure: true,
32− HTTPOnly: true,
26+ Name: cookieName,
27+ Secret: cookieSecret,
28+ Domains: []string{cookieDomain},
29+ Path: cookiePath,
30+ Expire: time.Hour,
31+ Secure: true,
32+ HTTPOnly: true,
33+ CSRFPerRequest: false,
3334 }
3435
3536 var err error
@@ -160,7 +161,7 @@ var _ = Describe("CSRF Cookie Tests", func() {
160161 "; Path=%s; Domain=%s; Expires=%s; HttpOnly; Secure",
161162 cookiePath,
162163 cookieDomain,
163− testCookieExpires(testNow.Add(cookieOpts.Expire)),
164+ testCookieExpires(testNow.Add(cookieOpts.CSRFExpire)),
164165 ),
165166 ))
166167 })