CSRF 공격을 막을 수 있는 방법 중 하나는 session cookie에 SameSite
attribute를 설정해놓는 것이다.
golangcollege/sessions
package는 항상 SameSite=Lax
로 session cookie를 설정해놓는다. 이는 user의 브라우저에서 cross-site usage을 위해 session cookie을 전달하지 않는다는 것이다. 이를 통해서도 CSRF attack 위험을 줄일 수 있다.
별도로 만약 SameSite=Strict
로 설정해놓고 싶다면, main.go
파일에 다음과 같이 설정이 가능하다.
main.go
...
session := sessions.New([]byte(*secret))
session.Lifetime = 12 * time.Hour
session.Secure = true
session.SameSite = http.SameSiteStrictMode
...
SameSite=Strict
로 설정해놓으면 session cookie가 모든 cross-site usage에 대해서 user의 브라우저에서 전달되지 않는다. 이는 user가 우리의 사이트에서 외부의 link를 눌러도 전달되지 않는다는 것이다.
하지만 안타깝게도 모든 browser가 SameSite
을 지원하지 않는다. 85%의 브라우저만이 이를 지원하므로 다른 방법이 필요하다.
이를 위해 token check
form을 구현할 필요가 있다. 그러나 이를 직접 구현하기에는 여러 가지 방법들이 있고 다소 난이도가 있다. 따라서 누군가 구현해놓은 third-party를 사용하는 것이 좋다.
두 가지 유명한 패키지가 있는데 gorilla/csrf
와 justinas/nosurf
가 있다. 이들은 Double Submit Cookie pattern
을 사용하여 공격을 막는 것으로 상당 부분 동일하다. 이 패턴에서는 랜덤한 CSRF token을 만들어 CSRF cookie에 넣어 user에 전달해준다. CSRF token은 CSRF에 취약한 각 form의 hidden field에 추가된다. form이 submit되면 두 패키지는 hidden field value를 check하고 cookie 값이 매칭되는 지 확인한다.
우리는 justinas/nosurf
패키지를 사용할 것이다.
go get github.com/justinas/nosurf@v1
설치해보고 사용해보도록 하자. cmd/web/middleware.go
파일을 열고 noSurf()
함수를 만들어보도록 하자.
cmd/web/middleware.go
// Create a NoSurf middleware function which uses a customized CSRF cookie with
// the Secure, Path and HttpOnly flags set.
func noSurf(next http.Handler) http.Handler {
csrfHandler := nosurf.New(next)
csrfHandler.SetBaseCookie(http.Cookie{
HttpOnly: true,
Path: "/",
Secure: true,
})
return csrfHandler
}
우리가 CSRF 공격으로부터 지키기 위한 form들 중 하나는 logout form이다. 이는 base.layout.tmpl
파일에 있으므로, 모든 페이지에서 노출될 가능이 있다. 때문에 noSurf
middleware를 우리의 application 모든 route에 넣어주는 것이 좋다. 단, /static/
은 할 필요가 없다.
그래서 cmd/web/routes.go
파일에 noSurf
미들웨어를 dynamicMiddleware
에 추가하도록 하자.
package main
import (
"net/http"
"github.com/bmizerany/pat"
"github.com/justinas/alice"
)
func (app *application) routes() http.Handler {
standardMiddleware := alice.New(app.recoverPanic, app.logRequest, secureHeaders)
// Use the nosurf middleware on all our 'dynamic' routes.
dynamicMiddleware := alice.New(app.session.Enable, noSurf)
authenticatedMiddleware := dynamicMiddleware.Append(app.requireAuthentication)
mux := pat.New()
mux.Get("/", dynamicMiddleware.ThenFunc(app.home))
mux.Get("/snippet/create", authenticatedMiddleware.ThenFunc(app.createSnippetForm))
mux.Post("/snippet/create", authenticatedMiddleware.ThenFunc(app.createSnippet))
mux.Get("/snippet/:id", dynamicMiddleware.ThenFunc(app.showSnippet))
// Add the five new routes.
mux.Get("/user/signup", dynamicMiddleware.ThenFunc(app.signupUserForm))
mux.Post("/user/signup", dynamicMiddleware.ThenFunc(app.signupUser))
mux.Get("/user/login", dynamicMiddleware.ThenFunc(app.loginUserForm))
mux.Post("/user/login", dynamicMiddleware.ThenFunc(app.loginUser))
mux.Post("/user/logout", authenticatedMiddleware.ThenFunc(app.logoutUser))
fileServer := http.FileServer(http.Dir("./ui/static/"))
mux.Get("/static/", http.StripPrefix("/static", fileServer))
return standardMiddleware.Then(mux)
}
서버를 실행하여 잘 동작하는 지를 확인하고 싶겠지만, 아직 완성된 것은 아니다. 쿠키에 대한 설정은 끝났지만 CSRF token은 아직 설정되지 않았다.
즉, 각 form에 CSRF token을 hidden field에 넣어주어 매 요청마다 CSRF token을 확인하면 된다.
page에 전송되는 templateData
에 CSRFToken
을 추가하여 전송하도록 하자.
cmd/web/templates.go
...
type templateData struct {
CSRFToken string
CurrentYear int
Flash string
Form *forms.Form
IsAuthenticated bool
Snippet *models.Snippet
Snippets []*models.Snippet
}
...
logout form은 잠재적으로 모든 페이지에서 나올 수 있으므로, CSRF token을 모든 template data에 추가하는 것이 좋다. 그래서 addDefaultData()
helper에 넣으면 된다.
cmd/web/helpers.go
...
func (app *application) addDefaultData(td *templateData, r *http.Request) *templateData {
if td == nil {
td = &templateData{}
}
td.CSRFToken = nosurf.Token(r)
td.CurrentYear = time.Now().Year()
td.Flash = app.session.PopString(r, "flash")
td.IsAuthenticated = app.isAuthenticated(r)
return td
}
...
이제 우리의 모든 form들을 수정하여 token을 사용할 수 있도록 하자.
ui/html/base.layout.tmpl
{{define "base"}}
<!doctype html>
<html lang='en'>
<head>
<meta charset='utf-8'>
<title>{{template "title" .}} - Snippetbox</title>
<link rel='stylesheet' href='/static/css/main.css'>
<link rel='shortcut icon' href='/static/img/favicon.ico' type='image/x-icon'>
<link rel='stylesheet' href='https://fonts.googleapis.com/css?family=Ubuntu+Mono:400,700'>
</head>
<body>
<header>
<h1><a href='/'>Snippetbox</a></h1>
</header>
<nav>
<!-- Update the navigation to include signup, login and logout links -->
<div>
<a href='/'>Home</a>
<!-- Toggle the navigation link -->
{{if .IsAuthenticated}}
<a href='/snippet/create'>Create snippet</a>
{{end}}
</div>
<div>
<!-- Toggle the navigation links -->
{{if .IsAuthenticated}}
<form action='/user/logout' method='POST'>
<!-- Include the CSRF token -->
<input type='hidden' name='csrf_token' value='{{.CSRFToken}}'>
<button>Logout</button>
</form>
{{else}}
<a href='/user/signup'>Signup</a>
<a href='/user/login'>Login</a>
{{end}}
</div>
</nav>
<main>
{{with .Flash}}
<div class='flash'>{{.}}</div>
{{end}}
{{template "main" .}}
</main>
{{template "footer" .}}
<script src="/static/js/main.js" type="text/javascript"></script>
</body>
</html>
{{end}}
ui/html/create.page.tmpl
{{template "base" .}}
{{define "title"}}Create a New Snippet{{end}}
{{define "main"}}
<form action='/snippet/create' method='POST'>
<!-- Include the CSRF token -->
<input type='hidden' name='csrf_token' value='{{.CSRFToken}}'>
{{with .Form}}
<div>
<label>Title:</label>
{{with .Errors.Get "title"}}
<label class='error'>{{.}}</label>
{{end}}
<input type='text' name='title' value='{{.Get "title"}}'>
</div>
<div>
<label>Content:</label>
{{with .Errors.Get "content"}}
<label class='error'>{{.}}</label>
{{end}}
<textarea name='content'>{{.Get "content"}}</textarea>
</div>
<div>
<label>Delete in:</label>
{{with .Errors.Get "expires"}}
<label class='error'>{{.}}</label>
{{end}}
{{$exp := or (.Get "expires") "365"}}
<input type='radio' name='expires' value='365' {{if (eq $exp "365")}}checked{{end}}> One Year
<input type='radio' name='expires' value='7' {{if (eq $exp "7")}}checked{{end}}> One Week
<input type='radio' name='expires' value='1' {{if (eq $exp "1")}}checked{{end}}> One Day
</div>
<div>
<input type='submit' value='Publish snippet'>
</div>
{{end}}
</form>
{{end}}
ui/html/login.page.tmpl
{{template "base" .}}
{{define "title"}}Login{{end}}
{{define "main"}}
<form action='/user/login' method='POST' novalidate>
<!-- Include the CSRF token -->
<input type='hidden' name='csrf_token' value='{{.CSRFToken}}'>
{{with .Form}}
{{with .Errors.Get "generic"}}
<div class='error'>{{.}}</div>
{{end}}
<div>
<label>Email:</label>
<input type='email' name='email' value='{{.Get "email"}}'>
</div>
<div>
<label>Password:</label>
<input type='password' name='password'>
</div>
<div>
<input type='submit' value='Login'>
</div>
{{end}}
</form>
{{end}}
ui/html/signup.page.tmpl
{{template "base" .}}
{{define "title"}}Signup{{end}}
{{define "main"}}
<form action='/user/signup' method='POST' novalidate>
<!-- Include the CSRF token -->
<input type='hidden' name='csrf_token' value='{{.CSRFToken}}'>
{{with .Form}}
<div>
<label>Name:</label>
{{with .Errors.Get "name"}}
<label class='error'>{{.}}</label>
{{end}}
<input type='text' name='name' value='{{.Get "name"}}'>
</div>
<div>
<label>Email:</label>
{{with .Errors.Get "email"}}
<label class='error'>{{.}}</label>
{{end}}
<input type='email' name='email' value='{{.Get "email"}}'>
</div>
<div>
<label>Password:</label>
{{with .Errors.Get "password"}}
<label class='error'>{{.}}</label>
{{end}}
<input type='password' name='password'>
</div>
<div>
<input type='submit' value='Signup'>
</div>
{{end}}
</form>
{{end}}
이제 모든 설정이 끝났고, CSRF token이 숨겨져 있다. 잘되는 지 실행해보도록 하자.
개발자 디버깅으로 확인할 때 CSRF token
이 있다면 성공한 것이다.