Skip to content

Commit 2891edb

Browse files
authored
Refactor CSRF protector (#32057) (#32069)
#32057 improves the CSRF handling and is worth to backport
1 parent 8dbe83d commit 2891edb

File tree

7 files changed

+71
-172
lines changed

7 files changed

+71
-172
lines changed

options/locale/locale_en-US.ini

-2
Original file line numberDiff line numberDiff line change
@@ -218,8 +218,6 @@ string.desc = Z - A
218218
[error]
219219
occurred = An error occurred
220220
report_message = If you believe that this is a Gitea bug, please search for issues on <a href="https://github.com/go-gitea/gitea/issues" target="_blank">GitHub</a> or open a new issue if necessary.
221-
missing_csrf = Bad Request: no CSRF token present
222-
invalid_csrf = Bad Request: invalid CSRF token
223221
not_found = The target couldn't be found.
224222
network_error = Network error
225223

routers/web/web.go

+2
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,8 @@ func webAuth(authMethod auth_service.Method) func(*context.Context) {
129129
// ensure the session uid is deleted
130130
_ = ctx.Session.Delete("uid")
131131
}
132+
133+
ctx.Csrf.PrepareForSessionUser(ctx)
132134
}
133135
}
134136

services/context/context.go

+1-5
Original file line numberDiff line numberDiff line change
@@ -138,10 +138,8 @@ func Contexter() func(next http.Handler) http.Handler {
138138
csrfOpts := CsrfOptions{
139139
Secret: hex.EncodeToString(setting.GetGeneralTokenSigningSecret()),
140140
Cookie: setting.CSRFCookieName,
141-
SetCookie: true,
142141
Secure: setting.SessionConfig.Secure,
143142
CookieHTTPOnly: setting.CSRFCookieHTTPOnly,
144-
Header: "X-Csrf-Token",
145143
CookieDomain: setting.SessionConfig.Domain,
146144
CookiePath: setting.SessionConfig.CookiePath,
147145
SameSite: setting.SessionConfig.SameSite,
@@ -167,7 +165,7 @@ func Contexter() func(next http.Handler) http.Handler {
167165
ctx.Base.AppendContextValue(WebContextKey, ctx)
168166
ctx.Base.AppendContextValueFunc(gitrepo.RepositoryContextKey, func() any { return ctx.Repo.GitRepo })
169167

170-
ctx.Csrf = PrepareCSRFProtector(csrfOpts, ctx)
168+
ctx.Csrf = NewCSRFProtector(csrfOpts)
171169

172170
// Get the last flash message from cookie
173171
lastFlashCookie := middleware.GetSiteCookie(ctx.Req, CookieNameFlash)
@@ -204,8 +202,6 @@ func Contexter() func(next http.Handler) http.Handler {
204202
ctx.Resp.Header().Set(`X-Frame-Options`, setting.CORSConfig.XFrameOptions)
205203

206204
ctx.Data["SystemConfig"] = setting.Config()
207-
ctx.Data["CsrfToken"] = ctx.Csrf.GetToken()
208-
ctx.Data["CsrfTokenHtml"] = template.HTML(`<input type="hidden" name="_csrf" value="` + ctx.Data["CsrfToken"].(string) + `">`)
209205

210206
// FIXME: do we really always need these setting? There should be someway to have to avoid having to always set these
211207
ctx.Data["DisableMigrations"] = setting.Repository.DisableMigrations

services/context/csrf.go

+60-132
Original file line numberDiff line numberDiff line change
@@ -20,64 +20,42 @@
2020
package context
2121

2222
import (
23-
"encoding/base32"
24-
"fmt"
23+
"html/template"
2524
"net/http"
2625
"strconv"
2726
"time"
2827

2928
"code.gitea.io/gitea/modules/log"
30-
"code.gitea.io/gitea/modules/setting"
3129
"code.gitea.io/gitea/modules/util"
32-
"code.gitea.io/gitea/modules/web/middleware"
30+
)
31+
32+
const (
33+
CsrfHeaderName = "X-Csrf-Token"
34+
CsrfFormName = "_csrf"
3335
)
3436

3537
// CSRFProtector represents a CSRF protector and is used to get the current token and validate the token.
3638
type CSRFProtector interface {
37-
// GetHeaderName returns HTTP header to search for token.
38-
GetHeaderName() string
39-
// GetFormName returns form value to search for token.
40-
GetFormName() string
41-
// GetToken returns the token.
42-
GetToken() string
43-
// Validate validates the token in http context.
39+
// PrepareForSessionUser prepares the csrf protector for the current session user.
40+
PrepareForSessionUser(ctx *Context)
41+
// Validate validates the csrf token in http context.
4442
Validate(ctx *Context)
45-
// DeleteCookie deletes the cookie
43+
// DeleteCookie deletes the csrf cookie
4644
DeleteCookie(ctx *Context)
4745
}
4846

4947
type csrfProtector struct {
5048
opt CsrfOptions
51-
// Token generated to pass via header, cookie, or hidden form value.
52-
Token string
53-
// This value must be unique per user.
54-
ID string
55-
}
56-
57-
// GetHeaderName returns the name of the HTTP header for csrf token.
58-
func (c *csrfProtector) GetHeaderName() string {
59-
return c.opt.Header
60-
}
61-
62-
// GetFormName returns the name of the form value for csrf token.
63-
func (c *csrfProtector) GetFormName() string {
64-
return c.opt.Form
65-
}
66-
67-
// GetToken returns the current token. This is typically used
68-
// to populate a hidden form in an HTML template.
69-
func (c *csrfProtector) GetToken() string {
70-
return c.Token
49+
// id must be unique per user.
50+
id string
51+
// token is the valid one which wil be used by end user and passed via header, cookie, or hidden form value.
52+
token string
7153
}
7254

7355
// CsrfOptions maintains options to manage behavior of Generate.
7456
type CsrfOptions struct {
7557
// The global secret value used to generate Tokens.
7658
Secret string
77-
// HTTP header used to set and get token.
78-
Header string
79-
// Form value used to set and get token.
80-
Form string
8159
// Cookie value used to set and get token.
8260
Cookie string
8361
// Cookie domain.
@@ -87,156 +65,106 @@ type CsrfOptions struct {
8765
CookieHTTPOnly bool
8866
// SameSite set the cookie SameSite type
8967
SameSite http.SameSite
90-
// Key used for getting the unique ID per user.
91-
SessionKey string
92-
// oldSessionKey saves old value corresponding to SessionKey.
93-
oldSessionKey string
94-
// If true, send token via X-Csrf-Token header.
95-
SetHeader bool
96-
// If true, send token via _csrf cookie.
97-
SetCookie bool
9868
// Set the Secure flag to true on the cookie.
9969
Secure bool
100-
// Disallow Origin appear in request header.
101-
Origin bool
102-
// Cookie lifetime. Default is 0
103-
CookieLifeTime int
104-
}
105-
106-
func prepareDefaultCsrfOptions(opt CsrfOptions) CsrfOptions {
107-
if opt.Secret == "" {
108-
randBytes, err := util.CryptoRandomBytes(8)
109-
if err != nil {
110-
// this panic can be handled by the recover() in http handlers
111-
panic(fmt.Errorf("failed to generate random bytes: %w", err))
112-
}
113-
opt.Secret = base32.StdEncoding.EncodeToString(randBytes)
114-
}
115-
if opt.Header == "" {
116-
opt.Header = "X-Csrf-Token"
117-
}
118-
if opt.Form == "" {
119-
opt.Form = "_csrf"
120-
}
121-
if opt.Cookie == "" {
122-
opt.Cookie = "_csrf"
123-
}
124-
if opt.CookiePath == "" {
125-
opt.CookiePath = "/"
126-
}
127-
if opt.SessionKey == "" {
128-
opt.SessionKey = "uid"
129-
}
130-
if opt.CookieLifeTime == 0 {
131-
opt.CookieLifeTime = int(CsrfTokenTimeout.Seconds())
132-
}
133-
134-
opt.oldSessionKey = "_old_" + opt.SessionKey
135-
return opt
70+
// sessionKey is the key used for getting the unique ID per user.
71+
sessionKey string
72+
// oldSessionKey saves old value corresponding to sessionKey.
73+
oldSessionKey string
13674
}
13775

138-
func newCsrfCookie(c *csrfProtector, value string) *http.Cookie {
76+
func newCsrfCookie(opt *CsrfOptions, value string) *http.Cookie {
13977
return &http.Cookie{
140-
Name: c.opt.Cookie,
78+
Name: opt.Cookie,
14179
Value: value,
142-
Path: c.opt.CookiePath,
143-
Domain: c.opt.CookieDomain,
144-
MaxAge: c.opt.CookieLifeTime,
145-
Secure: c.opt.Secure,
146-
HttpOnly: c.opt.CookieHTTPOnly,
147-
SameSite: c.opt.SameSite,
80+
Path: opt.CookiePath,
81+
Domain: opt.CookieDomain,
82+
MaxAge: int(CsrfTokenTimeout.Seconds()),
83+
Secure: opt.Secure,
84+
HttpOnly: opt.CookieHTTPOnly,
85+
SameSite: opt.SameSite,
14886
}
14987
}
15088

151-
// PrepareCSRFProtector returns a CSRFProtector to be used for every request.
152-
// Additionally, depending on options set, generated tokens will be sent via Header and/or Cookie.
153-
func PrepareCSRFProtector(opt CsrfOptions, ctx *Context) CSRFProtector {
154-
opt = prepareDefaultCsrfOptions(opt)
155-
x := &csrfProtector{opt: opt}
156-
157-
if opt.Origin && len(ctx.Req.Header.Get("Origin")) > 0 {
158-
return x
89+
func NewCSRFProtector(opt CsrfOptions) CSRFProtector {
90+
if opt.Secret == "" {
91+
panic("CSRF secret is empty but it must be set") // it shouldn't happen because it is always set in code
15992
}
93+
opt.Cookie = util.IfZero(opt.Cookie, "_csrf")
94+
opt.CookiePath = util.IfZero(opt.CookiePath, "/")
95+
opt.sessionKey = "uid"
96+
opt.oldSessionKey = "_old_" + opt.sessionKey
97+
return &csrfProtector{opt: opt}
98+
}
16099

161-
x.ID = "0"
162-
uidAny := ctx.Session.Get(opt.SessionKey)
163-
if uidAny != nil {
100+
func (c *csrfProtector) PrepareForSessionUser(ctx *Context) {
101+
c.id = "0"
102+
if uidAny := ctx.Session.Get(c.opt.sessionKey); uidAny != nil {
164103
switch uidVal := uidAny.(type) {
165104
case string:
166-
x.ID = uidVal
105+
c.id = uidVal
167106
case int64:
168-
x.ID = strconv.FormatInt(uidVal, 10)
107+
c.id = strconv.FormatInt(uidVal, 10)
169108
default:
170109
log.Error("invalid uid type in session: %T", uidAny)
171110
}
172111
}
173112

174-
oldUID := ctx.Session.Get(opt.oldSessionKey)
175-
uidChanged := oldUID == nil || oldUID.(string) != x.ID
176-
cookieToken := ctx.GetSiteCookie(opt.Cookie)
113+
oldUID := ctx.Session.Get(c.opt.oldSessionKey)
114+
uidChanged := oldUID == nil || oldUID.(string) != c.id
115+
cookieToken := ctx.GetSiteCookie(c.opt.Cookie)
177116

178117
needsNew := true
179118
if uidChanged {
180-
_ = ctx.Session.Set(opt.oldSessionKey, x.ID)
119+
_ = ctx.Session.Set(c.opt.oldSessionKey, c.id)
181120
} else if cookieToken != "" {
182121
// If cookie token presents, re-use existing unexpired token, else generate a new one.
183122
if issueTime, ok := ParseCsrfToken(cookieToken); ok {
184123
dur := time.Since(issueTime) // issueTime is not a monotonic-clock, the server time may change a lot to an early time.
185124
if dur >= -CsrfTokenRegenerationInterval && dur <= CsrfTokenRegenerationInterval {
186-
x.Token = cookieToken
125+
c.token = cookieToken
187126
needsNew = false
188127
}
189128
}
190129
}
191130

192131
if needsNew {
193132
// FIXME: actionId.
194-
x.Token = GenerateCsrfToken(x.opt.Secret, x.ID, "POST", time.Now())
195-
if opt.SetCookie {
196-
cookie := newCsrfCookie(x, x.Token)
197-
ctx.Resp.Header().Add("Set-Cookie", cookie.String())
198-
}
133+
c.token = GenerateCsrfToken(c.opt.Secret, c.id, "POST", time.Now())
134+
cookie := newCsrfCookie(&c.opt, c.token)
135+
ctx.Resp.Header().Add("Set-Cookie", cookie.String())
199136
}
200137

201-
if opt.SetHeader {
202-
ctx.Resp.Header().Add(opt.Header, x.Token)
203-
}
204-
return x
138+
ctx.Data["CsrfToken"] = c.token
139+
ctx.Data["CsrfTokenHtml"] = template.HTML(`<input type="hidden" name="_csrf" value="` + template.HTMLEscapeString(c.token) + `">`)
205140
}
206141

207142
func (c *csrfProtector) validateToken(ctx *Context, token string) {
208-
if !ValidCsrfToken(token, c.opt.Secret, c.ID, "POST", time.Now()) {
143+
if !ValidCsrfToken(token, c.opt.Secret, c.id, "POST", time.Now()) {
209144
c.DeleteCookie(ctx)
210-
if middleware.IsAPIPath(ctx.Req) {
211-
// currently, there should be no access to the APIPath with CSRF token. because templates shouldn't use the `/api/` endpoints.
212-
http.Error(ctx.Resp, "Invalid CSRF token.", http.StatusBadRequest)
213-
} else {
214-
ctx.Flash.Error(ctx.Tr("error.invalid_csrf"))
215-
ctx.Redirect(setting.AppSubURL + "/")
216-
}
145+
// currently, there should be no access to the APIPath with CSRF token. because templates shouldn't use the `/api/` endpoints.
146+
// FIXME: distinguish what the response is for: HTML (web page) or JSON (fetch)
147+
http.Error(ctx.Resp, "Invalid CSRF token.", http.StatusBadRequest)
217148
}
218149
}
219150

220151
// Validate should be used as a per route middleware. It attempts to get a token from an "X-Csrf-Token"
221152
// HTTP header and then a "_csrf" form value. If one of these is found, the token will be validated.
222-
// If this validation fails, custom Error is sent in the reply.
223-
// If neither a header nor form value is found, http.StatusBadRequest is sent.
153+
// If this validation fails, http.StatusBadRequest is sent.
224154
func (c *csrfProtector) Validate(ctx *Context) {
225-
if token := ctx.Req.Header.Get(c.GetHeaderName()); token != "" {
155+
if token := ctx.Req.Header.Get(CsrfHeaderName); token != "" {
226156
c.validateToken(ctx, token)
227157
return
228158
}
229-
if token := ctx.Req.FormValue(c.GetFormName()); token != "" {
159+
if token := ctx.Req.FormValue(CsrfFormName); token != "" {
230160
c.validateToken(ctx, token)
231161
return
232162
}
233163
c.validateToken(ctx, "") // no csrf token, use an empty token to respond error
234164
}
235165

236166
func (c *csrfProtector) DeleteCookie(ctx *Context) {
237-
if c.opt.SetCookie {
238-
cookie := newCsrfCookie(c, "")
239-
cookie.MaxAge = -1
240-
ctx.Resp.Header().Add("Set-Cookie", cookie.String())
241-
}
167+
cookie := newCsrfCookie(&c.opt, "")
168+
cookie.MaxAge = -1
169+
ctx.Resp.Header().Add("Set-Cookie", cookie.String())
242170
}

tests/integration/attachment_test.go

+2-1
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,8 @@ func createAttachment(t *testing.T, session *TestSession, repoURL, filename stri
5959
func TestCreateAnonymousAttachment(t *testing.T) {
6060
defer tests.PrepareTestEnv(t)()
6161
session := emptyTestSession(t)
62-
createAttachment(t, session, "user2/repo1", "image.png", generateImg(), http.StatusSeeOther)
62+
// this test is not right because it just doesn't pass the CSRF validation
63+
createAttachment(t, session, "user2/repo1", "image.png", generateImg(), http.StatusBadRequest)
6364
}
6465

6566
func TestCreateIssueAttachment(t *testing.T) {

tests/integration/csrf_test.go

+4-22
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,10 @@ package integration
55

66
import (
77
"net/http"
8-
"strings"
98
"testing"
109

1110
"code.gitea.io/gitea/models/unittest"
1211
user_model "code.gitea.io/gitea/models/user"
13-
"code.gitea.io/gitea/modules/setting"
1412
"code.gitea.io/gitea/tests"
1513

1614
"github.com/stretchr/testify/assert"
@@ -25,28 +23,12 @@ func TestCsrfProtection(t *testing.T) {
2523
req := NewRequestWithValues(t, "POST", "/user/settings", map[string]string{
2624
"_csrf": "fake_csrf",
2725
})
28-
session.MakeRequest(t, req, http.StatusSeeOther)
29-
30-
resp := session.MakeRequest(t, req, http.StatusSeeOther)
31-
loc := resp.Header().Get("Location")
32-
assert.Equal(t, setting.AppSubURL+"/", loc)
33-
resp = session.MakeRequest(t, NewRequest(t, "GET", loc), http.StatusOK)
34-
htmlDoc := NewHTMLParser(t, resp.Body)
35-
assert.Equal(t, "Bad Request: invalid CSRF token",
36-
strings.TrimSpace(htmlDoc.doc.Find(".ui.message").Text()),
37-
)
26+
resp := session.MakeRequest(t, req, http.StatusBadRequest)
27+
assert.Contains(t, resp.Body.String(), "Invalid CSRF token")
3828

3929
// test web form csrf via header. TODO: should use an UI api to test
4030
req = NewRequest(t, "POST", "/user/settings")
4131
req.Header.Add("X-Csrf-Token", "fake_csrf")
42-
session.MakeRequest(t, req, http.StatusSeeOther)
43-
44-
resp = session.MakeRequest(t, req, http.StatusSeeOther)
45-
loc = resp.Header().Get("Location")
46-
assert.Equal(t, setting.AppSubURL+"/", loc)
47-
resp = session.MakeRequest(t, NewRequest(t, "GET", loc), http.StatusOK)
48-
htmlDoc = NewHTMLParser(t, resp.Body)
49-
assert.Equal(t, "Bad Request: invalid CSRF token",
50-
strings.TrimSpace(htmlDoc.doc.Find(".ui.message").Text()),
51-
)
32+
resp = session.MakeRequest(t, req, http.StatusBadRequest)
33+
assert.Contains(t, resp.Body.String(), "Invalid CSRF token")
5234
}

0 commit comments

Comments
 (0)