diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..c6b74c3 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,20 @@ +; https://editorconfig.org/ + +root = true + +[*] +insert_final_newline = true +charset = utf-8 +trim_trailing_whitespace = true +indent_style = space +indent_size = 2 + +[{Makefile,go.mod,go.sum,*.go,.gitmodules}] +indent_style = tab +indent_size = 4 + +[*.md] +indent_size = 4 +trim_trailing_whitespace = false + +eclint_indent_style = unset \ No newline at end of file diff --git a/.github/workflows/issues.yml b/.github/workflows/issues.yml new file mode 100644 index 0000000..768b05b --- /dev/null +++ b/.github/workflows/issues.yml @@ -0,0 +1,21 @@ +# Add all the issues created to the project. +name: Add issue or pull request to Project + +on: + issues: + types: + - opened + pull_request_target: + types: + - opened + - reopened + +jobs: + add-to-project: + runs-on: ubuntu-latest + steps: + - name: Add issue to project + uses: actions/add-to-project@v0.5.0 + with: + project-url: https://github.com/orgs/gorilla/projects/4 + github-token: ${{ secrets.ADD_TO_PROJECT_TOKEN }} diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..ff4a613 --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,37 @@ +name: Security +on: + push: + branches: + - main + pull_request: + branches: + - main +permissions: + contents: read +jobs: + scan: + strategy: + matrix: + go: ['1.20','1.21'] + fail-fast: true + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v3 + + - name: Setup Go ${{ matrix.go }} + uses: actions/setup-go@v4 + with: + go-version: ${{ matrix.go }} + cache: false + + - name: Run GoSec + uses: securego/gosec@master + with: + args: -exclude-dir examples ./... + + - name: Run GoVulnCheck + uses: golang/govulncheck-action@v1 + with: + go-version-input: ${{ matrix.go }} + go-package: ./... diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..50a3946 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,35 @@ +name: Test +on: + push: + branches: + - main + pull_request: + branches: + - main +permissions: + contents: read +jobs: + unit: + strategy: + matrix: + go: ['1.20','1.21'] + os: [ubuntu-latest, macos-latest, windows-latest] + fail-fast: true + runs-on: ${{ matrix.os }} + steps: + - name: Checkout Code + uses: actions/checkout@v3 + + - name: Setup Go ${{ matrix.go }} + uses: actions/setup-go@v4 + with: + go-version: ${{ matrix.go }} + cache: false + + - name: Run Tests + run: go test -race -cover -coverprofile=coverage -covermode=atomic -v ./... + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 + with: + files: ./coverage diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml new file mode 100644 index 0000000..a3eb74b --- /dev/null +++ b/.github/workflows/verify.yml @@ -0,0 +1,32 @@ +name: Verify +on: + push: + branches: + - main + pull_request: + branches: + - main +permissions: + contents: read +jobs: + lint: + strategy: + matrix: + go: ['1.20','1.21'] + fail-fast: true + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v3 + + - name: Setup Go ${{ matrix.go }} + uses: actions/setup-go@v4 + with: + go-version: ${{ matrix.go }} + cache: false + + - name: Run GolangCI-Lint + uses: golangci/golangci-lint-action@v3 + with: + version: v1.53 + args: --timeout=5m diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..84039fe --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +coverage.coverprofile diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 13b50cd..0000000 --- a/.travis.yml +++ /dev/null @@ -1,22 +0,0 @@ -language: go -sudo: false - -matrix: - include: - - go: 1.2 - - go: 1.3 - - go: 1.4 - - go: 1.5 - - go: 1.6 - - go: tip - allow_failures: - - go: tip - -install: - - # skip - -script: - - go get -t -v ./... - - diff -u <(echo -n) <(gofmt -d .) - - go vet $(go list ./... | grep -v /vendor/) - - go test -v -race ./... diff --git a/Gopkg.lock b/Gopkg.lock new file mode 100644 index 0000000..5db9540 --- /dev/null +++ b/Gopkg.lock @@ -0,0 +1,27 @@ +# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. + + +[[projects]] + name = "github.com/gorilla/context" + packages = ["."] + revision = "1ea25387ff6f684839d82767c1733ff4d4d15d0a" + version = "v1.1" + +[[projects]] + name = "github.com/gorilla/securecookie" + packages = ["."] + revision = "667fe4e3466a040b780561fe9b51a83a3753eefc" + version = "v1.1" + +[[projects]] + name = "github.com/pkg/errors" + packages = ["."] + revision = "645ef00459ed84a119197bfb8d8205042c6df63d" + version = "v0.8.0" + +[solve-meta] + analyzer-name = "dep" + analyzer-version = 1 + inputs-digest = "1695686bc8fa0eb76df9fe8c5ca473686071ddcf795a0595a9465a03e8ac9bef" + solver-name = "gps-cdcl" + solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml new file mode 100644 index 0000000..02837b1 --- /dev/null +++ b/Gopkg.toml @@ -0,0 +1,34 @@ + +# Gopkg.toml example +# +# Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md +# for detailed Gopkg.toml documentation. +# +# required = ["github.com/user/thing/cmd/thing"] +# ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"] +# +# [[constraint]] +# name = "github.com/user/project" +# version = "1.0.0" +# +# [[constraint]] +# name = "github.com/user/project2" +# branch = "dev" +# source = "github.com/myfork/project2" +# +# [[override]] +# name = "github.com/x/y" +# version = "2.4.0" + + +[[constraint]] + name = "github.com/gorilla/context" + version = "1.1.0" + +[[constraint]] + name = "github.com/gorilla/securecookie" + version = "1.1.0" + +[[constraint]] + name = "github.com/pkg/errors" + version = "0.8.0" diff --git a/LICENSE b/LICENSE index f407b7f..bb9d80b 100644 --- a/LICENSE +++ b/LICENSE @@ -1,27 +1,27 @@ -Copyright (c) 2015, Matt Silverlock (matt@eatsleeprepeat.net) All rights -reserved. +Copyright (c) 2023 The Gorilla Authors. All rights reserved. -Redistribution and use in source and binary forms, with or without modification, -are permitted provided that the following conditions are met: +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: -1. Redistributions of source code must retain the above copyright notice, this -list of conditions and the following disclaimer. + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. -2. Redistributions in binary form must reproduce the above copyright notice, -this list of conditions and the following disclaimer in the documentation and/or -other materials provided with the distribution. - -3. Neither the name of the copyright holder nor the names of its contributors -may be used to endorse or promote products derived from this software without -specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR -ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON -ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ac37ffd --- /dev/null +++ b/Makefile @@ -0,0 +1,34 @@ +GO_LINT=$(shell which golangci-lint 2> /dev/null || echo '') +GO_LINT_URI=github.com/golangci/golangci-lint/cmd/golangci-lint@latest + +GO_SEC=$(shell which gosec 2> /dev/null || echo '') +GO_SEC_URI=github.com/securego/gosec/v2/cmd/gosec@latest + +GO_VULNCHECK=$(shell which govulncheck 2> /dev/null || echo '') +GO_VULNCHECK_URI=golang.org/x/vuln/cmd/govulncheck@latest + +.PHONY: golangci-lint +golangci-lint: + $(if $(GO_LINT), ,go install $(GO_LINT_URI)) + @echo "##### Running golangci-lint" + golangci-lint run -v + +.PHONY: gosec +gosec: + $(if $(GO_SEC), ,go install $(GO_SEC_URI)) + @echo "##### Running gosec" + gosec ./... + +.PHONY: govulncheck +govulncheck: + $(if $(GO_VULNCHECK), ,go install $(GO_VULNCHECK_URI)) + @echo "##### Running govulncheck" + govulncheck ./... + +.PHONY: verify +verify: golangci-lint gosec govulncheck + +.PHONY: test +test: + @echo "##### Running tests" + go test -race -cover -coverprofile=coverage.coverprofile -covermode=atomic -v ./... diff --git a/README.md b/README.md index 02e4f42..d768c81 100644 --- a/README.md +++ b/README.md @@ -1,43 +1,64 @@ # gorilla/csrf -[![GoDoc](https://godoc.org/github.com/gorilla/csrf?status.svg)](https://godoc.org/github.com/gorilla/csrf) [![Build Status](https://travis-ci.org/gorilla/csrf.svg?branch=master)](https://travis-ci.org/gorilla/csrf) + +![testing](https://github.com/gorilla/csrf/actions/workflows/test.yml/badge.svg) +[![codecov](https://codecov.io/github/gorilla/csrf/branch/main/graph/badge.svg)](https://codecov.io/github/gorilla/csrf) +[![godoc](https://godoc.org/github.com/gorilla/csrf?status.svg)](https://godoc.org/github.com/gorilla/csrf) +[![sourcegraph](https://sourcegraph.com/github.com/gorilla/csrf/-/badge.svg)](https://sourcegraph.com/github.com/gorilla/csrf?badge) + + +![Gorilla Logo](https://github.com/gorilla/.github/assets/53367916/d92caabf-98e0-473e-bfbf-ab554ba435e5) gorilla/csrf is a HTTP middleware library that provides [cross-site request forgery](http://blog.codinghorror.com/preventing-csrf-and-xsrf-attacks/) (CSRF) - protection. It includes: +protection. It includes: -* The `csrf.Protect` middleware/handler provides CSRF protection on routes +- The `csrf.Protect` middleware/handler provides CSRF protection on routes attached to a router or a sub-router. -* A `csrf.Token` function that provides the token to pass into your response, +- A `csrf.Token` function that provides the token to pass into your response, whether that be a HTML form or a JSON response body. -* ... and a `csrf.TemplateField` helper that you can pass into your `html/template` +- ... and a `csrf.TemplateField` helper that you can pass into your `html/template` templates to replace a `{{ .csrfField }}` template tag with a hidden input field. gorilla/csrf is designed to work with any Go web framework, including: -* The [Gorilla](http://www.gorillatoolkit.org/) toolkit -* Go's built-in [net/http](http://golang.org/pkg/net/http/) package -* [Goji](https://goji.io) - see the [tailored fork](https://github.com/goji/csrf) -* [Gin](https://github.com/gin-gonic/gin) -* [Echo](https://github.com/labstack/echo) -* ... and any other router/framework that rallies around Go's `http.Handler` interface. +- The [Gorilla](https://www.gorillatoolkit.org/) toolkit +- Go's built-in [net/http](http://golang.org/pkg/net/http/) package +- [Goji](https://goji.io) - see the [tailored fork](https://github.com/goji/csrf) +- [Gin](https://github.com/gin-gonic/gin) +- [Echo](https://github.com/labstack/echo) +- ... and any other router/framework that rallies around Go's `http.Handler` interface. gorilla/csrf is also compatible with middleware 'helper' libraries like [Alice](https://github.com/justinas/alice) and [Negroni](https://github.com/codegangsta/negroni). +## Contents + + * [Install](#install) + * [Examples](#examples) + + [HTML Forms](#html-forms) + + [JavaScript Applications](#javascript-applications) + + [Google App Engine](#google-app-engine) + + [Setting SameSite](#setting-samesite) + + [Setting Options](#setting-options) + * [Design Notes](#design-notes) + * [License](#license) + ## Install With a properly configured Go toolchain: + ```sh go get github.com/gorilla/csrf ``` ## Examples -* [HTML Forms](#html-forms) -* [JavaScript Apps](#javascript-applications) -* [Google App Engine](#google-app-engine) -* [Setting Options](#setting-options) +- [HTML Forms](#html-forms) +- [JavaScript Apps](#javascript-applications) +- [Google App Engine](#google-app-engine) +- [Setting SameSite](#setting-samesite) +- [Setting Options](#setting-options) gorilla/csrf is easy to use: add the middleware to your router with the below: @@ -50,9 +71,12 @@ http.ListenAndServe(":8000", CSRF(r)) ...and then collect the token with `csrf.Token(r)` in your handlers before passing it to the template, JSON body or HTTP header (see below). -Note that the authentication key passed to `csrf.Protect([]byte(key))` should be -32-bytes long and persist across application restarts. Generating a random key -won't allow you to authenticate existing cookies and will break your CSRF +Note that the authentication key passed to `csrf.Protect([]byte(key))` should: +- be 32-bytes long +- persist across application restarts. +- kept secret from potential malicious users - do not hardcode it into the source code, especially not in open-source applications. + +Generating a random key won't allow you to authenticate existing cookies and will break your CSRF validation. gorilla/csrf inspects the HTTP headers (first) and form body (second) on @@ -77,7 +101,10 @@ func main() { r := mux.NewRouter() r.HandleFunc("/signup", ShowSignupForm) // All POST requests without a valid token will return HTTP 403 Forbidden. - r.HandleFunc("/signup/post", SubmitSignupForm) + // We should also ensure that our mutating (non-idempotent) handler only + // matches on POST requests. We can check that here, at the router level, or + // within the handler itself via r.Method. + r.HandleFunc("/signup/post", SubmitSignupForm).Methods("POST") // Add the middleware to your router by wrapping it. http.ListenAndServe(":8000", @@ -89,12 +116,12 @@ func main() { func ShowSignupForm(w http.ResponseWriter, r *http.Request) { // signup_form.tmpl just needs a {{ .csrfField }} template tag for // csrf.TemplateField to inject the CSRF token into. Easy! - t.ExecuteTemplate(w, "signup_form.tmpl", map[string]interface{ + t.ExecuteTemplate(w, "signup_form.tmpl", map[string]interface{}{ csrf.TemplateTag: csrf.TemplateField(r), }) // We could also retrieve the token directly from csrf.Token(r) and // set it in the request header - w.Header.Set("X-CSRF-Token", token) - // This is useful if your sending JSON to clients or a front-end JavaScript + // This is useful if you're sending JSON to clients or a front-end JavaScript // framework. } @@ -112,10 +139,18 @@ body. ### JavaScript Applications This approach is useful if you're using a front-end JavaScript framework like -React, Ember or Angular, or are providing a JSON API. +React, Ember or Angular, and are providing a JSON API. Specifically, we need +to provide a way for our front-end fetch/AJAX calls to pass the token on each +fetch (AJAX/XMLHttpRequest) request. We achieve this by: + +- Parsing the token from the `` field generated by the + `csrf.TemplateField(r)` helper, or passing it back in a response header. +- Sending this token back on every request +- Ensuring our cookie is attached to the request so that the form/header + value can be compared to the cookie value. We'll also look at applying selective CSRF protection using -[gorilla/mux's](http://www.gorillatoolkit.org/pkg/mux) sub-routers, +[gorilla/mux's](https://www.gorillatoolkit.org/pkg/mux) sub-routers, as we don't handle any POST/PUT/DELETE requests with our top-level router. ```go @@ -128,12 +163,82 @@ import ( func main() { r := mux.NewRouter() + csrfMiddleware := csrf.Protect([]byte("32-byte-long-auth-key")) api := r.PathPrefix("/api").Subrouter() - api.HandleFunc("/user/:id", GetUser).Methods("GET") + api.Use(csrfMiddleware) + api.HandleFunc("/user/{id}", GetUser).Methods("GET") - http.ListenAndServe(":8000", - csrf.Protect([]byte("32-byte-long-auth-key"))(r)) + http.ListenAndServe(":8000", r) +} + +func GetUser(w http.ResponseWriter, r *http.Request) { + // Authenticate the request, get the id from the route params, + // and fetch the user from the DB, etc. + + // Get the token and pass it in the CSRF header. Our JSON-speaking client + // or JavaScript framework can now read the header and return the token in + // in its own "X-CSRF-Token" request header on the subsequent POST. + w.Header().Set("X-CSRF-Token", csrf.Token(r)) + b, err := json.Marshal(user) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + + w.Write(b) +} +``` + +In our JavaScript application, we should read the token from the response +headers and pass it in a request header for all requests. Here's what that +looks like when using [Axios](https://github.com/axios/axios), a popular +JavaScript HTTP client library: + +```js +// You can alternatively parse the response header for the X-CSRF-Token, and +// store that instead, if you followed the steps above to write the token to a +// response header. +let csrfToken = document.getElementsByName("gorilla.csrf.Token")[0].value + +// via https://github.com/axios/axios#creating-an-instance +const instance = axios.create({ + baseURL: "https://example.com/api/", + timeout: 1000, + headers: { "X-CSRF-Token": csrfToken } +}) + +// Now, any HTTP request you make will include the csrfToken from the page, +// provided you update the csrfToken variable for each render. +try { + let resp = await instance.post(endpoint, formData) + // Do something with resp +} catch (err) { + // Handle the exception +} +``` + +If you plan to host your JavaScript application on another domain, you can use the Trusted Origins +feature to allow the host of your JavaScript application to make requests to your Go application. Observe the example below: + + +```go +package main + +import ( + "github.com/gorilla/csrf" + "github.com/gorilla/mux" +) + +func main() { + r := mux.NewRouter() + csrfMiddleware := csrf.Protect([]byte("32-byte-long-auth-key"), csrf.TrustedOrigins([]string{"ui.domain.com"})) + + api := r.PathPrefix("/api").Subrouter() + api.Use(csrfMiddleware) + api.HandleFunc("/user/{id}", GetUser).Methods("GET") + + http.ListenAndServe(":8000", r) } func GetUser(w http.ResponseWriter, r *http.Request) { @@ -154,11 +259,13 @@ func GetUser(w http.ResponseWriter, r *http.Request) { } ``` +On the example above, you're authorizing requests from `ui.domain.com` to make valid CSRF requests to your application, so you can have your API server on another domain without problems. + ### Google App Engine If you're using [Google App Engine](https://cloud.google.com/appengine/docs/go/how-requests-are-handled#Go_Requests_and_HTTP), -which doesn't allow you to hook into the default `http.ServeMux` directly, +(first-generation) which doesn't allow you to hook into the default `http.ServeMux` directly, you can still use gorilla/csrf (and gorilla/mux): ```go @@ -175,6 +282,49 @@ func init() { } ``` +Note: You can ignore this if you're using the +[second-generation](https://cloud.google.com/appengine/docs/go/) Go runtime +on App Engine (Go 1.11 and above). + +### Setting SameSite + +Go 1.11 introduced the option to set the SameSite attribute in cookies. This is +valuable if a developer wants to instruct a browser to not include cookies during +a cross site request. SameSiteStrictMode prevents all cross site requests from including +the cookie. SameSiteLaxMode prevents CSRF prone requests (POST) from including the cookie +but allows the cookie to be included in GET requests to support external linking. + +```go +func main() { + CSRF := csrf.Protect( + []byte("a-32-byte-long-key-goes-here"), + // instruct the browser to never send cookies during cross site requests + csrf.SameSite(csrf.SameSiteStrictMode), + ) + + r := mux.NewRouter() + r.HandleFunc("/signup", GetSignupForm) + r.HandleFunc("/signup/post", PostSignupForm) + + http.ListenAndServe(":8000", CSRF(r)) +} +``` + +### Cookie path + +By default, CSRF cookies are set on the path of the request. + +This can create issues, if the request is done from one path to a different path. + +You might want to set up a root path for all the cookies; that way, the CSRF will always work across all your paths. + +``` + CSRF := csrf.Protect( + []byte("a-32-byte-long-key-goes-here"), + csrf.Path("/"), + ) +``` + ### Setting Options What about providing your own error handler and changing the HTTP header the @@ -207,22 +357,25 @@ added, open an issue. Getting CSRF protection right is important, so here's some background: -* This library generates unique-per-request (masked) tokens as a mitigation +- This library generates unique-per-request (masked) tokens as a mitigation against the [BREACH attack](http://breachattack.com/). -* The 'base' (unmasked) token is stored in the session, which means that +- The 'base' (unmasked) token is stored in the session, which means that multiple browser tabs won't cause a user problems as their per-request token is compared with the base token. -* Operates on a "whitelist only" approach where safe (non-mutating) HTTP methods - (GET, HEAD, OPTIONS, TRACE) are the *only* methods where token validation is not +- Operates on a "whitelist only" approach where safe (non-mutating) HTTP methods + (GET, HEAD, OPTIONS, TRACE) are the _only_ methods where token validation is not enforced. -* The design is based on the battle-tested +- The design is based on the battle-tested [Django](https://docs.djangoproject.com/en/1.8/ref/csrf/) and [Ruby on Rails](http://api.rubyonrails.org/classes/ActionController/RequestForgeryProtection.html) approaches. -* Cookies are authenticated and based on the [securecookie](https://github.com/gorilla/securecookie) +- Cookies are authenticated and based on the [securecookie](https://github.com/gorilla/securecookie) library. They're also Secure (issued over HTTPS only) and are HttpOnly by default, because sane defaults are important. -* Go's `crypto/rand` library is used to generate the 32 byte (256 bit) tokens +- Cookie SameSite attribute (prevents cookies from being sent by a browser + during cross site requests) are not set by default to maintain backwards compatibility + for legacy systems. The SameSite attribute can be set with the SameSite option. +- Go's `crypto/rand` library is used to generate the 32 byte (256 bit) tokens and the one-time-pad used for masking them. This library does not seek to be adventurous. diff --git a/context.go b/context.go index d8bb42f..d24b146 100644 --- a/context.go +++ b/context.go @@ -1,26 +1,25 @@ +//go:build go1.7 // +build go1.7 package csrf import ( "context" + "fmt" "net/http" - - "github.com/pkg/errors" ) func contextGet(r *http.Request, key string) (interface{}, error) { val := r.Context().Value(key) if val == nil { - return nil, errors.Errorf("no value exists in the context for key %q", key) + return nil, fmt.Errorf("no value exists in the context for key %q", key) } - return val, nil } func contextSave(r *http.Request, key string, val interface{}) *http.Request { ctx := r.Context() - ctx = context.WithValue(ctx, key, val) + ctx = context.WithValue(ctx, key, val) // nolint:staticcheck return r.WithContext(ctx) } diff --git a/context_legacy.go b/context_legacy.go deleted file mode 100644 index f88c9eb..0000000 --- a/context_legacy.go +++ /dev/null @@ -1,28 +0,0 @@ -// +build !go1.7 - -package csrf - -import ( - "net/http" - - "github.com/gorilla/context" - - "github.com/pkg/errors" -) - -func contextGet(r *http.Request, key string) (interface{}, error) { - if val, ok := context.GetOk(r, key); ok { - return val, nil - } - - return nil, errors.Errorf("no value exists in the context for key %q", key) -} - -func contextSave(r *http.Request, key string, val interface{}) *http.Request { - context.Set(r, key, val) - return r -} - -func contextClear(r *http.Request) { - context.Clear(r) -} diff --git a/csrf.go b/csrf.go index b4b0439..5dda254 100644 --- a/csrf.go +++ b/csrf.go @@ -1,11 +1,12 @@ package csrf import ( + "context" + "errors" "fmt" "net/http" "net/url" - - "github.com/pkg/errors" + "slices" "github.com/gorilla/securecookie" ) @@ -15,14 +16,22 @@ const tokenLength = 32 // Context/session keys & prefixes const ( - tokenKey string = "gorilla.csrf.Token" - formKey string = "gorilla.csrf.Form" + tokenKey string = "gorilla.csrf.Token" // #nosec G101 + formKey string = "gorilla.csrf.Form" // #nosec G101 errorKey string = "gorilla.csrf.Error" skipCheckKey string = "gorilla.csrf.Skip" cookieName string = "_gorilla_csrf" errorPrefix string = "gorilla/csrf: " ) +type contextKey string + +// PlaintextHTTPContextKey is the context key used to store whether the request +// is being served via plaintext HTTP. This is used to signal to the middleware +// that strict Referer checking should not be enforced as is done for HTTPS by +// default. +const PlaintextHTTPContextKey contextKey = "plaintext" + var ( // The name value used in form fields. fieldName = tokenKey @@ -42,6 +51,9 @@ var ( // ErrNoReferer is returned when a HTTPS request provides an empty Referer // header. ErrNoReferer = errors.New("referer not supplied") + // ErrBadOrigin is returned when the Origin header is present and is not a + // trusted origin. + ErrBadOrigin = errors.New("origin invalid") // ErrBadReferer is returned when the scheme & host in the URL do not match // the supplied Referer header. ErrBadReferer = errors.New("referer invalid") @@ -52,6 +64,26 @@ var ( ErrBadToken = errors.New("CSRF token invalid") ) +// SameSiteMode allows a server to define a cookie attribute making it impossible for +// the browser to send this cookie along with cross-site requests. The main +// goal is to mitigate the risk of cross-origin information leakage, and provide +// some protection against cross-site request forgery attacks. +// +// See https://tools.ietf.org/html/draft-ietf-httpbis-cookie-same-site-00 for details. +type SameSiteMode int + +// SameSite options +const ( + // SameSiteDefaultMode sets the `SameSite` cookie attribute, which is + // invalid in some older browsers due to changes in the SameSite spec. These + // browsers will not send the cookie to the server. + // csrf uses SameSiteLaxMode (SameSite=Lax) as the default as of v1.7.0+ + SameSiteDefaultMode SameSiteMode = iota + 1 + SameSiteLaxMode + SameSiteStrictMode + SameSiteNoneMode +) + type csrf struct { h http.Handler sc *securecookie.SecureCookie @@ -66,12 +98,14 @@ type options struct { Path string // Note that the function and field names match the case of the associated // http.Cookie field instead of the "correct" HTTPOnly name that golint suggests. - HttpOnly bool - Secure bool - RequestHeader string - FieldName string - ErrorHandler http.Handler - CookieName string + HttpOnly bool + Secure bool + SameSite SameSiteMode + RequestHeader string + FieldName string + ErrorHandler http.Handler + CookieName string + TrustedOrigins []string } // Protect is HTTP middleware that provides Cross-Site Request Forgery @@ -86,39 +120,43 @@ type options struct { // 'Forbidden' error response. // // Example: +// // package main // // import ( -// "github.com/elithrar/protect" +// "html/template" +// +// "github.com/gorilla/csrf" // "github.com/gorilla/mux" // ) // +// var t = template.Must(template.New("signup_form.tmpl").Parse(form)) +// // func main() { -// r := mux.NewRouter() +// r := mux.NewRouter() // -// mux.HandlerFunc("/signup", GetSignupForm) -// // POST requests without a valid token will return a HTTP 403 Forbidden. -// mux.HandlerFunc("/signup/post", PostSignupForm) +// r.HandleFunc("/signup", GetSignupForm) +// // POST requests without a valid token will return a HTTP 403 Forbidden. +// r.HandleFunc("/signup/post", PostSignupForm) // -// // Add the middleware to your router. -// http.ListenAndServe(":8000", -// // Note that the authentication key provided should be 32 bytes -// // long and persist across application restarts. +// // Add the middleware to your router. +// http.ListenAndServe(":8000", +// // Note that the authentication key provided should be 32 bytes +// // long and persist across application restarts. // csrf.Protect([]byte("32-byte-long-auth-key"))(r)) // } // // func GetSignupForm(w http.ResponseWriter, r *http.Request) { // // signup_form.tmpl just needs a {{ .csrfField }} template tag for // // csrf.TemplateField to inject the CSRF token into. Easy! -// t.ExecuteTemplate(w, "signup_form.tmpl", map[string]interface{ +// t.ExecuteTemplate(w, "signup_form.tmpl", map[string]interface{}{ // csrf.TemplateTag: csrf.TemplateField(r), // }) // // We could also retrieve the token directly from csrf.Token(r) and // // set it in the request header - w.Header.Set("X-CSRF-Token", token) -// // This is useful if your sending JSON to clients or a front-end JavaScript +// // This is useful if you're sending JSON to clients or a front-end JavaScript // // framework. // } -// func Protect(authKey []byte, opts ...Option) func(http.Handler) http.Handler { return func(h http.Handler) http.Handler { cs := parseOptions(h, opts...) @@ -161,6 +199,7 @@ func Protect(authKey []byte, opts ...Option) func(http.Handler) http.Handler { maxAge: cs.opts.MaxAge, secure: cs.opts.Secure, httpOnly: cs.opts.HttpOnly, + sameSite: cs.opts.SameSite, path: cs.opts.Path, domain: cs.opts.Domain, sc: cs.sc, @@ -216,10 +255,50 @@ func (cs *csrf) ServeHTTP(w http.ResponseWriter, r *http.Request) { // HTTP methods not defined as idempotent ("safe") under RFC7231 require // inspection. if !contains(safeMethods, r.Method) { - // Enforce an origin check for HTTPS connections. As per the Django CSRF - // implementation (https://goo.gl/vKA7GE) the Referer header is almost - // always present for same-domain HTTP requests. - if r.URL.Scheme == "https" { + var isPlaintext bool + val := r.Context().Value(PlaintextHTTPContextKey) + if val != nil { + isPlaintext, _ = val.(bool) + } + + // take a copy of the request URL to avoid mutating the original + // attached to the request. + // set the scheme & host based on the request context as these are not + // populated by default for server requests + // ref: https://pkg.go.dev/net/http#Request + requestURL := *r.URL // shallow clone + + requestURL.Scheme = "https" + if isPlaintext { + requestURL.Scheme = "http" + } + if requestURL.Host == "" { + requestURL.Host = r.Host + } + + // if we have an Origin header, check it against our allowlist + origin := r.Header.Get("Origin") + if origin != "" { + parsedOrigin, err := url.Parse(origin) + if err != nil { + r = envError(r, ErrBadOrigin) + cs.opts.ErrorHandler.ServeHTTP(w, r) + return + } + if !sameOrigin(&requestURL, parsedOrigin) && !slices.Contains(cs.opts.TrustedOrigins, parsedOrigin.Host) { + r = envError(r, ErrBadOrigin) + cs.opts.ErrorHandler.ServeHTTP(w, r) + return + } + } + + // If we are serving via TLS and have no Origin header, prevent against + // CSRF via HTTP machine in the middle attacks by enforcing strict + // Referer origin checks. Consider an attacker who performs a + // successful HTTP Machine-in-the-Middle attack and uses this to inject + // a form and cause submission to our origin. We strictly disallow + // cleartext HTTP origins and evaluate the domain against an allowlist. + if origin == "" && !isPlaintext { // Fetch the Referer value. Call the error handler if it's empty or // otherwise fails to parse. referer, err := url.Parse(r.Referer()) @@ -229,23 +308,39 @@ func (cs *csrf) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - if sameOrigin(r.URL, referer) == false { + // disallow cleartext HTTP referers when serving via TLS + if referer.Scheme == "http" { + r = envError(r, ErrBadReferer) + cs.opts.ErrorHandler.ServeHTTP(w, r) + return + } + + // If the request is being served via TLS and the Referer is not the + // same origin, check the domain against our allowlist. We only + // check when we have host information from the referer. + if referer.Host != "" && referer.Host != r.Host && !slices.Contains(cs.opts.TrustedOrigins, referer.Host) { r = envError(r, ErrBadReferer) cs.opts.ErrorHandler.ServeHTTP(w, r) return } } - // If the token returned from the session store is nil for non-idempotent - // ("unsafe") methods, call the error handler. - if realToken == nil { + // Retrieve the combined token (pad + masked) token... + maskedToken, err := cs.requestToken(r) + if err != nil { + r = envError(r, ErrBadToken) + cs.opts.ErrorHandler.ServeHTTP(w, r) + return + } + + if maskedToken == nil { r = envError(r, ErrNoToken) cs.opts.ErrorHandler.ServeHTTP(w, r) return } - // Retrieve the combined token (pad + masked) token and unmask it. - requestToken := unmask(cs.requestToken(r)) + // ... and unmask it. + requestToken := unmask(maskedToken) // Compare the request token against the real token if !compareTokens(requestToken, realToken) { @@ -265,11 +360,19 @@ func (cs *csrf) ServeHTTP(w http.ResponseWriter, r *http.Request) { contextClear(r) } +// PlaintextHTTPRequest accepts as input a http.Request and returns a new +// http.Request with the PlaintextHTTPContextKey set to true. This is used to +// signal to the CSRF middleware that the request is being served over plaintext +// HTTP and that Referer-based origin allow-listing checks should be skipped. +func PlaintextHTTPRequest(r *http.Request) *http.Request { + ctx := context.WithValue(r.Context(), PlaintextHTTPContextKey, true) + return r.WithContext(ctx) +} + // unauthorizedhandler sets a HTTP 403 Forbidden status and writes the // CSRF failure reason to the response. func unauthorizedHandler(w http.ResponseWriter, r *http.Request) { http.Error(w, fmt.Sprintf("%s - %s", http.StatusText(http.StatusForbidden), FailureReason(r)), http.StatusForbidden) - return } diff --git a/csrf_test.go b/csrf_test.go index eed669c..0281680 100644 --- a/csrf_test.go +++ b/csrf_test.go @@ -1,6 +1,7 @@ package csrf import ( + "fmt" "net/http" "net/http/httptest" "strings" @@ -16,10 +17,7 @@ func TestProtect(t *testing.T) { s := http.NewServeMux() s.HandleFunc("/", testHandler) - r, err := http.NewRequest("GET", "/", nil) - if err != nil { - t.Fatal(err) - } + r := createRequest("GET", "/", false) rr := httptest.NewRecorder() p := Protect(testKey)(s) @@ -46,10 +44,7 @@ func TestCookieOptions(t *testing.T) { s := http.NewServeMux() s.HandleFunc("/", testHandler) - r, err := http.NewRequest("GET", "/", nil) - if err != nil { - t.Fatal(err) - } + r := createRequest("GET", "/", false) rr := httptest.NewRecorder() p := Protect(testKey, CookieName("nameoverride"), Secure(false), HttpOnly(false), Path("/pathoverride"), Domain("domainoverride"), MaxAge(173))(s) @@ -86,10 +81,7 @@ func TestMethods(t *testing.T) { // Test idempontent ("safe") methods for _, method := range safeMethods { - r, err := http.NewRequest(method, "/", nil) - if err != nil { - t.Fatal(err) - } + r := createRequest(method, "/", false) rr := httptest.NewRecorder() p.ServeHTTP(rr, r) @@ -107,10 +99,7 @@ func TestMethods(t *testing.T) { // Test non-idempotent methods (should return a 403 without a cookie set) nonIdempotent := []string{"POST", "PUT", "DELETE", "PATCH"} for _, method := range nonIdempotent { - r, err := http.NewRequest(method, "/", nil) - if err != nil { - t.Fatal(err) - } + r := createRequest(method, "/", false) rr := httptest.NewRecorder() p.ServeHTTP(rr, r) @@ -132,16 +121,8 @@ func TestNoCookie(t *testing.T) { s := http.NewServeMux() p := Protect(testKey)(s) - var token string - s.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - token = Token(r) - })) - // POST the token back in the header. - r, err := http.NewRequest("POST", "http://www.gorillatoolkit.org/", nil) - if err != nil { - t.Fatal(err) - } + r := createRequest("POST", "/", false) rr := httptest.NewRecorder() p.ServeHTTP(rr, r) @@ -158,24 +139,18 @@ func TestBadCookie(t *testing.T) { p := Protect(testKey)(s) var token string - s.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s.Handle("/", http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { token = Token(r) })) // Obtain a CSRF cookie via a GET request. - r, err := http.NewRequest("GET", "http://www.gorillatoolkit.org/", nil) - if err != nil { - t.Fatal(err) - } + r := createRequest("GET", "/", false) rr := httptest.NewRecorder() p.ServeHTTP(rr, r) // POST the token back in the header. - r, err = http.NewRequest("POST", "http://www.gorillatoolkit.org/", nil) - if err != nil { - t.Fatal(err) - } + r = createRequest("POST", "/", false) // Replace the cookie prefix badHeader := strings.Replace(cookieName+"=", rr.Header().Get("Set-Cookie"), "_badCookie", -1) @@ -198,10 +173,7 @@ func TestVaryHeader(t *testing.T) { s.HandleFunc("/", testHandler) p := Protect(testKey)(s) - r, err := http.NewRequest("HEAD", "https://www.golang.org/", nil) - if err != nil { - t.Fatal(err) - } + r := createRequest("GET", "/", true) rr := httptest.NewRecorder() p.ServeHTTP(rr, r) @@ -216,16 +188,13 @@ func TestVaryHeader(t *testing.T) { } } -// Requests with no Referer header should fail. +// TestNoReferer checks that HTTPS requests with no Referer header fail. func TestNoReferer(t *testing.T) { s := http.NewServeMux() s.HandleFunc("/", testHandler) p := Protect(testKey)(s) - r, err := http.NewRequest("POST", "https://golang.org/", nil) - if err != nil { - t.Fatal(err) - } + r := createRequest("POST", "https://golang.org/", true) rr := httptest.NewRecorder() p.ServeHTTP(rr, r) @@ -243,25 +212,17 @@ func TestBadReferer(t *testing.T) { p := Protect(testKey)(s) var token string - s.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s.Handle("/", http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { token = Token(r) })) // Obtain a CSRF cookie via a GET request. - r, err := http.NewRequest("GET", "https://www.gorillatoolkit.org/", nil) - if err != nil { - t.Fatal(err) - } - + r := createRequest("GET", "/", true) rr := httptest.NewRecorder() p.ServeHTTP(rr, r) // POST the token back in the header. - r, err = http.NewRequest("POST", "https://www.gorillatoolkit.org/", nil) - if err != nil { - t.Fatal(err) - } - + r = createRequest("POST", "/", true) setCookie(rr, r) r.Header.Set("X-CSRF-Token", token) @@ -277,34 +238,88 @@ func TestBadReferer(t *testing.T) { } } +// TestTrustedReferer checks that HTTPS requests with a Referer that does not +// match the request URL correctly but is a trusted origin pass CSRF validation. +func TestTrustedReferer(t *testing.T) { + + testTable := []struct { + trustedOrigin []string + shouldPass bool + }{ + {[]string{"golang.org"}, true}, + {[]string{"api.example.com", "golang.org"}, true}, + {[]string{"http://golang.org"}, false}, + {[]string{"https://golang.org"}, false}, + {[]string{"http://example.com"}, false}, + {[]string{"example.com"}, false}, + } + + for _, item := range testTable { + t.Run(fmt.Sprintf("TrustedOrigin: %v", item.trustedOrigin), func(t *testing.T) { + + s := http.NewServeMux() + + p := Protect(testKey, TrustedOrigins(item.trustedOrigin))(s) + + var token string + s.Handle("/", http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + token = Token(r) + })) + + // Obtain a CSRF cookie via a GET request. + r := createRequest("GET", "/", true) + + rr := httptest.NewRecorder() + p.ServeHTTP(rr, r) + + // POST the token back in the header. + r = createRequest("POST", "/", true) + + setCookie(rr, r) + r.Header.Set("X-CSRF-Token", token) + + // Set a non-matching Referer header. + r.Header.Set("Referer", "https://golang.org/") + + rr = httptest.NewRecorder() + p.ServeHTTP(rr, r) + + if item.shouldPass { + if rr.Code != http.StatusOK { + t.Fatalf("middleware failed to pass to the next handler: got %v want %v", + rr.Code, http.StatusOK) + } + } else { + if rr.Code != http.StatusForbidden { + t.Fatalf("middleware failed reject a non-matching Referer header: got %v want %v", + rr.Code, http.StatusForbidden) + } + } + }) + } +} + // Requests with a valid Referer should pass. func TestWithReferer(t *testing.T) { s := http.NewServeMux() p := Protect(testKey)(s) var token string - s.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s.Handle("/", http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { token = Token(r) })) // Obtain a CSRF cookie via a GET request. - r, err := http.NewRequest("GET", "http://www.gorillatoolkit.org/", nil) - if err != nil { - t.Fatal(err) - } - + r := createRequest("GET", "/", true) rr := httptest.NewRecorder() p.ServeHTTP(rr, r) // POST the token back in the header. - r, err = http.NewRequest("POST", "http://www.gorillatoolkit.org/", nil) - if err != nil { - t.Fatal(err) - } + r = createRequest("POST", "/", true) setCookie(rr, r) r.Header.Set("X-CSRF-Token", token) - r.Header.Set("Referer", "http://www.gorillatoolkit.org/") + r.Header.Set("Referer", "https://www.gorillatoolkit.org/") rr = httptest.NewRecorder() p.ServeHTTP(rr, r) @@ -315,6 +330,215 @@ func TestWithReferer(t *testing.T) { } } +// Requests without a token should fail with ErrNoToken. +func TestNoTokenProvided(t *testing.T) { + var finalErr error + + s := http.NewServeMux() + p := Protect(testKey, ErrorHandler(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + finalErr = FailureReason(r) + })))(s) + + var token string + s.Handle("/", http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + token = Token(r) + })) + // Obtain a CSRF cookie via a GET request. + r := createRequest("GET", "/", true) + + rr := httptest.NewRecorder() + p.ServeHTTP(rr, r) + + // POST the token back in the header. + r = createRequest("POST", "/", true) + + setCookie(rr, r) + // By accident we use the wrong header name for the token... + r.Header.Set("X-CSRF-nekot", token) + r.Header.Set("Referer", "https://www.gorillatoolkit.org/") + + rr = httptest.NewRecorder() + p.ServeHTTP(rr, r) + + if finalErr != nil && finalErr != ErrNoToken { + t.Fatalf("middleware failed to return correct error: got '%v' want '%v'", finalErr, ErrNoToken) + } +} + func setCookie(rr *httptest.ResponseRecorder, r *http.Request) { r.Header.Set("Cookie", rr.Header().Get("Set-Cookie")) } + +func TestProtectScenarios(t *testing.T) { + tests := []struct { + name string + safeMethod bool + originUntrusted bool + originHTTP bool + originTrusted bool + secureRequest bool + refererTrusted bool + refererUntrusted bool + refererHTTPDowngrade bool + refererRelative bool + tokenValid bool + tokenInvalid bool + want bool + }{ + { + name: "safe method pass", + safeMethod: true, + want: true, + }, + { + name: "cleartext POST with trusted origin & valid token pass", + originHTTP: true, + tokenValid: true, + want: true, + }, + { + name: "cleartext POST with untrusted origin reject", + originUntrusted: true, + tokenValid: true, + }, + { + name: "cleartext POST with HTTP origin & invalid token reject", + originHTTP: true, + }, + { + name: "cleartext POST without origin with valid token pass", + tokenValid: true, + want: true, + }, + { + name: "cleartext POST without origin with invalid token reject", + }, + { + name: "TLS POST with HTTP origin & no referer & valid token reject", + tokenValid: true, + secureRequest: true, + originHTTP: true, + }, + { + name: "TLS POST without origin and without referer reject", + secureRequest: true, + tokenValid: true, + }, + { + name: "TLS POST without origin with untrusted referer reject", + secureRequest: true, + refererUntrusted: true, + tokenValid: true, + }, + { + name: "TLS POST without origin with trusted referer & valid token pass", + secureRequest: true, + refererTrusted: true, + tokenValid: true, + want: true, + }, + { + name: "TLS POST without origin from _cleartext_ same domain referer with valid token reject", + secureRequest: true, + refererHTTPDowngrade: true, + tokenValid: true, + }, + { + name: "TLS POST without origin from relative referer with valid token pass", + secureRequest: true, + refererRelative: true, + tokenValid: true, + want: true, + }, + { + name: "TLS POST without origin from relative referer with invalid token reject", + secureRequest: true, + refererRelative: true, + tokenInvalid: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var token string + var flag bool + mux := http.NewServeMux() + mux.Handle("/", http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + token = Token(r) + })) + mux.Handle("/submit", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + flag = true + })) + p := Protect(testKey)(mux) + + // Obtain a CSRF cookie via a GET request. + r := createRequest("GET", "/", tt.secureRequest) + rr := httptest.NewRecorder() + p.ServeHTTP(rr, r) + + r = createRequest("POST", "/submit", tt.secureRequest) + if tt.safeMethod { + r = createRequest("GET", "/submit", tt.secureRequest) + } + + // Set the Origin header + switch { + case tt.originUntrusted: + r.Header.Set("Origin", "http://www.untrusted-origin.org") + case tt.originTrusted: + r.Header.Set("Origin", "https://www.gorillatoolkit.org") + case tt.originHTTP: + r.Header.Set("Origin", "http://www.gorillatoolkit.org") + } + + // Set the Referer header + switch { + case tt.refererTrusted: + p = Protect(testKey, TrustedOrigins([]string{"external-trusted-origin.test"}))(mux) + r.Header.Set("Referer", "https://external-trusted-origin.test/foobar") + case tt.refererUntrusted: + r.Header.Set("Referer", "http://www.invalid-referer.org") + case tt.refererHTTPDowngrade: + r.Header.Set("Referer", "http://www.gorillatoolkit.org/foobar") + case tt.refererRelative: + r.Header.Set("Referer", "/foobar") + } + + // Set the CSRF token & associated cookie + switch { + case tt.tokenInvalid: + setCookie(rr, r) + r.Header.Set("X-CSRF-Token", "this-is-an-invalid-token") + case tt.tokenValid: + setCookie(rr, r) + r.Header.Set("X-CSRF-Token", token) + } + + rr = httptest.NewRecorder() + p.ServeHTTP(rr, r) + + if tt.want && rr.Code != http.StatusOK { + t.Fatalf("middleware failed to pass to the next handler: got %v want %v", + rr.Code, http.StatusOK) + } + + if tt.want && !flag { + t.Fatalf("middleware failed to pass to the next handler: got %v want %v", + flag, true) + + } + if !tt.want && flag { + t.Fatalf("middleware failed to reject the request: got %v want %v", flag, false) + } + }) + } +} + +func createRequest(method, path string, useTLS bool) *http.Request { + r := httptest.NewRequest(method, path, nil) + r.Host = "www.gorillatoolkit.org" + if !useTLS { + return PlaintextHTTPRequest(r) + } + return r +} diff --git a/doc.go b/doc.go index 612c8d9..503c948 100644 --- a/doc.go +++ b/doc.go @@ -4,13 +4,15 @@ prevention middleware for Go web applications & services. It includes: - * The `csrf.Protect` middleware/handler provides CSRF protection on routes - attached to a router or a sub-router. - * A `csrf.Token` function that provides the token to pass into your response, - whether that be a HTML form or a JSON response body. - * ... and a `csrf.TemplateField` helper that you can pass into your `html/template` - templates to replace a `{{ .csrfField }}` template tag with a hidden input - field. +* The `csrf.Protect` middleware/handler provides CSRF protection on routes +attached to a router or a sub-router. + +* A `csrf.Token` function that provides the token to pass into your response, +whether that be a HTML form or a JSON response body. + +* ... and a `csrf.TemplateField` helper that you can pass into your `html/template` +templates to replace a `{{ .csrfField }}` template tag with a hidden input +field. gorilla/csrf is easy to use: add the middleware to individual handlers with the below: @@ -31,64 +33,69 @@ validation. Here's the common use-case: HTML forms you want to provide CSRF protection for, in order to protect malicious POST requests being made: - package main - - import ( - "fmt" - "html/template" - "net/http" - - "github.com/gorilla/csrf" - "github.com/gorilla/mux" - ) - - var form = ` - - - Sign Up! - - -
- - - - {{ .csrfField }} - -
- - - ` - - var t = template.Must(template.New("signup_form.tmpl").Parse(form)) - - func main() { - r := mux.NewRouter() - r.HandleFunc("/signup", ShowSignupForm) - // All POST requests without a valid token will return HTTP 403 Forbidden. - r.HandleFunc("/signup/post", SubmitSignupForm) - - // Add the middleware to your router by wrapping it. - http.ListenAndServe(":8000", - csrf.Protect([]byte("32-byte-long-auth-key"))(r)) - } - - func ShowSignupForm(w http.ResponseWriter, r *http.Request) { - // signup_form.tmpl just needs a {{ .csrfField }} template tag for - // csrf.TemplateField to inject the CSRF token into. Easy! - t.ExecuteTemplate(w, "signup_form.tmpl", map[string]interface{}{ - csrf.TemplateTag: csrf.TemplateField(r), - }) - } - - func SubmitSignupForm(w http.ResponseWriter, r *http.Request) { - // We can trust that requests making it this far have satisfied - // our CSRF protection requirements. - fmt.Fprintf(w, "%v\n", r.PostForm) - } + package main + + import ( + "fmt" + "html/template" + "net/http" + + "github.com/gorilla/csrf" + "github.com/gorilla/mux" + ) + + var form = ` + + + Sign Up! + + +
+ + + + {{ .csrfField }} + +
+ + + ` + + var t = template.Must(template.New("signup_form.tmpl").Parse(form)) + + func main() { + r := mux.NewRouter() + r.HandleFunc("/signup", ShowSignupForm) + // All POST requests without a valid token will return HTTP 403 Forbidden. + // We should also ensure that our mutating (non-idempotent) handler only + // matches on POST requests. We can check that here, at the router level, or + // within the handler itself via r.Method. + r.HandleFunc("/signup/post", SubmitSignupForm).Methods("POST") + + // Add the middleware to your router by wrapping it. + http.ListenAndServe(":8000", + csrf.Protect([]byte("32-byte-long-auth-key"))(r)) + // PS: Don't forget to pass csrf.Secure(false) if you're developing locally + // over plain HTTP (just don't leave it on in production). + } + + func ShowSignupForm(w http.ResponseWriter, r *http.Request) { + // signup_form.tmpl just needs a {{ .csrfField }} template tag for + // csrf.TemplateField to inject the CSRF token into. Easy! + t.ExecuteTemplate(w, "signup_form.tmpl", map[string]interface{}{ + csrf.TemplateTag: csrf.TemplateField(r), + }) + } + + func SubmitSignupForm(w http.ResponseWriter, r *http.Request) { + // We can trust that requests making it this far have satisfied + // our CSRF protection requirements. + fmt.Fprintf(w, "%v\n", r.PostForm) + } Note that the CSRF middleware will (by necessity) consume the request body if the token is passed via POST form values. If you need to consume this in your @@ -99,59 +106,69 @@ You can also send the CSRF token in the response header. This approach is useful if you're using a front-end JavaScript framework like Ember or Angular, or are providing a JSON API: - package main + package main - import ( - "github.com/gorilla/csrf" - "github.com/gorilla/mux" - ) + import ( + "github.com/gorilla/csrf" + "github.com/gorilla/mux" + ) - func main() { - r := mux.NewRouter() + func main() { + r := mux.NewRouter() - api := r.PathPrefix("/api").Subrouter() - api.HandleFunc("/user/:id", GetUser).Methods("GET") + api := r.PathPrefix("/api").Subrouter() + api.HandleFunc("/user/:id", GetUser).Methods("GET") - http.ListenAndServe(":8000", - csrf.Protect([]byte("32-byte-long-auth-key"))(r)) - } + http.ListenAndServe(":8000", + csrf.Protect([]byte("32-byte-long-auth-key"))(r)) + } - func GetUser(w http.ResponseWriter, r *http.Request) { - // Authenticate the request, get the id from the route params, - // and fetch the user from the DB, etc. + func GetUser(w http.ResponseWriter, r *http.Request) { + // Authenticate the request, get the id from the route params, + // and fetch the user from the DB, etc. - // Get the token and pass it in the CSRF header. Our JSON-speaking client - // or JavaScript framework can now read the header and return the token in - // in its own "X-CSRF-Token" request header on the subsequent POST. - w.Header().Set("X-CSRF-Token", csrf.Token(r)) - b, err := json.Marshal(user) - if err != nil { - http.Error(w, err.Error(), 500) - return - } + // Get the token and pass it in the CSRF header. Our JSON-speaking client + // or JavaScript framework can now read the header and return the token in + // in its own "X-CSRF-Token" request header on the subsequent POST. + w.Header().Set("X-CSRF-Token", csrf.Token(r)) + b, err := json.Marshal(user) + if err != nil { + http.Error(w, err.Error(), 500) + return + } - w.Write(b) - } + w.Write(b) + } + +If you're writing a client that's supposed to mimic browser behavior, make sure to +send back the CSRF cookie (the default name is _gorilla_csrf, but this can be changed +with the CookieName Option) along with either the X-CSRF-Token header or the gorilla.csrf.Token form field. In addition: getting CSRF protection right is important, so here's some background: - * This library generates unique-per-request (masked) tokens as a mitigation - against the [BREACH attack](http://breachattack.com/). - * The 'base' (unmasked) token is stored in the session, which means that - multiple browser tabs won't cause a user problems as their per-request token - is compared with the base token. - * Operates on a "whitelist only" approach where safe (non-mutating) HTTP methods - (GET, HEAD, OPTIONS, TRACE) are the *only* methods where token validation is not - enforced. - * The design is based on the battle-tested - [Django](https://docs.djangoproject.com/en/1.8/ref/csrf/) and [Ruby on - Rails](http://api.rubyonrails.org/classes/ActionController/RequestForgeryProtection.html) - approaches. - * Cookies are authenticated and based on the [securecookie](https://github.com/gorilla/securecookie) - library. They're also Secure (issued over HTTPS only) and are HttpOnly - by default, because sane defaults are important. - * Go's `crypto/rand` library is used to generate the 32 byte (256 bit) tokens - and the one-time-pad used for masking them. +* This library generates unique-per-request (masked) tokens as a mitigation +against the BREACH attack (http://breachattack.com/). + +* The 'base' (unmasked) token is stored in the session, which means that +multiple browser tabs won't cause a user problems as their per-request token +is compared with the base token. + +* Operates on a "whitelist only" approach where safe (non-mutating) HTTP methods +(GET, HEAD, OPTIONS, TRACE) are the *only* methods where token validation is not +enforced. + +* The design is based on the battle-tested Django +(https://docs.djangoproject.com/en/1.8/ref/csrf/) and Ruby on Rails +(http://api.rubyonrails.org/classes/ActionController/RequestForgeryProtection.html) +approaches. + +* Cookies are authenticated and based on the securecookie +(https://github.com/gorilla/securecookie) library. They're also Secure (issued +over HTTPS only) and are HttpOnly by default, because sane defaults are +important. + +* Go's `crypto/rand` library is used to generate the 32 byte (256 bit) tokens +and the one-time-pad used for masking them. This library does not seek to be adventurous. diff --git a/examples/api-backends/README.md b/examples/api-backends/README.md new file mode 100644 index 0000000..c2a8eb0 --- /dev/null +++ b/examples/api-backends/README.md @@ -0,0 +1,11 @@ +# API Backends + +Examples in this directory are intended to provide basic working backend CSRF-protected APIs, +compatible with the JavaScript frontend examples available in the +[`examples/javascript-frontends`](../javascript-frontends). + +In addition to CSRF protection, these backends provide the CORS configuration required for +communicating the CSRF cookies and headers with JavaScript client code running in the browser. + +See [`examples/javascript-frontends`](../javascript-frontends/README.md) for details on CORS and +CSRF configuration compatibility requirements. diff --git a/examples/api-backends/gorilla-mux/go.mod b/examples/api-backends/gorilla-mux/go.mod new file mode 100644 index 0000000..17e266f --- /dev/null +++ b/examples/api-backends/gorilla-mux/go.mod @@ -0,0 +1,17 @@ +// +build ignore + +module github.com/gorilla-mux/examples/api-backends/gorilla-mux + +go 1.20 + +require ( + github.com/gorilla/csrf v1.7.1 + github.com/gorilla/handlers v1.5.1 + github.com/gorilla/mux v1.8.0 +) + +require ( + github.com/felixge/httpsnoop v1.0.3 // indirect + github.com/gorilla/securecookie v1.1.1 // indirect + github.com/pkg/errors v0.9.1 // indirect +) diff --git a/examples/api-backends/gorilla-mux/go.sum b/examples/api-backends/gorilla-mux/go.sum new file mode 100644 index 0000000..8271f7f --- /dev/null +++ b/examples/api-backends/gorilla-mux/go.sum @@ -0,0 +1,13 @@ +github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk= +github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/gorilla/csrf v1.7.1 h1:Ir3o2c1/Uzj6FBxMlAUB6SivgVMy1ONXwYgXn+/aHPE= +github.com/gorilla/csrf v1.7.1/go.mod h1:+a/4tCmqhG6/w4oafeAZ9pEa3/NZOWYVbD9fV0FwIQA= +github.com/gorilla/handlers v1.5.1 h1:9lRY6j8DEeeBT10CvO9hGW0gmky0BprnvDI5vfhUHH4= +github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q= +github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= +github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= +github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= diff --git a/examples/api-backends/gorilla-mux/main.go b/examples/api-backends/gorilla-mux/main.go new file mode 100644 index 0000000..849549a --- /dev/null +++ b/examples/api-backends/gorilla-mux/main.go @@ -0,0 +1,66 @@ +// +build ignore + +package main + +import ( + "fmt" + "log" + "net/http" + "os" + "strings" + "time" + + "github.com/gorilla/csrf" + "github.com/gorilla/handlers" + "github.com/gorilla/mux" +) + +func main() { + router := mux.NewRouter() + + loggingMiddleware := func(h http.Handler) http.Handler { + return handlers.LoggingHandler(os.Stdout, h) + } + router.Use(loggingMiddleware) + + CSRFMiddleware := csrf.Protect( + []byte("place-your-32-byte-long-key-here"), + csrf.Secure(false), // false in development only! + csrf.RequestHeader("X-CSRF-Token"), // Must be in CORS Allowed and Exposed Headers + ) + + APIRouter := router.PathPrefix("/api").Subrouter() + APIRouter.Use(CSRFMiddleware) + APIRouter.HandleFunc("", Get).Methods(http.MethodGet) + APIRouter.HandleFunc("", Post).Methods(http.MethodPost) + + CORSMiddleware := handlers.CORS( + handlers.AllowCredentials(), + handlers.AllowedOriginValidator( + func(origin string) bool { + return strings.HasPrefix(origin, "http://localhost") + }, + ), + handlers.AllowedHeaders([]string{"X-CSRF-Token"}), + handlers.ExposedHeaders([]string{"X-CSRF-Token"}), + ) + + server := &http.Server{ + Handler: CORSMiddleware(router), + Addr: "localhost:8080", + ReadTimeout: 60 * time.Second, + WriteTimeout: 60 * time.Second, + } + + fmt.Println("starting http server on localhost:8080") + log.Panic(server.ListenAndServe()) +} + +func Get(w http.ResponseWriter, r *http.Request) { + w.Header().Add("X-CSRF-Token", csrf.Token(r)) + w.WriteHeader(http.StatusOK) +} + +func Post(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) +} diff --git a/examples/javascript-frontends/README.md b/examples/javascript-frontends/README.md new file mode 100644 index 0000000..02a1de4 --- /dev/null +++ b/examples/javascript-frontends/README.md @@ -0,0 +1,19 @@ +# JavaScript Frontends + +Examples in this directory are intended to provide basic working frontend JavaScript, compatible +with the API backend examples available in the [`examples/api-backends`](../api-backends). + +## CSRF and CORS compatibility + +In order to be compatible with a CSRF-protected backend, frontend clients must: + +1. Be served from a domain allowed by the backend's CORS Allowed Origins configuration. + 1. `http://localhost*` for the backend examples provided + 2. An example server to serve the HTML and JavaScript for the frontend examples from localhost is included in + [`examples/javascript-frontends/example-frontend-server`](../javascript-frontends/example-frontend-server) +3. Use the HTTP headers expected by the backend to send and receive CSRF Tokens. + The backends configure this as the Gorilla `csrf.RequestHeader`, + as well as the CORS Allowed Headers and Exposed Headers. + 1. `X-CSRF-Token` for the backend examples provided + 2. Note that some JavaScript HTTP clients automatically lowercase all received headers, + so the values must be accessed with the key `"x-csrf-token"` in the frontend code. diff --git a/examples/javascript-frontends/example-frontend-server/go.mod b/examples/javascript-frontends/example-frontend-server/go.mod new file mode 100644 index 0000000..af48442 --- /dev/null +++ b/examples/javascript-frontends/example-frontend-server/go.mod @@ -0,0 +1,10 @@ +module github.com/gorilla-mux/examples/javascript-frontends/example-frontend-server + +go 1.20 + +require ( + github.com/gorilla/handlers v1.5.1 + github.com/gorilla/mux v1.8.0 +) + +require github.com/felixge/httpsnoop v1.0.3 // indirect diff --git a/examples/javascript-frontends/example-frontend-server/go.sum b/examples/javascript-frontends/example-frontend-server/go.sum new file mode 100644 index 0000000..1e9fd4d --- /dev/null +++ b/examples/javascript-frontends/example-frontend-server/go.sum @@ -0,0 +1,7 @@ +github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk= +github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/gorilla/handlers v1.5.1 h1:9lRY6j8DEeeBT10CvO9hGW0gmky0BprnvDI5vfhUHH4= +github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q= +github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= +github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= diff --git a/examples/javascript-frontends/example-frontend-server/main.go b/examples/javascript-frontends/example-frontend-server/main.go new file mode 100644 index 0000000..6cac20b --- /dev/null +++ b/examples/javascript-frontends/example-frontend-server/main.go @@ -0,0 +1,42 @@ +// +build ignore + +package main + +import ( + "fmt" + "log" + "net/http" + "os" + "time" + + "github.com/gorilla/handlers" + "github.com/gorilla/mux" +) + +func main() { + router := mux.NewRouter() + + loggingMiddleware := func(h http.Handler) http.Handler { + return handlers.LoggingHandler(os.Stdout, h) + } + router.Use(loggingMiddleware) + + wd, err := os.Getwd() + if err != nil { + log.Panic(err) + } + // change this directory to point at a different Javascript frontend to serve + httpStaticAssetsDir := http.Dir(fmt.Sprintf("%s/../frontends/axios/", wd)) + + router.PathPrefix("/").Handler(http.FileServer(httpStaticAssetsDir)) + + server := &http.Server{ + Handler: router, + Addr: "localhost:8081", + ReadTimeout: 60 * time.Second, + WriteTimeout: 60 * time.Second, + } + + fmt.Println("starting http server on localhost:8081") + log.Panic(server.ListenAndServe()) +} diff --git a/examples/javascript-frontends/frontends/axios/index.html b/examples/javascript-frontends/frontends/axios/index.html new file mode 100644 index 0000000..29ac9c0 --- /dev/null +++ b/examples/javascript-frontends/frontends/axios/index.html @@ -0,0 +1,34 @@ + + + + + Gorilla CSRF + + + +
+

