Skip to content

Commit 1fede04

Browse files
authored
Refactor CSRF protector (#32057)
Remove unused CSRF options, decouple "new csrf protector" and "prepare" logic, do not redirect to home page if CSRF validation falis (it shouldn't happen in daily usage, if it happens, redirecting to home doesn't help either but just makes the problem more complex for "fetch")
1 parent 55f1fcf commit 1fede04

File tree

7 files changed

+71
-172
lines changed

7 files changed

+71
-172
lines changed

options/locale/locale_en-US.ini

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -222,8 +222,6 @@ string.desc = Z - A
222222
[error]
223223
occurred = An error occurred
224224
report_message = If you believe that this is a Gitea bug, please search for issues on <a href="%s" target="_blank">GitHub</a> or open a new issue if necessary.
225-
missing_csrf = Bad Request: no CSRF token present
226-
invalid_csrf = Bad Request: invalid CSRF token
227225
not_found = The target couldn't be found.
228226
network_error = Network error
229227

routers/web/web.go

Lines changed: 2 additions & 0 deletions
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

Lines changed: 1 addition & 5 deletions
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

Lines changed: 60 additions & 132 deletions
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

Lines changed: 2 additions & 1 deletion
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

Lines changed: 4 additions & 22 deletions
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)