├── .travis.yml ├── README.md ├── LICENSE ├── http_handler.go ├── charlie.go ├── http_handler_test.go └── charlie_test.go /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | go: 3 | - 1.3.3 4 | notifications: 5 | # See http://about.travis-ci.org/docs/user/build-configuration/ to learn more 6 | # about configuring notification recipients and more. 7 | email: 8 | recipients: 9 | - coda.hale@gmail.com 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | charlie 2 | ======= 3 | 4 | [![Build Status](https://travis-ci.org/codahale/charlie.png?branch=master)](https://travis-ci.org/codahale/charlie) 5 | 6 | Charlie provides a fast, safe, stateless mechanism for adding CSRF protection to 7 | web applications. 8 | 9 | For documentation, check [godoc](http://godoc.org/github.com/codahale/charlie). 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Coda Hale 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /http_handler.go: -------------------------------------------------------------------------------- 1 | package charlie 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | "time" 7 | ) 8 | 9 | // HTTPParams provides configuration for wrapping an http.Handler 10 | // to check the validity of a CSRF token before permitting a request. 11 | type HTTPParams struct { 12 | InvalidHandler http.Handler 13 | 14 | Key []byte 15 | 16 | CSRFCookie string 17 | CSRFHeader string 18 | 19 | SessionCookie string 20 | SessionHeader string 21 | } 22 | 23 | // Wrap wraps an http.Handler to check the validity of a CSRF token. 24 | // It only serves requests where a valid ID/token pair can be found in 25 | // either the request headers or cookies. Otherwise, it calls the InvalidHandler 26 | // or returns an empty 403. 27 | func (hp *HTTPParams) Wrap(h http.Handler) http.Handler { 28 | csrf := New(hp.Key) 29 | csrf.MaxAge = 3 * time.Hour 30 | 31 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 32 | token := headerOrCookieValue(r, hp.CSRFHeader, hp.CSRFCookie) 33 | id := headerOrCookieValue(r, hp.SessionHeader, hp.SessionCookie) 34 | 35 | var valid bool 36 | 37 | if token != "" && id != "" { 38 | err := csrf.Validate(id, token) 39 | if err == nil { 40 | valid = true 41 | } else if err != ErrInvalidToken { 42 | // This should never occur 43 | panic(err) 44 | } 45 | } 46 | 47 | if valid { 48 | h.ServeHTTP(w, r) 49 | } else if hp.InvalidHandler != nil { 50 | hp.InvalidHandler.ServeHTTP(w, r) 51 | } else { 52 | log.Printf("Rejected request with an invalid CSRF token=%q for session=%q. (event=csrf_invalid)", 53 | token, id) 54 | w.WriteHeader(http.StatusForbidden) 55 | } 56 | }) 57 | } 58 | 59 | func headerOrCookieValue(r *http.Request, headerName, cookieName string) string { 60 | if headerName != "" { 61 | token := r.Header.Get(headerName) 62 | if token != "" { 63 | return token 64 | } 65 | } 66 | 67 | if cookieName != "" { 68 | cookie, err := r.Cookie(cookieName) 69 | if err == nil { 70 | return cookie.Value 71 | } else if err != http.ErrNoCookie { 72 | // This should never occur 73 | panic(err) 74 | } 75 | } 76 | 77 | return "" 78 | } 79 | -------------------------------------------------------------------------------- /charlie.go: -------------------------------------------------------------------------------- 1 | // Package charlie provides a fast, safe, stateless mechanism for adding CSRF 2 | // protection to web applications. 3 | // 4 | // Charlie generates per-request tokens, which resist modern web attacks like 5 | // BEAST, BREACH, CRIME, TIME, and Lucky 13, as well as web attacks of the 6 | // future, like CONDOR, BEETLEBUTT, NINJAFACE, and TacoTacoPopNLock 7 | // Quasi-Chunking. In addition, the fact that Charlie tokens are stateless means 8 | // their usage is dramatically simpler than most CSRF countermeasures--simply 9 | // return a token with each response and require a token with each authenticated 10 | // request. 11 | // 12 | // A token is a 32-bit Unix epoch timestamp, concatenated with the 13 | // HMAC-SHA256-128 MAC of both the timestamp and the user's identity (or session 14 | // ID). This is a rapidly changing value, making tokens indistinguishable from 15 | // random data to an attacker performing an online attack. 16 | // 17 | // Generation and validation each take ~4us on modern hardware, and the tokens 18 | // themselves are only 28 bytes long. 19 | package charlie 20 | 21 | import ( 22 | "crypto/hmac" 23 | "crypto/sha256" 24 | "encoding/base64" 25 | "encoding/binary" 26 | "errors" 27 | "time" 28 | ) 29 | 30 | var ( 31 | // ErrInvalidToken is returned when the provided token is invalid. 32 | ErrInvalidToken = errors.New("invalid token") 33 | ) 34 | 35 | // Params are the parameters used for generating and validating tokens. 36 | type Params struct { 37 | key []byte 38 | timer func() time.Time 39 | 40 | MaxAge time.Duration // MaxAge is the maximum age of tokens. 41 | } 42 | 43 | // New returns a new set of parameters given a key. 44 | func New(key []byte) *Params { 45 | k := make([]byte, len(key)) 46 | copy(k, key) 47 | return &Params{ 48 | key: k, 49 | timer: time.Now, 50 | MaxAge: 10 * time.Minute, 51 | } 52 | } 53 | 54 | // Generate returns a new token for the given user. 55 | func (p *Params) Generate(id string) string { 56 | buf := make([]byte, dataSize, dataSize+macSize) 57 | binary.BigEndian.PutUint32(buf, uint32(p.timer().Unix())) 58 | token := append(buf, hmacSHA256(p.key, buf, id)...) 59 | return base64.URLEncoding.EncodeToString(token) 60 | } 61 | 62 | // Validate validates the given token for the given user. 63 | func (p *Params) Validate(id, token string) error { 64 | data, err := base64.URLEncoding.DecodeString(token) 65 | if err != nil || len(data) < dataSize+macSize { 66 | return ErrInvalidToken 67 | } 68 | 69 | mac := data[dataSize:][:macSize] 70 | data = data[:dataSize] 71 | if !hmac.Equal(hmacSHA256(p.key, data, id), mac) { 72 | return ErrInvalidToken 73 | } 74 | 75 | t := time.Unix(int64(binary.BigEndian.Uint32(data)), 0) 76 | if p.timer().Sub(t) > p.MaxAge { 77 | return ErrInvalidToken 78 | } 79 | 80 | return nil 81 | } 82 | 83 | const ( 84 | dataSize = 4 // 32-bit timestamps 85 | macSize = 16 86 | ) 87 | 88 | func hmacSHA256(key, data []byte, id string) []byte { 89 | h := hmac.New(sha256.New, key) 90 | _, _ = h.Write(data) 91 | _, _ = h.Write([]byte(id)) 92 | return h.Sum(nil)[:macSize] 93 | } 94 | -------------------------------------------------------------------------------- /http_handler_test.go: -------------------------------------------------------------------------------- 1 | package charlie 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | const ( 11 | testCSRFHeader = "csrf-hdr" 12 | testCSRFCookie = "csrf-ck" 13 | testSessionHeader = "s-hdr" 14 | testSessionCookie = "s-ck" 15 | testKey = "superdupersecret" 16 | testSessionID = "mysession" 17 | ) 18 | 19 | var noContentHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 20 | w.WriteHeader(204) 21 | }) 22 | 23 | func TestHTTPWrapping(t *testing.T) { 24 | v := HTTPParams{ 25 | Key: []byte(testKey), 26 | CSRFHeader: testCSRFHeader, 27 | CSRFCookie: testCSRFCookie, 28 | SessionCookie: testSessionCookie, 29 | SessionHeader: testSessionHeader, 30 | } 31 | 32 | csrf := New(v.Key) 33 | token := csrf.Generate(testSessionID) 34 | 35 | handler := v.Wrap(noContentHandler) 36 | 37 | // Valid pair in cookies 38 | req := http.Request{Header: http.Header{}} 39 | req.AddCookie(&http.Cookie{ 40 | Name: testCSRFCookie, 41 | Value: token, 42 | Expires: time.Now().AddDate(10, 0, 0), 43 | }) 44 | req.AddCookie(&http.Cookie{ 45 | Name: testSessionCookie, 46 | Value: testSessionID, 47 | Expires: time.Now().AddDate(10, 0, 0), 48 | }) 49 | 50 | res := httptest.ResponseRecorder{} 51 | handler.ServeHTTP(&res, &req) 52 | if res.Code != 204 { 53 | t.Errorf("Expected to receive a 204 with correct CSRF token, got %d", res.Code) 54 | } 55 | 56 | // Valid pair in headers 57 | hdr := http.Header{} 58 | hdr.Set(testCSRFHeader, token) 59 | hdr.Set(testSessionHeader, testSessionID) 60 | 61 | res = httptest.ResponseRecorder{} 62 | handler.ServeHTTP(&res, &http.Request{Header: hdr}) 63 | if res.Code != 204 { 64 | t.Fatalf("Expected to receive a 204 with correct CSRF token, got %d", res.Code) 65 | } 66 | 67 | // Incorrect session/token pair 68 | hdr.Set(testSessionHeader, "notasession") 69 | 70 | res = httptest.ResponseRecorder{} 71 | handler.ServeHTTP(&res, &http.Request{Header: hdr}) 72 | if res.Code != http.StatusForbidden { 73 | t.Errorf("Expected to receive a 403 with an incorrect session, got %d", res.Code) 74 | } 75 | 76 | // Missing session header 77 | hdr.Del(testSessionHeader) 78 | 79 | res = httptest.ResponseRecorder{} 80 | handler.ServeHTTP(&res, &http.Request{Header: hdr}) 81 | if res.Code != http.StatusForbidden { 82 | t.Errorf("Expected to receive a 403 with an incorrect session, got %d", res.Code) 83 | } 84 | 85 | // Custom InvalidHandler 86 | v.InvalidHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 87 | w.WriteHeader(444) 88 | }) 89 | 90 | res = httptest.ResponseRecorder{} 91 | handler.ServeHTTP(&res, &http.Request{Header: hdr}) 92 | if res.Code != 444 { 93 | t.Errorf("Expected to receive a 444 with a custom handler, got %d", res.Code) 94 | } 95 | } 96 | 97 | func TestHTTPWrappingMisconfiguration(t *testing.T) { 98 | v := HTTPParams{} 99 | 100 | handler := v.Wrap(noContentHandler) 101 | 102 | res := httptest.ResponseRecorder{} 103 | handler.ServeHTTP(&res, &http.Request{}) 104 | if res.Code != http.StatusForbidden { 105 | t.Fatalf("Expected to receive a 403 without configuration, got %d", res.Code) 106 | } 107 | 108 | v.Key = []byte(testKey) 109 | 110 | res = httptest.ResponseRecorder{} 111 | handler.ServeHTTP(&res, &http.Request{}) 112 | if res.Code != http.StatusForbidden { 113 | t.Fatalf("Expected to receive a 403 with missing header/cookie configuration, got %d", res.Code) 114 | } 115 | 116 | } 117 | -------------------------------------------------------------------------------- /charlie_test.go: -------------------------------------------------------------------------------- 1 | package charlie 2 | 3 | import ( 4 | "encoding/base64" 5 | "net/http" 6 | "sync" 7 | "testing" 8 | "time" 9 | ) 10 | 11 | func Example() { 12 | // create a new TokenParams 13 | params := New([]byte("yay for dumbledore")) 14 | 15 | http.HandleFunc("/secure", func(w http.ResponseWriter, r *http.Request) { 16 | sessionID := r.Header.Get("Session-ID") 17 | 18 | // validate the token, if any 19 | token := r.Header.Get("CSRF-Token") 20 | if err := params.Validate(sessionID, token); err != nil { 21 | http.Error(w, "Invalid CSRF token", http.StatusBadRequest) 22 | return 23 | } 24 | 25 | // generate a new token for the response 26 | w.Header().Add("CSRF-Token", params.Generate(sessionID)) 27 | 28 | // handle actual request 29 | // ... 30 | }) 31 | } 32 | 33 | var params = New([]byte("ayellowsubmarine")) 34 | 35 | func TestRoundTrip(t *testing.T) { 36 | token := params.Generate("woo") 37 | 38 | if err := params.Validate("woo", token); err != nil { 39 | t.Fatal(err) 40 | } 41 | } 42 | 43 | func TestTokenLength(t *testing.T) { 44 | token := params.Generate("woo") 45 | 46 | if v, want := len(token), 28; v != want { 47 | t.Errorf("Token length was %d, but expected %d", v, want) 48 | } 49 | } 50 | 51 | func TestEmptyToken(t *testing.T) { 52 | if err := params.Validate("woo", ""); err != ErrInvalidToken { 53 | t.Errorf("Error was %v, but expected ErrInvalidToken", err) 54 | } 55 | } 56 | 57 | func TestRoundTripConcurrent(t *testing.T) { 58 | tokens := make(chan string, 100) 59 | 60 | producers := 10 61 | wgP := new(sync.WaitGroup) 62 | wgP.Add(producers) 63 | 64 | consumers := 10 65 | wgC := new(sync.WaitGroup) 66 | wgC.Add(consumers) 67 | 68 | for i := 0; i < producers; i++ { 69 | go func() { 70 | defer wgP.Done() 71 | for j := 0; j < 1000; j++ { 72 | tokens <- params.Generate("woo") 73 | } 74 | }() 75 | } 76 | 77 | for i := 0; i < consumers; i++ { 78 | go func() { 79 | defer wgC.Done() 80 | for token := range tokens { 81 | if err := params.Validate("woo", token); err != nil { 82 | t.Fatal(err) 83 | } 84 | } 85 | }() 86 | } 87 | 88 | wgP.Wait() 89 | close(tokens) 90 | wgC.Wait() 91 | } 92 | 93 | func TestRoundTripExpired(t *testing.T) { 94 | token := params.Generate("woo") 95 | 96 | params.timer = func() time.Time { 97 | return time.Now().Add(20 * time.Minute) 98 | } 99 | defer func() { 100 | params.timer = time.Now 101 | }() 102 | 103 | if err := params.Validate("woo", token); err != ErrInvalidToken { 104 | t.Fatalf("Error was %v, but expected ErrInvalidToken", err) 105 | } 106 | } 107 | 108 | func TestRoundTripBadEncoding(t *testing.T) { 109 | token := params.Generate("woo") 110 | 111 | if err := params.Validate("woo", "A"+token); err != ErrInvalidToken { 112 | t.Fatalf("Error was %v, but expected ErrInvalidToken", err) 113 | } 114 | } 115 | 116 | func TestRoundTripBadToken(t *testing.T) { 117 | token := params.Generate("woo") 118 | 119 | b, _ := base64.URLEncoding.DecodeString(token) 120 | b[0] ^= 12 121 | token = base64.URLEncoding.EncodeToString(b) 122 | 123 | if err := params.Validate("woo", token); err != ErrInvalidToken { 124 | t.Fatalf("Error was %v, but expected ErrInvalidToken", err) 125 | } 126 | } 127 | 128 | func BenchmarkGenerate(b *testing.B) { 129 | b.ReportAllocs() 130 | b.RunParallel(func(pb *testing.PB) { 131 | for pb.Next() { 132 | params.Generate("yay") 133 | } 134 | }) 135 | } 136 | func BenchmarkValidate(b *testing.B) { 137 | token := params.Generate("yay") 138 | b.ReportAllocs() 139 | b.ResetTimer() 140 | 141 | b.RunParallel(func(pb *testing.PB) { 142 | for pb.Next() { 143 | if err := params.Validate("yay", token); err != nil { 144 | b.Fatal(err) 145 | } 146 | } 147 | }) 148 | } 149 | --------------------------------------------------------------------------------