Gorilla CSRF: Axios JS Frontend

+

See Console and Network tabs of your browser's Developer Tools for further details

+
+ +
+

Get Request:

+

Full Response:

+ +

CSRF Token:

+ +
+ + +
+

Post Request:

+

Full Response:

+

+ Note that the X-CSRF-Token value is in the Axios config.headers; + it is not a response header set by the server. +

+ +
+ + + \ No newline at end of file diff --git a/examples/javascript-frontends/frontends/axios/index.js b/examples/javascript-frontends/frontends/axios/index.js new file mode 100644 index 0000000..d931c58 --- /dev/null +++ b/examples/javascript-frontends/frontends/axios/index.js @@ -0,0 +1,50 @@ +// make GET request to backend on page load in order to obtain +// a CSRF Token and load it into the Axios instance's headers +// https://github.com/axios/axios#creating-an-instance +const initializeAxiosInstance = async (url) => { + try { + let resp = await axios.get(url, {withCredentials: true}); + console.log(resp); + document.getElementById("get-request-full-response").innerHTML = JSON.stringify(resp); + + let csrfToken = parseCSRFToken(resp); + console.log(csrfToken); + document.getElementById("get-response-csrf-token").innerHTML = csrfToken; + + return axios.create({ + // withCredentials must be true to in order for the browser + // to send cookies, which are necessary for CSRF verification + withCredentials: true, + headers: {"X-CSRF-Token": csrfToken} + }); + } catch (err) { + console.log(err); + } +}; + +const post = async (axiosInstance, url) => { + try { + let resp = await axiosInstance.post(url); + console.log(resp); + document.getElementById("post-request-full-response").innerHTML = JSON.stringify(resp); + } catch (err) { + console.log(err); + } +}; + +// general-purpose func to deal with clients like Axios, +// which lowercase all headers received from the server response +const parseCSRFToken = (resp) => { + let csrfToken = resp.headers[csrfTokenHeader]; + if (!csrfToken) { + csrfToken = resp.headers[csrfTokenHeader.toLowerCase()]; + } + return csrfToken +} + +const url = "http://localhost:8080/api"; +const csrfTokenHeader = "X-CSRF-Token"; +initializeAxiosInstance(url) + .then(axiosInstance => { + post(axiosInstance, url); + }); diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..09c4174 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module github.com/gorilla/csrf + +require github.com/gorilla/securecookie v1.1.2 + +go 1.20 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..285ffee --- /dev/null +++ b/go.sum @@ -0,0 +1,3 @@ +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= +github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= diff --git a/helpers.go b/helpers.go index 3dacfd2..99005ee 100644 --- a/helpers.go +++ b/helpers.go @@ -51,18 +51,17 @@ func UnsafeSkipCheck(r *http.Request) *http.Request { // // Example: // -// // The following tag in our form.tmpl template: -// {{ .csrfField }} -// -// // ... becomes: -// +// // The following tag in our form.tmpl template: +// {{ .csrfField }} // +// // ... becomes: +// func TemplateField(r *http.Request) template.HTML { if name, err := contextGet(r, formKey); err == nil { fragment := fmt.Sprintf(``, name, Token(r)) - return template.HTML(fragment) + return template.HTML(fragment) // #nosec G203 } return template.HTML("") @@ -75,7 +74,7 @@ func TemplateField(r *http.Request) template.HTML { // token and returning them together as a 64-byte slice. This effectively // randomises the token on a per-request basis without breaking multiple browser // tabs/windows. -func mask(realToken []byte, r *http.Request) string { +func mask(realToken []byte, _ *http.Request) string { otp, err := generateRandomBytes(tokenLength) if err != nil { return "" @@ -105,7 +104,7 @@ func unmask(issued []byte) []byte { // requestToken returns the issued token (pad + masked token) from the HTTP POST // body or HTTP header. It will return nil if the token fails to decode. -func (cs *csrf) requestToken(r *http.Request) []byte { +func (cs *csrf) requestToken(r *http.Request) ([]byte, error) { // 1. Check the HTTP header first. issued := r.Header.Get(cs.opts.RequestHeader) @@ -123,14 +122,19 @@ func (cs *csrf) requestToken(r *http.Request) []byte { } } + // Return nil (equivalent to empty byte slice) if no token was found + if issued == "" { + return nil, nil + } + // Decode the "issued" (pad + masked) token sent in the request. Return a // nil byte slice on a decoding error (this will fail upstream). decoded, err := base64.StdEncoding.DecodeString(issued) if err != nil { - return nil + return nil, err } - return decoded + return decoded, nil } // generateRandomBytes returns securely generated random bytes. diff --git a/helpers_test.go b/helpers_test.go index 30ee7b3..f40c996 100644 --- a/helpers_test.go +++ b/helpers_test.go @@ -6,6 +6,7 @@ import ( "encoding/base64" "fmt" "io" + "log" "mime/multipart" "net/http" "net/http/httptest" @@ -34,9 +35,12 @@ func TestFormToken(t *testing.T) { s.HandleFunc("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { token = Token(r) t := template.Must((template.New("base").Parse(testTemplate))) - t.Execute(w, map[string]interface{}{ + err := t.Execute(w, map[string]interface{}{ TemplateTag: TemplateField(r), }) + if err != nil { + log.Printf("errored during executing the template: %v", err) + } })) r, err := http.NewRequest("GET", "/", nil) @@ -71,15 +75,15 @@ func TestMultipartFormToken(t *testing.T) { s.HandleFunc("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { token = Token(r) t := template.Must((template.New("base").Parse(testTemplate))) - t.Execute(w, map[string]interface{}{ + err := t.Execute(w, map[string]interface{}{ TemplateTag: TemplateField(r), }) + if err != nil { + log.Printf("errored during executing the template: %v", err) + } })) - r, err := http.NewRequest("GET", "/", nil) - if err != nil { - t.Fatal(err) - } + r := createRequest("GET", "/", true) rr := httptest.NewRecorder() p := Protect(testKey)(s) @@ -93,16 +97,20 @@ func TestMultipartFormToken(t *testing.T) { t.Fatal(err) } - wr.Write([]byte(token)) - mp.Close() - - r, err = http.NewRequest("POST", "http://www.gorillatoolkit.org/", &b) + _, err = wr.Write([]byte(token)) if err != nil { t.Fatal(err) } + mp.Close() + + r = httptest.NewRequest("POST", "/", &b) + r.Host = "www.gorillatoolkit.org" + // Add the multipart header. r.Header.Set("Content-Type", mp.FormDataContentType()) + // Add Origin to pass the same-origin check. + r.Header.Set("Origin", "https://www.gorillatoolkit.org") // Send back the issued cookie. setCookie(rr, r) @@ -185,7 +193,7 @@ func TestXOR(t *testing.T) { for _, token := range testTokens { if res := xorToken(token.a, token.b); res != nil { - if bytes.Compare(res, token.expected) != 0 { + if !bytes.Equal(res, token.expected) { t.Fatalf("xorBytes failed to return the expected result: got %v want %v", res, token.expected) } @@ -226,16 +234,17 @@ func TestTemplateField(t *testing.T) { token = Token(r) templateField = string(TemplateField(r)) t := template.Must((template.New("base").Parse(testTemplate))) - t.Execute(w, map[string]interface{}{ + err := t.Execute(w, map[string]interface{}{ TemplateTag: TemplateField(r), }) + if err != nil { + log.Printf("errored during executing the template: %v", err) + } })) testFieldName := "custom_field_name" - r, err := http.NewRequest("GET", "/", nil) - if err != nil { - t.Fatal(err) - } + r := createRequest("GET", "/", false) + // r, err := http.NewRequest("GET", "/", nil) rr := httptest.NewRecorder() p := Protect(testKey, FieldName(testFieldName))(s) @@ -280,15 +289,12 @@ func TestUnsafeSkipCSRFCheck(t *testing.T) { var teapot = 418 // Issue a POST request without a CSRF token in the request. - s.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { // Set a non-200 header to make the test explicit. w.WriteHeader(teapot) })) - r, err := http.NewRequest("POST", "/", nil) - if err != nil { - t.Fatal(err) - } + r := createRequest("POST", "/", false) // Must be used prior to the CSRF handler being invoked. p := skipCheck(Protect(testKey)(s)) diff --git a/options.go b/options.go index c644d49..c61d301 100644 --- a/options.go +++ b/options.go @@ -1,12 +1,15 @@ package csrf -import "net/http" +import ( + "net/http" +) // Option describes a functional option for configuring the CSRF handler. type Option func(*csrf) // MaxAge sets the maximum age (in seconds) of a CSRF token's underlying cookie. -// Defaults to 12 hours. +// Defaults to 12 hours. Call csrf.MaxAge(0) to explicitly set session-only +// cookies. func MaxAge(age int) Option { return func(cs *csrf) { cs.opts.MaxAge = age @@ -58,12 +61,32 @@ func HttpOnly(h bool) Option { } } +// SameSite sets the cookie SameSite attribute. Defaults to blank to maintain +// backwards compatibility, however, Strict is recommended. +// +// SameSite(SameSiteStrictMode) will prevent the cookie from being sent by the +// browser to the target site in all cross-site browsing context, even when +// following a regular link (GET request). +// +// SameSite(SameSiteLaxMode) provides a reasonable balance between security and +// usability for websites that want to maintain user's logged-in session after +// the user arrives from an external link. The session cookie would be allowed +// when following a regular link from an external website while blocking it in +// CSRF-prone request methods (e.g. POST). +// +// This option is only available for go 1.11+. +func SameSite(s SameSiteMode) Option { + return func(cs *csrf) { + cs.opts.SameSite = s + } +} + // ErrorHandler allows you to change the handler called when CSRF request // processing encounters an invalid token or request. A typical use would be to // provide a handler that returns a static HTML file with a HTTP 403 status. By // default a HTTP 403 status and a plain text CSRF failure reason are served. // -// Note that a custom error handler can also access the csrf.Failure(r) +// Note that a custom error handler can also access the csrf.FailureReason(r) // function to retrieve the CSRF validation reason from the request context. func ErrorHandler(h http.Handler) Option { return func(cs *csrf) { @@ -97,6 +120,17 @@ func CookieName(name string) Option { } } +// TrustedOrigins configures a set of origins (Referers) that are considered as trusted. +// This will allow cross-domain CSRF use-cases - e.g. where the front-end is served +// from a different domain than the API server - to correctly pass a CSRF check. +// +// You should only provide origins you own or have full control over. +func TrustedOrigins(origins []string) Option { + return func(cs *csrf) { + cs.opts.TrustedOrigins = origins + } +} + // setStore sets the store used by the CSRF middleware. // Note: this is private (for now) to allow for internal API changes. func setStore(s store) Option { @@ -118,6 +152,13 @@ func parseOptions(h http.Handler, opts ...Option) *csrf { cs.opts.Secure = true cs.opts.HttpOnly = true + // Set SameSite=Lax by default, allowing the CSRF cookie to only be sent on + // top-level navigations. + cs.opts.SameSite = SameSiteLaxMode + + // Default; only override this if the package user explicitly calls MaxAge(0) + cs.opts.MaxAge = defaultAge + // Range over each options function and apply it // to our csrf type to configure it. Options functions are // applied in order, with any conflicting options overriding diff --git a/options_test.go b/options_test.go index 13e259c..d133fd1 100644 --- a/options_test.go +++ b/options_test.go @@ -24,6 +24,7 @@ func TestOptions(t *testing.T) { Path(path), HttpOnly(false), Secure(false), + SameSite(SameSiteStrictMode), RequestHeader(header), FieldName(field), ErrorHandler(http.HandlerFunc(errorHandler)), @@ -53,6 +54,10 @@ func TestOptions(t *testing.T) { t.Errorf("Secure not set correctly: got %v want %v", cs.opts.Secure, false) } + if cs.opts.SameSite != SameSiteStrictMode { + t.Errorf("SameSite not set correctly: got %v want %v", cs.opts.SameSite, SameSiteStrictMode) + } + if cs.opts.RequestHeader != header { t.Errorf("RequestHeader not set correctly: got %v want %v", cs.opts.RequestHeader, header) } @@ -71,3 +76,26 @@ func TestOptions(t *testing.T) { cs.opts.CookieName, name) } } + +func TestMaxAge(t *testing.T) { + t.Run("Ensure the default MaxAge is applied", func(t *testing.T) { + handler := Protect(testKey)(nil) + csrf := handler.(*csrf) + cs := csrf.st.(*cookieStore) + + if cs.maxAge != defaultAge { + t.Fatalf("default maxAge not applied: got %d (want %d)", cs.maxAge, defaultAge) + } + }) + + t.Run("Support an explicit MaxAge of 0 (session-only)", func(t *testing.T) { + handler := Protect(testKey, MaxAge(0))(nil) + csrf := handler.(*csrf) + cs := csrf.st.(*cookieStore) + + if cs.maxAge != 0 { + t.Fatalf("zero (0) maxAge not applied: got %d (want %d)", cs.maxAge, 0) + } + }) + +} diff --git a/store.go b/store.go index 39f47ad..4e6382b 100644 --- a/store.go +++ b/store.go @@ -1,3 +1,6 @@ +//go:build go1.11 +// +build go1.11 + package csrf import ( @@ -28,6 +31,7 @@ type cookieStore struct { path string domain string sc *securecookie.SecureCookie + sameSite SameSiteMode } // Get retrieves a CSRF token from the session cookie. It returns an empty token @@ -63,6 +67,7 @@ func (cs *cookieStore) Save(token []byte, w http.ResponseWriter) error { MaxAge: cs.maxAge, HttpOnly: cs.httpOnly, Secure: cs.secure, + SameSite: http.SameSite(cs.sameSite), Path: cs.path, Domain: cs.domain, } diff --git a/store_legacy.go b/store_legacy.go new file mode 100644 index 0000000..4e1fb9e --- /dev/null +++ b/store_legacy.go @@ -0,0 +1,88 @@ +//go:build !go1.11 +// +build !go1.11 + +// file for compatibility with go versions prior to 1.11 + +package csrf + +import ( + "net/http" + "time" + + "github.com/gorilla/securecookie" +) + +// store represents the session storage used for CSRF tokens. +type store interface { + // Get returns the real CSRF token from the store. + Get(*http.Request) ([]byte, error) + // Save stores the real CSRF token in the store and writes a + // cookie to the http.ResponseWriter. + // For non-cookie stores, the cookie should contain a unique (256 bit) ID + // or key that references the token in the backend store. + // csrf.GenerateRandomBytes is a helper function for generating secure IDs. + Save(token []byte, w http.ResponseWriter) error +} + +// cookieStore is a signed cookie session store for CSRF tokens. +type cookieStore struct { + name string + maxAge int + secure bool + httpOnly bool + path string + domain string + sc *securecookie.SecureCookie + sameSite SameSiteMode +} + +// Get retrieves a CSRF token from the session cookie. It returns an empty token +// if decoding fails (e.g. HMAC validation fails or the named cookie doesn't exist). +func (cs *cookieStore) Get(r *http.Request) ([]byte, error) { + // Retrieve the cookie from the request + cookie, err := r.Cookie(cs.name) + if err != nil { + return nil, err + } + + token := make([]byte, tokenLength) + // Decode the HMAC authenticated cookie. + err = cs.sc.Decode(cs.name, cookie.Value, &token) + if err != nil { + return nil, err + } + + return token, nil +} + +// Save stores the CSRF token in the session cookie. +func (cs *cookieStore) Save(token []byte, w http.ResponseWriter) error { + // Generate an encoded cookie value with the CSRF token. + encoded, err := cs.sc.Encode(cs.name, token) + if err != nil { + return err + } + + cookie := &http.Cookie{ + Name: cs.name, + Value: encoded, + MaxAge: cs.maxAge, + HttpOnly: cs.httpOnly, + Secure: cs.secure, + Path: cs.path, + Domain: cs.domain, + } + + // Set the Expires field on the cookie based on the MaxAge + // If MaxAge <= 0, we don't set the Expires attribute, making the cookie + // session-only. + if cs.maxAge > 0 { + cookie.Expires = time.Now().Add( + time.Duration(cs.maxAge) * time.Second) + } + + // Write the authenticated cookie to the response. + http.SetCookie(w, cookie) + + return nil +} diff --git a/store_legacy_test.go b/store_legacy_test.go new file mode 100644 index 0000000..ce64a6e --- /dev/null +++ b/store_legacy_test.go @@ -0,0 +1,163 @@ +//go:build !go1.11 +// +build !go1.11 + +// file for compatibility with go versions prior to 1.11 + +package csrf + +import ( + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gorilla/securecookie" +) + +// Check store implementations +var _ store = &cookieStore{} + +// brokenSaveStore is a CSRF store that cannot, well, save. +type brokenSaveStore struct { + store +} + +func (bs *brokenSaveStore) Get(*http.Request) ([]byte, error) { + // Generate an invalid token so we can progress to our Save method + return generateRandomBytes(24) +} + +func (bs *brokenSaveStore) Save(realToken []byte, w http.ResponseWriter) error { + return errors.New("test error") +} + +// Tests for failure if the middleware can't save to the Store. +func TestStoreCannotSave(t *testing.T) { + s := http.NewServeMux() + bs := &brokenSaveStore{} + s.HandleFunc("/", testHandler) + p := Protect(testKey, setStore(bs))(s) + + r, err := http.NewRequest("GET", "/", nil) + if err != nil { + t.Fatal(err) + } + + rr := httptest.NewRecorder() + p.ServeHTTP(rr, r) + + if rr.Code != http.StatusForbidden { + t.Fatalf("broken store did not set an error status: got %v want %v", + rr.Code, http.StatusForbidden) + } + + if c := rr.Header().Get("Set-Cookie"); c != "" { + t.Fatalf("broken store incorrectly set a cookie: got %v want %v", + c, "") + } + +} + +// TestCookieDecode tests that an invalid cookie store returns a decoding error. +func TestCookieDecode(t *testing.T) { + r, err := http.NewRequest("GET", "/", nil) + if err != nil { + t.Fatal(err) + } + + var age = 3600 + + // Test with a nil hash key + sc := securecookie.New(nil, nil) + sc.MaxAge(age) + st := &cookieStore{cookieName, age, true, true, "", "", sc, SameSiteDefaultMode} + + // Set a fake cookie value so r.Cookie passes. + r.Header.Set("Cookie", fmt.Sprintf("%s=%s", cookieName, "notacookie")) + + _, err = st.Get(r) + if err == nil { + t.Fatal("cookiestore did not report an invalid hashkey on decode") + } +} + +// TestCookieEncode tests that an invalid cookie store returns an encoding error. +func TestCookieEncode(t *testing.T) { + var age = 3600 + + // Test with a nil hash key + sc := securecookie.New(nil, nil) + sc.MaxAge(age) + st := &cookieStore{cookieName, age, true, true, "", "", sc, SameSiteDefaultMode} + + rr := httptest.NewRecorder() + + err := st.Save(nil, rr) + if err == nil { + t.Fatal("cookiestore did not report an invalid hashkey on encode") + } +} + +// TestMaxAgeZero tests that setting MaxAge(0) does not set the Expires +// attribute on the cookie. +func TestMaxAgeZero(t *testing.T) { + var age = 0 + + s := http.NewServeMux() + s.HandleFunc("/", testHandler) + + r, err := http.NewRequest("GET", "/", nil) + if err != nil { + t.Fatal(err) + } + + rr := httptest.NewRecorder() + p := Protect(testKey, MaxAge(age))(s) + p.ServeHTTP(rr, r) + + if rr.Code != http.StatusOK { + t.Fatalf("middleware failed to pass to the next handler: got %v want %v", + rr.Code, http.StatusOK) + } + + if rr.Header().Get("Set-Cookie") == "" { + t.Fatalf("cookie not set: got %q", rr.Header().Get("Set-Cookie")) + } + + cookie := rr.Header().Get("Set-Cookie") + if !strings.Contains(cookie, "HttpOnly") || strings.Contains(cookie, "Expires") { + t.Fatalf("cookie incorrectly has the Expires attribute set: got %q", cookie) + } +} + +// TestSameSizeSet tests that setting SameSite Option does not set the SameSite +// attribute on the cookie in legacy systems. +func TestSameSizeSet(t *testing.T) { + s := http.NewServeMux() + s.HandleFunc("/", testHandler) + + r, err := http.NewRequest("GET", "/", nil) + if err != nil { + t.Fatal(err) + } + + rr := httptest.NewRecorder() + p := Protect(testKey, SameSite(SameSiteStrictMode))(s) + p.ServeHTTP(rr, r) + + if rr.Code != http.StatusOK { + t.Fatalf("middleware failed to pass to the next handler: got %v want %v", + rr.Code, http.StatusOK) + } + + if rr.Header().Get("Set-Cookie") == "" { + t.Fatalf("cookie not set: got %q", rr.Header().Get("Set-Cookie")) + } + + cookie := rr.Header().Get("Set-Cookie") + if strings.Contains(cookie, "SameSite") { + t.Fatalf("cookie incorrectly has the SameSite attribute set: got %q", cookie) + } +} diff --git a/store_test.go b/store_test.go index e2836e5..712a438 100644 --- a/store_test.go +++ b/store_test.go @@ -1,14 +1,16 @@ +//go:build go1.11 +// +build go1.11 + package csrf import ( + "errors" "fmt" "net/http" "net/http/httptest" "strings" "testing" - "github.com/pkg/errors" - "github.com/gorilla/securecookie" ) @@ -17,7 +19,7 @@ var _ store = &cookieStore{} // brokenSaveStore is a CSRF store that cannot, well, save. type brokenSaveStore struct { - store + store // nolint:unused } func (bs *brokenSaveStore) Get(*http.Request) ([]byte, error) { @@ -68,7 +70,7 @@ func TestCookieDecode(t *testing.T) { // Test with a nil hash key sc := securecookie.New(nil, nil) sc.MaxAge(age) - st := &cookieStore{cookieName, age, true, true, "", "", sc} + st := &cookieStore{cookieName, age, true, true, "", "", sc, SameSiteDefaultMode} // Set a fake cookie value so r.Cookie passes. r.Header.Set("Cookie", fmt.Sprintf("%s=%s", cookieName, "notacookie")) @@ -86,7 +88,7 @@ func TestCookieEncode(t *testing.T) { // Test with a nil hash key sc := securecookie.New(nil, nil) sc.MaxAge(age) - st := &cookieStore{cookieName, age, true, true, "", "", sc} + st := &cookieStore{cookieName, age, true, true, "", "", sc, SameSiteDefaultMode} rr := httptest.NewRecorder() @@ -127,3 +129,64 @@ func TestMaxAgeZero(t *testing.T) { t.Fatalf("cookie incorrectly has the Expires attribute set: got %q", cookie) } } + +// TestSameSizeSet tests that setting SameSite Option sets the SameSite +// attribute on the cookie in post go1.11 systems. +func TestSameSizeSet(t *testing.T) { + s := http.NewServeMux() + s.HandleFunc("/", testHandler) + + r, err := http.NewRequest("GET", "/", nil) + if err != nil { + t.Fatal(err) + } + + rr := httptest.NewRecorder() + p := Protect(testKey, SameSite(SameSiteStrictMode))(s) + p.ServeHTTP(rr, r) + + if rr.Code != http.StatusOK { + t.Fatalf("middleware failed to pass to the next handler: got %v want %v", + rr.Code, http.StatusOK) + } + + if rr.Header().Get("Set-Cookie") == "" { + t.Fatalf("cookie not set: got %q", rr.Header().Get("Set-Cookie")) + } + + cookie := rr.Header().Get("Set-Cookie") + if !strings.Contains(cookie, "SameSite") { + t.Fatalf("cookie incorrectly does not have the SameSite attribute set: got %q", cookie) + } +} + +// TestSameSiteDefault tests that the default set of options +// set SameSite=Lax on the CSRF cookie. +func TestSameSiteDefaultLaxMode(t *testing.T) { + s := http.NewServeMux() + s.HandleFunc("/", testHandler) + + r, err := http.NewRequest("GET", "/", nil) + if err != nil { + t.Fatal(err) + } + + rr := httptest.NewRecorder() + p := Protect(testKey)(s) + p.ServeHTTP(rr, r) + + if rr.Code != http.StatusOK { + t.Fatalf("middleware failed to pass to the next handler: got %v want %v", + rr.Code, http.StatusOK) + } + + cookie := rr.Header().Get("Set-Cookie") + if cookie == "" { + t.Fatalf("cookie not get Set-Cookie header: got headers %v", rr.Header()) + } + + sameSiteLax := "SameSite=Lax" + if !strings.Contains(cookie, sameSiteLax) { + t.Fatalf("cookie should contain %q by default: got %s", sameSiteLax, cookie) + } +} diff --git a/vendor/github.com/gorilla/securecookie/.editorconfig b/vendor/github.com/gorilla/securecookie/.editorconfig new file mode 100644 index 0000000..2940ec9 --- /dev/null +++ b/vendor/github.com/gorilla/securecookie/.editorconfig @@ -0,0 +1,20 @@ +; https://editorconfig.org/ + +root = true + +[*] +insert_final_newline = true +charset = utf-8 +trim_trailing_whitespace = true +indent_style = space +indent_size = 2 + +[{Makefile,go.mod,go.sum,*.go,.gitmodules}] +indent_style = tab +indent_size = 4 + +[*.md] +indent_size = 4 +trim_trailing_whitespace = false + +eclint_indent_style = unset diff --git a/vendor/github.com/gorilla/securecookie/.gitignore b/vendor/github.com/gorilla/securecookie/.gitignore new file mode 100644 index 0000000..84039fe --- /dev/null +++ b/vendor/github.com/gorilla/securecookie/.gitignore @@ -0,0 +1 @@ +coverage.coverprofile diff --git a/vendor/github.com/gorilla/securecookie/LICENSE b/vendor/github.com/gorilla/securecookie/LICENSE new file mode 100644 index 0000000..bb9d80b --- /dev/null +++ b/vendor/github.com/gorilla/securecookie/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2023 The Gorilla Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/vendor/github.com/gorilla/securecookie/Makefile b/vendor/github.com/gorilla/securecookie/Makefile new file mode 100644 index 0000000..2b9008a --- /dev/null +++ b/vendor/github.com/gorilla/securecookie/Makefile @@ -0,0 +1,39 @@ +GO_LINT=$(shell which golangci-lint 2> /dev/null || echo '') +GO_LINT_URI=github.com/golangci/golangci-lint/cmd/golangci-lint@latest + +GO_SEC=$(shell which gosec 2> /dev/null || echo '') +GO_SEC_URI=github.com/securego/gosec/v2/cmd/gosec@latest + +GO_VULNCHECK=$(shell which govulncheck 2> /dev/null || echo '') +GO_VULNCHECK_URI=golang.org/x/vuln/cmd/govulncheck@latest + +.PHONY: golangci-lint +golangci-lint: + $(if $(GO_LINT), ,go install $(GO_LINT_URI)) + @echo "##### Running golangci-lint" + golangci-lint run -v + +.PHONY: gosec +gosec: + $(if $(GO_SEC), ,go install $(GO_SEC_URI)) + @echo "##### Running gosec" + gosec ./... + +.PHONY: govulncheck +govulncheck: + $(if $(GO_VULNCHECK), ,go install $(GO_VULNCHECK_URI)) + @echo "##### Running govulncheck" + govulncheck ./... + +.PHONY: verify +verify: golangci-lint gosec govulncheck + +.PHONY: test +test: + @echo "##### Running tests" + go test -race -cover -coverprofile=coverage.coverprofile -covermode=atomic -v ./... + +.PHONY: fuzz +fuzz: + @echo "##### Running fuzz tests" + go test -v -fuzz FuzzEncodeDecode -fuzztime 60s diff --git a/vendor/github.com/gorilla/securecookie/README.md b/vendor/github.com/gorilla/securecookie/README.md new file mode 100644 index 0000000..c3b9815 --- /dev/null +++ b/vendor/github.com/gorilla/securecookie/README.md @@ -0,0 +1,144 @@ +# gorilla/securecookie + +![testing](https://github.com/gorilla/securecookie/actions/workflows/test.yml/badge.svg) +[![codecov](https://codecov.io/github/gorilla/securecookie/branch/main/graph/badge.svg)](https://codecov.io/github/gorilla/securecookie) +[![godoc](https://godoc.org/github.com/gorilla/securecookie?status.svg)](https://godoc.org/github.com/gorilla/securecookie) +[![sourcegraph](https://sourcegraph.com/github.com/gorilla/securecookie/-/badge.svg)](https://sourcegraph.com/github.com/gorilla/securecookie?badge) + +![Gorilla Logo](https://github.com/gorilla/.github/assets/53367916/d92caabf-98e0-473e-bfbf-ab554ba435e5) + +securecookie encodes and decodes authenticated and optionally encrypted +cookie values. + +Secure cookies can't be forged, because their values are validated using HMAC. +When encrypted, the content is also inaccessible to malicious eyes. It is still +recommended that sensitive data not be stored in cookies, and that HTTPS be used +to prevent cookie [replay attacks](https://en.wikipedia.org/wiki/Replay_attack). + +## Examples + +To use it, first create a new SecureCookie instance: + +```go +// Hash keys should be at least 32 bytes long +var hashKey = []byte("very-secret") +// Block keys should be 16 bytes (AES-128) or 32 bytes (AES-256) long. +// Shorter keys may weaken the encryption used. +var blockKey = []byte("a-lot-secret") +var s = securecookie.New(hashKey, blockKey) +``` + +The hashKey is required, used to authenticate the cookie value using HMAC. +It is recommended to use a key with 32 or 64 bytes. + +The blockKey is optional, used to encrypt the cookie value -- set it to nil +to not use encryption. If set, the length must correspond to the block size +of the encryption algorithm. For AES, used by default, valid lengths are +16, 24, or 32 bytes to select AES-128, AES-192, or AES-256. + +Strong keys can be created using the convenience function +`GenerateRandomKey()`. Note that keys created using `GenerateRandomKey()` are not +automatically persisted. New keys will be created when the application is +restarted, and previously issued cookies will not be able to be decoded. + +Once a SecureCookie instance is set, use it to encode a cookie value: + +```go +func SetCookieHandler(w http.ResponseWriter, r *http.Request) { + value := map[string]string{ + "foo": "bar", + } + if encoded, err := s.Encode("cookie-name", value); err == nil { + cookie := &http.Cookie{ + Name: "cookie-name", + Value: encoded, + Path: "/", + Secure: true, + HttpOnly: true, + } + http.SetCookie(w, cookie) + } +} +``` + +Later, use the same SecureCookie instance to decode and validate a cookie +value: + +```go +func ReadCookieHandler(w http.ResponseWriter, r *http.Request) { + if cookie, err := r.Cookie("cookie-name"); err == nil { + value := make(map[string]string) + if err = s2.Decode("cookie-name", cookie.Value, &value); err == nil { + fmt.Fprintf(w, "The value of foo is %q", value["foo"]) + } + } +} +``` + +We stored a map[string]string, but secure cookies can hold any value that +can be encoded using `encoding/gob`. To store custom types, they must be +registered first using gob.Register(). For basic types this is not needed; +it works out of the box. An optional JSON encoder that uses `encoding/json` is +available for types compatible with JSON. + +### Key Rotation +Rotating keys is an important part of any security strategy. The `EncodeMulti` and +`DecodeMulti` functions allow for multiple keys to be rotated in and out. +For example, let's take a system that stores keys in a map: + +```go +// keys stored in a map will not be persisted between restarts +// a more persistent storage should be considered for production applications. +var cookies = map[string]*securecookie.SecureCookie{ + "previous": securecookie.New( + securecookie.GenerateRandomKey(64), + securecookie.GenerateRandomKey(32), + ), + "current": securecookie.New( + securecookie.GenerateRandomKey(64), + securecookie.GenerateRandomKey(32), + ), +} +``` + +Using the current key to encode new cookies: +```go +func SetCookieHandler(w http.ResponseWriter, r *http.Request) { + value := map[string]string{ + "foo": "bar", + } + if encoded, err := securecookie.EncodeMulti("cookie-name", value, cookies["current"]); err == nil { + cookie := &http.Cookie{ + Name: "cookie-name", + Value: encoded, + Path: "/", + } + http.SetCookie(w, cookie) + } +} +``` + +Later, decode cookies. Check against all valid keys: +```go +func ReadCookieHandler(w http.ResponseWriter, r *http.Request) { + if cookie, err := r.Cookie("cookie-name"); err == nil { + value := make(map[string]string) + err = securecookie.DecodeMulti("cookie-name", cookie.Value, &value, cookies["current"], cookies["previous"]) + if err == nil { + fmt.Fprintf(w, "The value of foo is %q", value["foo"]) + } + } +} +``` + +Rotate the keys. This strategy allows previously issued cookies to be valid until the next rotation: +```go +func Rotate(newCookie *securecookie.SecureCookie) { + cookies["previous"] = cookies["current"] + cookies["current"] = newCookie +} +``` + +## License + +BSD licensed. See the LICENSE file for details. diff --git a/vendor/github.com/gorilla/securecookie/doc.go b/vendor/github.com/gorilla/securecookie/doc.go new file mode 100644 index 0000000..ae89408 --- /dev/null +++ b/vendor/github.com/gorilla/securecookie/doc.go @@ -0,0 +1,61 @@ +// Copyright 2012 The Gorilla Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +/* +Package securecookie encodes and decodes authenticated and optionally +encrypted cookie values. + +Secure cookies can't be forged, because their values are validated using HMAC. +When encrypted, the content is also inaccessible to malicious eyes. + +To use it, first create a new SecureCookie instance: + + var hashKey = []byte("very-secret") + var blockKey = []byte("a-lot-secret") + var s = securecookie.New(hashKey, blockKey) + +The hashKey is required, used to authenticate the cookie value using HMAC. +It is recommended to use a key with 32 or 64 bytes. + +The blockKey is optional, used to encrypt the cookie value -- set it to nil +to not use encryption. If set, the length must correspond to the block size +of the encryption algorithm. For AES, used by default, valid lengths are +16, 24, or 32 bytes to select AES-128, AES-192, or AES-256. + +Strong keys can be created using the convenience function GenerateRandomKey(). + +Once a SecureCookie instance is set, use it to encode a cookie value: + + func SetCookieHandler(w http.ResponseWriter, r *http.Request) { + value := map[string]string{ + "foo": "bar", + } + if encoded, err := s.Encode("cookie-name", value); err == nil { + cookie := &http.Cookie{ + Name: "cookie-name", + Value: encoded, + Path: "/", + } + http.SetCookie(w, cookie) + } + } + +Later, use the same SecureCookie instance to decode and validate a cookie +value: + + func ReadCookieHandler(w http.ResponseWriter, r *http.Request) { + if cookie, err := r.Cookie("cookie-name"); err == nil { + value := make(map[string]string) + if err = s2.Decode("cookie-name", cookie.Value, &value); err == nil { + fmt.Fprintf(w, "The value of foo is %q", value["foo"]) + } + } + } + +We stored a map[string]string, but secure cookies can hold any value that +can be encoded using encoding/gob. To store custom types, they must be +registered first using gob.Register(). For basic types this is not needed; +it works out of the box. +*/ +package securecookie diff --git a/vendor/github.com/gorilla/securecookie/securecookie.go b/vendor/github.com/gorilla/securecookie/securecookie.go new file mode 100644 index 0000000..4d5ea86 --- /dev/null +++ b/vendor/github.com/gorilla/securecookie/securecookie.go @@ -0,0 +1,649 @@ +// Copyright 2012 The Gorilla Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package securecookie + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/gob" + "encoding/json" + "fmt" + "hash" + "io" + "strconv" + "strings" + "time" +) + +// Error is the interface of all errors returned by functions in this library. +type Error interface { + error + + // IsUsage returns true for errors indicating the client code probably + // uses this library incorrectly. For example, the client may have + // failed to provide a valid hash key, or may have failed to configure + // the Serializer adequately for encoding value. + IsUsage() bool + + // IsDecode returns true for errors indicating that a cookie could not + // be decoded and validated. Since cookies are usually untrusted + // user-provided input, errors of this type should be expected. + // Usually, the proper action is simply to reject the request. + IsDecode() bool + + // IsInternal returns true for unexpected errors occurring in the + // securecookie implementation. + IsInternal() bool + + // Cause, if it returns a non-nil value, indicates that this error was + // propagated from some underlying library. If this method returns nil, + // this error was raised directly by this library. + // + // Cause is provided principally for debugging/logging purposes; it is + // rare that application logic should perform meaningfully different + // logic based on Cause. See, for example, the caveats described on + // (MultiError).Cause(). + Cause() error +} + +// errorType is a bitmask giving the error type(s) of an cookieError value. +type errorType int + +const ( + usageError = errorType(1 << iota) + decodeError + internalError +) + +type cookieError struct { + typ errorType + msg string + cause error +} + +func (e cookieError) IsUsage() bool { return (e.typ & usageError) != 0 } +func (e cookieError) IsDecode() bool { return (e.typ & decodeError) != 0 } +func (e cookieError) IsInternal() bool { return (e.typ & internalError) != 0 } + +func (e cookieError) Cause() error { return e.cause } + +func (e cookieError) Error() string { + parts := []string{"securecookie: "} + if e.msg == "" { + parts = append(parts, "error") + } else { + parts = append(parts, e.msg) + } + if c := e.Cause(); c != nil { + parts = append(parts, " - caused by: ", c.Error()) + } + return strings.Join(parts, "") +} + +var ( + errGeneratingIV = cookieError{typ: internalError, msg: "failed to generate random iv"} + + errNoCodecs = cookieError{typ: usageError, msg: "no codecs provided"} + errHashKeyNotSet = cookieError{typ: usageError, msg: "hash key is not set"} + errBlockKeyNotSet = cookieError{typ: usageError, msg: "block key is not set"} + errEncodedValueTooLong = cookieError{typ: usageError, msg: "the value is too long"} + + errValueToDecodeTooLong = cookieError{typ: decodeError, msg: "the value is too long"} + errTimestampInvalid = cookieError{typ: decodeError, msg: "invalid timestamp"} + errTimestampTooNew = cookieError{typ: decodeError, msg: "timestamp is too new"} + errTimestampExpired = cookieError{typ: decodeError, msg: "expired timestamp"} + errDecryptionFailed = cookieError{typ: decodeError, msg: "the value could not be decrypted"} + errValueNotByte = cookieError{typ: decodeError, msg: "value not a []byte."} + errValueNotBytePtr = cookieError{typ: decodeError, msg: "value not a pointer to []byte."} + + // ErrMacInvalid indicates that cookie decoding failed because the HMAC + // could not be extracted and verified. Direct use of this error + // variable is deprecated; it is public only for legacy compatibility, + // and may be privatized in the future, as it is rarely useful to + // distinguish between this error and other Error implementations. + ErrMacInvalid = cookieError{typ: decodeError, msg: "the value is not valid"} +) + +// Codec defines an interface to encode and decode cookie values. +type Codec interface { + Encode(name string, value interface{}) (string, error) + Decode(name, value string, dst interface{}) error +} + +// New returns a new SecureCookie. +// +// hashKey is required, used to authenticate values using HMAC. Create it using +// GenerateRandomKey(). It is recommended to use a key with 32 or 64 bytes. +// +// blockKey is optional, used to encrypt values. Create it using +// GenerateRandomKey(). The key length must correspond to the key size +// of the encryption algorithm. For AES, used by default, valid lengths are +// 16, 24, or 32 bytes to select AES-128, AES-192, or AES-256. +// The default encoder used for cookie serialization is encoding/gob. +// +// Note that keys created using GenerateRandomKey() are not automatically +// persisted. New keys will be created when the application is restarted, and +// previously issued cookies will not be able to be decoded. +func New(hashKey, blockKey []byte) *SecureCookie { + s := &SecureCookie{ + hashKey: hashKey, + blockKey: blockKey, + hashFunc: sha256.New, + maxAge: 86400 * 30, + maxLength: 4096, + sz: GobEncoder{}, + } + if len(hashKey) == 0 { + s.err = errHashKeyNotSet + } + if blockKey != nil { + s.BlockFunc(aes.NewCipher) + } + return s +} + +// SecureCookie encodes and decodes authenticated and optionally encrypted +// cookie values. +type SecureCookie struct { + hashKey []byte + hashFunc func() hash.Hash + blockKey []byte + block cipher.Block + maxLength int + maxAge int64 + minAge int64 + err error + sz Serializer + // For testing purposes, the function that returns the current timestamp. + // If not set, it will use time.Now().UTC().Unix(). + timeFunc func() int64 +} + +// Serializer provides an interface for providing custom serializers for cookie +// values. +type Serializer interface { + Serialize(src interface{}) ([]byte, error) + Deserialize(src []byte, dst interface{}) error +} + +// GobEncoder encodes cookie values using encoding/gob. This is the simplest +// encoder and can handle complex types via gob.Register. +type GobEncoder struct{} + +// JSONEncoder encodes cookie values using encoding/json. Users who wish to +// encode complex types need to satisfy the json.Marshaller and +// json.Unmarshaller interfaces. +type JSONEncoder struct{} + +// NopEncoder does not encode cookie values, and instead simply accepts a []byte +// (as an interface{}) and returns a []byte. This is particularly useful when +// you encoding an object upstream and do not wish to re-encode it. +type NopEncoder struct{} + +// MaxLength restricts the maximum length, in bytes, for the cookie value. +// +// Default is 4096, which is the maximum value accepted by Internet Explorer. +func (s *SecureCookie) MaxLength(value int) *SecureCookie { + s.maxLength = value + return s +} + +// MaxAge restricts the maximum age, in seconds, for the cookie value. +// +// Default is 86400 * 30. Set it to 0 for no restriction. +func (s *SecureCookie) MaxAge(value int) *SecureCookie { + s.maxAge = int64(value) + return s +} + +// MinAge restricts the minimum age, in seconds, for the cookie value. +// +// Default is 0 (no restriction). +func (s *SecureCookie) MinAge(value int) *SecureCookie { + s.minAge = int64(value) + return s +} + +// HashFunc sets the hash function used to create HMAC. +// +// Default is crypto/sha256.New. +func (s *SecureCookie) HashFunc(f func() hash.Hash) *SecureCookie { + s.hashFunc = f + return s +} + +// BlockFunc sets the encryption function used to create a cipher.Block. +// +// Default is crypto/aes.New. +func (s *SecureCookie) BlockFunc(f func([]byte) (cipher.Block, error)) *SecureCookie { + if s.blockKey == nil { + s.err = errBlockKeyNotSet + } else if block, err := f(s.blockKey); err == nil { + s.block = block + } else { + s.err = cookieError{cause: err, typ: usageError} + } + return s +} + +// Encoding sets the encoding/serialization method for cookies. +// +// Default is encoding/gob. To encode special structures using encoding/gob, +// they must be registered first using gob.Register(). +func (s *SecureCookie) SetSerializer(sz Serializer) *SecureCookie { + s.sz = sz + + return s +} + +// Encode encodes a cookie value. +// +// It serializes, optionally encrypts, signs with a message authentication code, +// and finally encodes the value. +// +// The name argument is the cookie name. It is stored with the encoded value. +// The value argument is the value to be encoded. It can be any value that can +// be encoded using the currently selected serializer; see SetSerializer(). +// +// It is the client's responsibility to ensure that value, when encoded using +// the current serialization/encryption settings on s and then base64-encoded, +// is shorter than the maximum permissible length. +func (s *SecureCookie) Encode(name string, value interface{}) (string, error) { + if s.err != nil { + return "", s.err + } + if s.hashKey == nil { + s.err = errHashKeyNotSet + return "", s.err + } + var err error + var b []byte + // 1. Serialize. + if b, err = s.sz.Serialize(value); err != nil { + return "", cookieError{cause: err, typ: usageError} + } + // 2. Encrypt (optional). + if s.block != nil { + if b, err = encrypt(s.block, b); err != nil { + return "", cookieError{cause: err, typ: usageError} + } + } + b = encode(b) + // 3. Create MAC for "name|date|value". Extra pipe to be used later. + b = []byte(fmt.Sprintf("%s|%d|%s|", name, s.timestamp(), b)) + mac := createMac(hmac.New(s.hashFunc, s.hashKey), b[:len(b)-1]) + // Append mac, remove name. + b = append(b, mac...)[len(name)+1:] + // 4. Encode to base64. + b = encode(b) + // 5. Check length. + if s.maxLength != 0 && len(b) > s.maxLength { + return "", fmt.Errorf("%s: %d", errEncodedValueTooLong, len(b)) + } + // Done. + return string(b), nil +} + +// Decode decodes a cookie value. +// +// It decodes, verifies a message authentication code, optionally decrypts and +// finally deserializes the value. +// +// The name argument is the cookie name. It must be the same name used when +// it was stored. The value argument is the encoded cookie value. The dst +// argument is where the cookie will be decoded. It must be a pointer. +func (s *SecureCookie) Decode(name, value string, dst interface{}) error { + if s.err != nil { + return s.err + } + if s.hashKey == nil { + s.err = errHashKeyNotSet + return s.err + } + // 1. Check length. + if s.maxLength != 0 && len(value) > s.maxLength { + return fmt.Errorf("%s: %d", errValueToDecodeTooLong, len(value)) + } + // 2. Decode from base64. + b, err := decode([]byte(value)) + if err != nil { + return err + } + // 3. Verify MAC. Value is "date|value|mac". + parts := bytes.SplitN(b, []byte("|"), 3) + if len(parts) != 3 { + return ErrMacInvalid + } + h := hmac.New(s.hashFunc, s.hashKey) + b = append([]byte(name+"|"), b[:len(b)-len(parts[2])-1]...) + if err = verifyMac(h, b, parts[2]); err != nil { + return err + } + // 4. Verify date ranges. + var t1 int64 + if t1, err = strconv.ParseInt(string(parts[0]), 10, 64); err != nil { + return errTimestampInvalid + } + t2 := s.timestamp() + if s.minAge != 0 && t1 > t2-s.minAge { + return errTimestampTooNew + } + if s.maxAge != 0 && t1 < t2-s.maxAge { + return errTimestampExpired + } + // 5. Decrypt (optional). + b, err = decode(parts[1]) + if err != nil { + return err + } + if s.block != nil { + if b, err = decrypt(s.block, b); err != nil { + return err + } + } + // 6. Deserialize. + if err = s.sz.Deserialize(b, dst); err != nil { + return cookieError{cause: err, typ: decodeError} + } + // Done. + return nil +} + +// timestamp returns the current timestamp, in seconds. +// +// For testing purposes, the function that generates the timestamp can be +// overridden. If not set, it will return time.Now().UTC().Unix(). +func (s *SecureCookie) timestamp() int64 { + if s.timeFunc == nil { + return time.Now().UTC().Unix() + } + return s.timeFunc() +} + +// Authentication ------------------------------------------------------------- + +// createMac creates a message authentication code (MAC). +func createMac(h hash.Hash, value []byte) []byte { + h.Write(value) + return h.Sum(nil) +} + +// verifyMac verifies that a message authentication code (MAC) is valid. +func verifyMac(h hash.Hash, value []byte, mac []byte) error { + mac2 := createMac(h, value) + // Check that both MACs are of equal length, as subtle.ConstantTimeCompare + // does not do this prior to Go 1.4. + if len(mac) == len(mac2) && subtle.ConstantTimeCompare(mac, mac2) == 1 { + return nil + } + return ErrMacInvalid +} + +// Encryption ----------------------------------------------------------------- + +// encrypt encrypts a value using the given block in counter mode. +// +// A random initialization vector ( https://en.wikipedia.org/wiki/Block_cipher_mode_of_operation#Initialization_vector_(IV) ) with the length of the +// block size is prepended to the resulting ciphertext. +func encrypt(block cipher.Block, value []byte) ([]byte, error) { + iv := GenerateRandomKey(block.BlockSize()) + if iv == nil { + return nil, errGeneratingIV + } + // Encrypt it. + stream := cipher.NewCTR(block, iv) + stream.XORKeyStream(value, value) + // Return iv + ciphertext. + return append(iv, value...), nil +} + +// decrypt decrypts a value using the given block in counter mode. +// +// The value to be decrypted must be prepended by a initialization vector +// ( https://en.wikipedia.org/wiki/Block_cipher_mode_of_operation#Initialization_vector_(IV) ) with the length of the block size. +func decrypt(block cipher.Block, value []byte) ([]byte, error) { + size := block.BlockSize() + if len(value) > size { + // Extract iv. + iv := value[:size] + // Extract ciphertext. + value = value[size:] + // Decrypt it. + stream := cipher.NewCTR(block, iv) + stream.XORKeyStream(value, value) + return value, nil + } + return nil, errDecryptionFailed +} + +// Serialization -------------------------------------------------------------- + +// Serialize encodes a value using gob. +func (e GobEncoder) Serialize(src interface{}) ([]byte, error) { + buf := new(bytes.Buffer) + enc := gob.NewEncoder(buf) + if err := enc.Encode(src); err != nil { + return nil, cookieError{cause: err, typ: usageError} + } + return buf.Bytes(), nil +} + +// Deserialize decodes a value using gob. +func (e GobEncoder) Deserialize(src []byte, dst interface{}) error { + dec := gob.NewDecoder(bytes.NewBuffer(src)) + if err := dec.Decode(dst); err != nil { + return cookieError{cause: err, typ: decodeError} + } + return nil +} + +// Serialize encodes a value using encoding/json. +func (e JSONEncoder) Serialize(src interface{}) ([]byte, error) { + buf := new(bytes.Buffer) + enc := json.NewEncoder(buf) + if err := enc.Encode(src); err != nil { + return nil, cookieError{cause: err, typ: usageError} + } + return buf.Bytes(), nil +} + +// Deserialize decodes a value using encoding/json. +func (e JSONEncoder) Deserialize(src []byte, dst interface{}) error { + dec := json.NewDecoder(bytes.NewReader(src)) + if err := dec.Decode(dst); err != nil { + return cookieError{cause: err, typ: decodeError} + } + return nil +} + +// Serialize passes a []byte through as-is. +func (e NopEncoder) Serialize(src interface{}) ([]byte, error) { + if b, ok := src.([]byte); ok { + return b, nil + } + + return nil, errValueNotByte +} + +// Deserialize passes a []byte through as-is. +func (e NopEncoder) Deserialize(src []byte, dst interface{}) error { + if dat, ok := dst.(*[]byte); ok { + *dat = src + return nil + } + return errValueNotBytePtr +} + +// Encoding ------------------------------------------------------------------- + +// encode encodes a value using base64. +func encode(value []byte) []byte { + encoded := make([]byte, base64.URLEncoding.EncodedLen(len(value))) + base64.URLEncoding.Encode(encoded, value) + return encoded +} + +// decode decodes a cookie using base64. +func decode(value []byte) ([]byte, error) { + decoded := make([]byte, base64.URLEncoding.DecodedLen(len(value))) + b, err := base64.URLEncoding.Decode(decoded, value) + if err != nil { + return nil, cookieError{cause: err, typ: decodeError, msg: "base64 decode failed"} + } + return decoded[:b], nil +} + +// Helpers -------------------------------------------------------------------- + +// GenerateRandomKey creates a random key with the given length in bytes. +// On failure, returns nil. +// +// Note that keys created using `GenerateRandomKey()` are not automatically +// persisted. New keys will be created when the application is restarted, and +// previously issued cookies will not be able to be decoded. +// +// Callers should explicitly check for the possibility of a nil return, treat +// it as a failure of the system random number generator, and not continue. +func GenerateRandomKey(length int) []byte { + k := make([]byte, length) + if _, err := io.ReadFull(rand.Reader, k); err != nil { + return nil + } + return k +} + +// CodecsFromPairs returns a slice of SecureCookie instances. +// +// It is a convenience function to create a list of codecs for key rotation. Note +// that the generated Codecs will have the default options applied: callers +// should iterate over each Codec and type-assert the underlying *SecureCookie to +// change these. +// +// Example: +// +// codecs := securecookie.CodecsFromPairs( +// []byte("new-hash-key"), +// []byte("new-block-key"), +// []byte("old-hash-key"), +// []byte("old-block-key"), +// ) +// +// // Modify each instance. +// for _, s := range codecs { +// if cookie, ok := s.(*securecookie.SecureCookie); ok { +// cookie.MaxAge(86400 * 7) +// cookie.SetSerializer(securecookie.JSONEncoder{}) +// cookie.HashFunc(sha512.New512_256) +// } +// } +func CodecsFromPairs(keyPairs ...[]byte) []Codec { + codecs := make([]Codec, len(keyPairs)/2+len(keyPairs)%2) + for i := 0; i < len(keyPairs); i += 2 { + var blockKey []byte + if i+1 < len(keyPairs) { + blockKey = keyPairs[i+1] + } + codecs[i/2] = New(keyPairs[i], blockKey) + } + return codecs +} + +// EncodeMulti encodes a cookie value using a group of codecs. +// +// The codecs are tried in order. Multiple codecs are accepted to allow +// key rotation. +// +// On error, may return a MultiError. +func EncodeMulti(name string, value interface{}, codecs ...Codec) (string, error) { + if len(codecs) == 0 { + return "", errNoCodecs + } + + var errors MultiError + for _, codec := range codecs { + encoded, err := codec.Encode(name, value) + if err == nil { + return encoded, nil + } + errors = append(errors, err) + } + return "", errors +} + +// DecodeMulti decodes a cookie value using a group of codecs. +// +// The codecs are tried in order. Multiple codecs are accepted to allow +// key rotation. +// +// On error, may return a MultiError. +func DecodeMulti(name string, value string, dst interface{}, codecs ...Codec) error { + if len(codecs) == 0 { + return errNoCodecs + } + + var errors MultiError + for _, codec := range codecs { + err := codec.Decode(name, value, dst) + if err == nil { + return nil + } + errors = append(errors, err) + } + return errors +} + +// MultiError groups multiple errors. +type MultiError []error + +func (m MultiError) IsUsage() bool { return m.any(func(e Error) bool { return e.IsUsage() }) } +func (m MultiError) IsDecode() bool { return m.any(func(e Error) bool { return e.IsDecode() }) } +func (m MultiError) IsInternal() bool { return m.any(func(e Error) bool { return e.IsInternal() }) } + +// Cause returns nil for MultiError; there is no unique underlying cause in the +// general case. +// +// Note: we could conceivably return a non-nil Cause only when there is exactly +// one child error with a Cause. However, it would be brittle for client code +// to rely on the arity of causes inside a MultiError, so we have opted not to +// provide this functionality. Clients which really wish to access the Causes +// of the underlying errors are free to iterate through the errors themselves. +func (m MultiError) Cause() error { return nil } + +func (m MultiError) Error() string { + s, n := "", 0 + for _, e := range m { + if e != nil { + if n == 0 { + s = e.Error() + } + n++ + } + } + switch n { + case 0: + return "(0 errors)" + case 1: + return s + case 2: + return s + " (and 1 other error)" + } + return fmt.Sprintf("%s (and %d other errors)", s, n-1) +} + +// any returns true if any element of m is an Error for which pred returns true. +func (m MultiError) any(pred func(Error) bool) bool { + for _, e := range m { + if ourErr, ok := e.(Error); ok && pred(ourErr) { + return true + } + } + return false +} diff --git a/vendor/modules.txt b/vendor/modules.txt new file mode 100644 index 0000000..6224b61 --- /dev/null +++ b/vendor/modules.txt @@ -0,0 +1,3 @@ +# github.com/gorilla/securecookie v1.1.2 +## explicit; go 1.20 +github.com/gorilla/securecookie