diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..2940ec9 --- /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 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..19a6c1c --- /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.23'] + 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..c9987eb --- /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.23'] + 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..3471511 --- /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.23'] + 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.60.1 + 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 d87d465..0000000 --- a/.travis.yml +++ /dev/null @@ -1,7 +0,0 @@ -language: go - -go: - - 1.0 - - 1.1 - - 1.2 - - tip diff --git a/LICENSE b/LICENSE index 0e5fb87..7fa9009 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2012 Rodrigo Moraes. All rights reserved. +Copyright (c) 2024 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 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 a02df39..d2cbea6 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,98 @@ -sessions -======== - -Store Implementations ---------------------- -Other implementations of the sessions.Store interface: - - * [github.com/yosssi/boltstore](https://github.com/yosssi/boltstore) - Bolt - * [github.com/srinathgs/couchbasestore](https://github.com/srinathgs/couchbasestore) - Couchbase - * [github.com/bradleypeabody/gorilla-sessions-memcache](https://github.com/bradleypeabody/gorilla-sessions-memcache) - Memcache - * [github.com/hnakamur/gaesessions](https://github.com/hnakamur/gaesessions) - Memcache on GAE - * [github.com/kidstuff/mongostore](https://github.com/kidstuff/mongostore) - MongoDB - * [github.com/srinathgs/mysqlstore](https://github.com/srinathgs/mysqlstore) - MySQL - * [github.com/antonlindstrom/pgstore](https://github.com/antonlindstrom/pgstore) - PostgreSQL - * [github.com/boj/redistore](https://github.com/boj/redistore) - Redis - * [github.com/boj/riakstore](https://github.com/boj/riakstore) - Riak +# Gorilla Sessions + +> [!IMPORTANT] +> The latest version of this repository requires go 1.23 because of the new partitioned attribute. The last version that is compatible with older versions of go is v1.3.0. + +![testing](https://github.com/gorilla/sessions/actions/workflows/test.yml/badge.svg) +[![codecov](https://codecov.io/github/gorilla/sessions/branch/main/graph/badge.svg)](https://codecov.io/github/gorilla/sessions) +[![godoc](https://godoc.org/github.com/gorilla/sessions?status.svg)](https://godoc.org/github.com/gorilla/sessions) +[![sourcegraph](https://sourcegraph.com/github.com/gorilla/sessions/-/badge.svg)](https://sourcegraph.com/github.com/gorilla/sessions?badge) + +![Gorilla Logo](https://github.com/gorilla/.github/assets/53367916/d92caabf-98e0-473e-bfbf-ab554ba435e5) + +gorilla/sessions provides cookie and filesystem sessions and infrastructure for +custom session backends. + +The key features are: + +- Simple API: use it as an easy way to set signed (and optionally + encrypted) cookies. +- Built-in backends to store sessions in cookies or the filesystem. +- Flash messages: session values that last until read. +- Convenient way to switch session persistency (aka "remember me") and set + other attributes. +- Mechanism to rotate authentication and encryption keys. +- Multiple sessions per request, even using different backends. +- Interfaces and infrastructure for custom session backends: sessions from + different stores can be retrieved and batch-saved using a common API. + +Let's start with an example that shows the sessions API in a nutshell: + +```go + import ( + "net/http" + "github.com/gorilla/sessions" + ) + + // Note: Don't store your key in your source code. Pass it via an + // environmental variable, or flag (or both), and don't accidentally commit it + // alongside your code. Ensure your key is sufficiently random - i.e. use Go's + // crypto/rand or securecookie.GenerateRandomKey(32) and persist the result. + var store = sessions.NewCookieStore([]byte(os.Getenv("SESSION_KEY"))) + + func MyHandler(w http.ResponseWriter, r *http.Request) { + // Get a session. We're ignoring the error resulted from decoding an + // existing session: Get() always returns a session, even if empty. + session, _ := store.Get(r, "session-name") + // Set some session values. + session.Values["foo"] = "bar" + session.Values[42] = 43 + // Save it before we write to the response/return from the handler. + err := session.Save(r, w) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } +``` + +First we initialize a session store calling `NewCookieStore()` and passing a +secret key used to authenticate the session. Inside the handler, we call +`store.Get()` to retrieve an existing session or create a new one. Then we set +some session values in session.Values, which is a `map[interface{}]interface{}`. +And finally we call `session.Save()` to save the session in the response. + +More examples are available at [package documentation](https://pkg.go.dev/github.com/gorilla/sessions). + +## Store Implementations + +Other implementations of the `sessions.Store` interface: + +- [github.com/starJammer/gorilla-sessions-arangodb](https://github.com/starJammer/gorilla-sessions-arangodb) - ArangoDB +- [github.com/yosssi/boltstore](https://github.com/yosssi/boltstore) - Bolt +- [github.com/srinathgs/couchbasestore](https://github.com/srinathgs/couchbasestore) - Couchbase +- [github.com/denizeren/dynamostore](https://github.com/denizeren/dynamostore) - Dynamodb on AWS +- [github.com/savaki/dynastore](https://github.com/savaki/dynastore) - DynamoDB on AWS (Official AWS library) +- [github.com/bradleypeabody/gorilla-sessions-memcache](https://github.com/bradleypeabody/gorilla-sessions-memcache) - Memcache +- [github.com/dsoprea/go-appengine-sessioncascade](https://github.com/dsoprea/go-appengine-sessioncascade) - Memcache/Datastore/Context in AppEngine +- [github.com/kidstuff/mongostore](https://github.com/kidstuff/mongostore) - MongoDB +- [github.com/srinathgs/mysqlstore](https://github.com/srinathgs/mysqlstore) - MySQL +- [github.com/danielepintore/gorilla-sessions-mysql](https://github.com/danielepintore/gorilla-sessions-mysql) - MySQL +- [github.com/EnumApps/clustersqlstore](https://github.com/EnumApps/clustersqlstore) - MySQL Cluster +- [github.com/antonlindstrom/pgstore](https://github.com/antonlindstrom/pgstore) - PostgreSQL +- [github.com/boj/redistore](https://github.com/boj/redistore) - Redis +- [github.com/rbcervilla/redisstore](https://github.com/rbcervilla/redisstore) - Redis (Single, Sentinel, Cluster) +- [github.com/boj/rethinkstore](https://github.com/boj/rethinkstore) - RethinkDB +- [github.com/boj/riakstore](https://github.com/boj/riakstore) - Riak +- [github.com/michaeljs1990/sqlitestore](https://github.com/michaeljs1990/sqlitestore) - SQLite +- [github.com/wader/gormstore](https://github.com/wader/gormstore) - GORM (MySQL, PostgreSQL, SQLite) +- [github.com/gernest/qlstore](https://github.com/gernest/qlstore) - ql +- [github.com/quasoft/memstore](https://github.com/quasoft/memstore) - In-memory implementation for use in unit tests +- [github.com/lafriks/xormstore](https://github.com/lafriks/xormstore) - XORM (MySQL, PostgreSQL, SQLite, Microsoft SQL Server, TiDB) +- [github.com/GoogleCloudPlatform/firestore-gorilla-sessions](https://github.com/GoogleCloudPlatform/firestore-gorilla-sessions) - Cloud Firestore +- [github.com/stephenafamo/crdbstore](https://github.com/stephenafamo/crdbstore) - CockroachDB +- [github.com/ryicoh/tikvstore](github.com/ryicoh/tikvstore) - TiKV + +## License + +BSD licensed. See the LICENSE file for details. diff --git a/cookie.go b/cookie.go new file mode 100644 index 0000000..fd6f48c --- /dev/null +++ b/cookie.go @@ -0,0 +1,23 @@ +// 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 sessions + +import "net/http" + +// newCookieFromOptions returns an http.Cookie with the options set. +func newCookieFromOptions(name, value string, options *Options) *http.Cookie { + return &http.Cookie{ + Name: name, + Value: value, + Path: options.Path, + Domain: options.Domain, + MaxAge: options.MaxAge, + Secure: options.Secure, + HttpOnly: options.HttpOnly, + Partitioned: options.Partitioned, + SameSite: options.SameSite, + } + +} diff --git a/cookie_test.go b/cookie_test.go new file mode 100644 index 0000000..de530ca --- /dev/null +++ b/cookie_test.go @@ -0,0 +1,67 @@ +// 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 sessions + +import ( + "testing" +) + +// Test for creating new http.Cookie from name, value and options +func TestNewCookieFromOptions(t *testing.T) { + tests := []struct { + name string + value string + path string + domain string + maxAge int + secure bool + httpOnly bool + partitioned bool + }{ + {"", "bar", "/foo/bar", "foo.example.com", 3600, true, true, true}, + {"foo", "", "/foo/bar", "foo.example.com", 3600, true, true, true}, + {"foo", "bar", "", "foo.example.com", 3600, true, true, true}, + {"foo", "bar", "/foo/bar", "", 3600, true, true, true}, + {"foo", "bar", "/foo/bar", "foo.example.com", 0, true, true, true}, + {"foo", "bar", "/foo/bar", "foo.example.com", 3600, false, true, true}, + {"foo", "bar", "/foo/bar", "foo.example.com", 3600, true, false, true}, + {"foo", "bar", "/foo/bar", "foo.example.com", 3600, true, true, false}, + } + for i, v := range tests { + options := &Options{ + Path: v.path, + Domain: v.domain, + MaxAge: v.maxAge, + Secure: v.secure, + HttpOnly: v.httpOnly, + Partitioned: v.partitioned, + } + cookie := newCookieFromOptions(v.name, v.value, options) + if cookie.Name != v.name { + t.Fatalf("%v: bad cookie name: got %q, want %q", i+1, cookie.Name, v.name) + } + if cookie.Value != v.value { + t.Fatalf("%v: bad cookie value: got %q, want %q", i+1, cookie.Value, v.value) + } + if cookie.Path != v.path { + t.Fatalf("%v: bad cookie path: got %q, want %q", i+1, cookie.Path, v.path) + } + if cookie.Domain != v.domain { + t.Fatalf("%v: bad cookie domain: got %q, want %q", i+1, cookie.Domain, v.domain) + } + if cookie.MaxAge != v.maxAge { + t.Fatalf("%v: bad cookie maxAge: got %q, want %q", i+1, cookie.MaxAge, v.maxAge) + } + if cookie.Secure != v.secure { + t.Fatalf("%v: bad cookie secure: got %v, want %v", i+1, cookie.Secure, v.secure) + } + if cookie.HttpOnly != v.httpOnly { + t.Fatalf("%v: bad cookie httpOnly: got %v, want %v", i+1, cookie.HttpOnly, v.httpOnly) + } + if cookie.Partitioned != v.partitioned { + t.Fatalf("%v: bad cookie partitioned: got %v, want %v", i+1, cookie.Partitioned, v.partitioned) + } + } +} diff --git a/doc.go b/doc.go index 7f8be22..946bf5c 100644 --- a/doc.go +++ b/doc.go @@ -3,7 +3,7 @@ // license that can be found in the LICENSE file. /* -Package gorilla/sessions provides cookie and filesystem sessions and +Package sessions provides cookie and filesystem sessions and infrastructure for custom session backends. The key features are: @@ -26,17 +26,30 @@ Let's start with an example that shows the sessions API in a nutshell: "github.com/gorilla/sessions" ) - var store = sessions.NewCookieStore([]byte("something-very-secret")) + // Note: Don't store your key in your source code. Pass it via an + // environmental variable, or flag (or both), and don't accidentally commit it + // alongside your code. Ensure your key is sufficiently random - i.e. use Go's + // crypto/rand or securecookie.GenerateRandomKey(32) and persist the result. + // Ensure SESSION_KEY exists in the environment, or sessions will fail. + var store = sessions.NewCookieStore([]byte(os.Getenv("SESSION_KEY"))) func MyHandler(w http.ResponseWriter, r *http.Request) { - // Get a session. We're ignoring the error resulted from decoding an - // existing session: Get() always returns a session, even if empty. - session, _ := store.Get(r, "session-name") + // Get a session. Get() always returns a session, even if empty. + session, err := store.Get(r, "session-name") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + // Set some session values. session.Values["foo"] = "bar" session.Values[42] = 43 - // Save it. - session.Save(r, w) + // Save it before we write to the response/return from the handler. + err = session.Save(r, w) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } } First we initialize a session store calling NewCookieStore() and passing a @@ -48,13 +61,8 @@ And finally we call session.Save() to save the session in the response. Note that in production code, we should check for errors when calling session.Save(r, w), and either display an error message or otherwise handle it. -Important Note: If you aren't using gorilla/mux, you need to wrap your handlers -with context.ClearHandler as or else you will leak memory! An easy way to do this -is to wrap the top-level mux when calling http.ListenAndServe: - - http.ListenAndServe(":8080", context.ClearHandler(http.DefaultServeMux)) - -The ClearHandler function is provided by the gorilla/context package. +Save must be called before writing to the response, otherwise the session +cookie will not be sent to the client. That's all you need to know for the basic usage. Let's take a look at other options, starting with flash messages. @@ -66,17 +74,24 @@ flashes, call session.Flashes(). Here is an example: func MyHandler(w http.ResponseWriter, r *http.Request) { // Get a session. - session, _ := store.Get(r, "session-name") - // Get the previously flashes, if any. + session, err := store.Get(r, "session-name") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // Get the previous flashes, if any. if flashes := session.Flashes(); len(flashes) > 0 { - // Just print the flash values. - fmt.Fprint(w, "%v", flashes) + // Use the flash values. } else { // Set a new flash. session.AddFlash("Hello, flash messages world!") - fmt.Fprint(w, "No flashes found.") } - session.Save(r, w) + err = session.Save(r, w) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } } Flash messages are useful to set information to be read after a redirection, @@ -107,10 +122,30 @@ so it is easy to register new datatypes for storage in sessions: } As it's not possible to pass a raw type as a parameter to a function, gob.Register() -relies on us passing it an empty pointer to the type as a parameter. In the example -above we've passed it a pointer to a struct and a pointer to a custom type -representing a map[string]interface. This will then allow us to serialise/deserialise -values of those types to and from our sessions. +relies on us passing it a value of the desired type. In the example above we've passed +it a pointer to a struct and a pointer to a custom type representing a +map[string]interface. (We could have passed non-pointer values if we wished.) This will +then allow us to serialise/deserialise values of those types to and from our sessions. + +Note that because session values are stored in a map[string]interface{}, there's +a need to type-assert data when retrieving it. We'll use the Person struct we registered above: + + func MyHandler(w http.ResponseWriter, r *http.Request) { + session, err := store.Get(r, "session-name") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // Retrieve our struct and type-assert it + val := session.Values["person"] + var person = &Person{} + if person, ok := val.(*Person); !ok { + // Handle the case that it's not an expected type + } + + // Now we can use our person object + } By default, session cookies last for a month. This is probably too long for some cases, but it is easy to change this and other attributes during @@ -159,7 +194,11 @@ at once: it's sessions.Save(). Here's an example: session2, _ := store.Get(r, "session-two") session2.Values[42] = 43 // Save all sessions. - sessions.Save(r, w) + err = sessions.Save(r, w) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } } This is possible because when we call Get() from a session store, it adds the diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..25a82cc --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module github.com/gorilla/sessions + +go 1.23 + +require github.com/gorilla/securecookie v1.1.2 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/lex.go b/lex.go new file mode 100644 index 0000000..4bbbe10 --- /dev/null +++ b/lex.go @@ -0,0 +1,102 @@ +// This file contains code adapted from the Go standard library +// https://github.com/golang/go/blob/39ad0fd0789872f9469167be7fe9578625ff246e/src/net/http/lex.go + +package sessions + +import "strings" + +var isTokenTable = [127]bool{ + '!': true, + '#': true, + '$': true, + '%': true, + '&': true, + '\'': true, + '*': true, + '+': true, + '-': true, + '.': true, + '0': true, + '1': true, + '2': true, + '3': true, + '4': true, + '5': true, + '6': true, + '7': true, + '8': true, + '9': true, + 'A': true, + 'B': true, + 'C': true, + 'D': true, + 'E': true, + 'F': true, + 'G': true, + 'H': true, + 'I': true, + 'J': true, + 'K': true, + 'L': true, + 'M': true, + 'N': true, + 'O': true, + 'P': true, + 'Q': true, + 'R': true, + 'S': true, + 'T': true, + 'U': true, + 'W': true, + 'V': true, + 'X': true, + 'Y': true, + 'Z': true, + '^': true, + '_': true, + '`': true, + 'a': true, + 'b': true, + 'c': true, + 'd': true, + 'e': true, + 'f': true, + 'g': true, + 'h': true, + 'i': true, + 'j': true, + 'k': true, + 'l': true, + 'm': true, + 'n': true, + 'o': true, + 'p': true, + 'q': true, + 'r': true, + 's': true, + 't': true, + 'u': true, + 'v': true, + 'w': true, + 'x': true, + 'y': true, + 'z': true, + '|': true, + '~': true, +} + +func isToken(r rune) bool { + i := int(r) + return i < len(isTokenTable) && isTokenTable[i] +} + +func isNotToken(r rune) bool { + return !isToken(r) +} + +func isCookieNameValid(raw string) bool { + if raw == "" { + return false + } + return strings.IndexFunc(raw, isNotToken) < 0 +} diff --git a/options.go b/options.go new file mode 100644 index 0000000..6ed7934 --- /dev/null +++ b/options.go @@ -0,0 +1,24 @@ +// 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 sessions + +import "net/http" + +// Options stores configuration for a session or session store. +// +// Fields are a subset of http.Cookie fields. +type Options struct { + Path string + Domain string + // MaxAge=0 means no Max-Age attribute specified and the cookie will be + // deleted after the browser session ends. + // MaxAge<0 means delete cookie immediately. + // MaxAge>0 means Max-Age attribute present and given in seconds. + MaxAge int + Secure bool + HttpOnly bool + Partitioned bool + SameSite http.SameSite +} diff --git a/sessions.go b/sessions.go index 53111b3..c052b28 100644 --- a/sessions.go +++ b/sessions.go @@ -5,47 +5,34 @@ package sessions import ( + "context" "encoding/gob" "fmt" "net/http" "time" - - "github.com/gorilla/context" ) // Default flashes key. const flashesKey = "_flash" -// Options -------------------------------------------------------------------- - -// Options stores configuration for a session or session store. -// -// Fields are a subset of http.Cookie fields. -type Options struct { - Path string - Domain string - // MaxAge=0 means no 'Max-Age' attribute specified. - // MaxAge<0 means delete cookie now, equivalently 'Max-Age: 0'. - // MaxAge>0 means Max-Age attribute present and given in seconds. - MaxAge int - Secure bool - HttpOnly bool -} - // Session -------------------------------------------------------------------- // NewSession is called by session stores to create a new session instance. func NewSession(store Store, name string) *Session { return &Session{ - Values: make(map[interface{}]interface{}), - store: store, - name: name, + Values: make(map[interface{}]interface{}), + store: store, + name: name, + Options: new(Options), } } // Session stores the values and optional configuration for a session. type Session struct { - ID string + // The ID of the session, generated by stores. It should not be used for + // user data. + ID string + // Values contains the user-data for the session. Values map[interface{}]interface{} Options *Options IsNew bool @@ -88,7 +75,8 @@ func (s *Session) AddFlash(value interface{}, vars ...string) { } // Save is a convenience method to save this session. It is the same as calling -// store.Save(request, response, session) +// store.Save(request, response, session). You should call Save before writing to +// the response or returning from the handler. func (s *Session) Save(r *http.Request, w http.ResponseWriter) error { return s.store.Save(r, w, s) } @@ -119,7 +107,8 @@ const registryKey contextKey = 0 // GetRegistry returns a registry instance for the current request. func GetRegistry(r *http.Request) *Registry { - registry := context.Get(r, registryKey) + var ctx = r.Context() + registry := ctx.Value(registryKey) if registry != nil { return registry.(*Registry) } @@ -127,7 +116,7 @@ func GetRegistry(r *http.Request) *Registry { request: r, sessions: make(map[string]sessionInfo), } - context.Set(r, registryKey, newRegistry) + *r = *r.WithContext(context.WithValue(ctx, registryKey, newRegistry)) return newRegistry } @@ -141,6 +130,9 @@ type Registry struct { // // It returns a new session if there are no sessions registered for the name. func (s *Registry) Get(store Store, name string) (session *Session, err error) { + if !isCookieNameValid(name) { + return nil, fmt.Errorf("sessions: invalid character in cookie name: %s", name) + } if info, ok := s.sessions[name]; ok { session, err = info.s, info.e } else { @@ -186,15 +178,7 @@ func Save(r *http.Request, w http.ResponseWriter) error { // the Expires field calculated based on the MaxAge value, for Internet // Explorer compatibility. func NewCookie(name, value string, options *Options) *http.Cookie { - cookie := &http.Cookie{ - Name: name, - Value: value, - Path: options.Path, - Domain: options.Domain, - MaxAge: options.MaxAge, - Secure: options.Secure, - HttpOnly: options.HttpOnly, - } + cookie := newCookieFromOptions(name, value, options) if options.MaxAge > 0 { d := time.Duration(options.MaxAge) * time.Second cookie.Expires = time.Now().Add(d) diff --git a/sessions_test.go b/sessions_test.go index fcc69eb..9476c22 100644 --- a/sessions_test.go +++ b/sessions_test.go @@ -8,63 +8,19 @@ import ( "bytes" "encoding/gob" "net/http" + "net/http/httptest" + "strings" "testing" ) -// ---------------------------------------------------------------------------- -// ResponseRecorder -// ---------------------------------------------------------------------------- -// Copyright 2009 The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -// ResponseRecorder is an implementation of http.ResponseWriter that -// records its mutations for later inspection in tests. -type ResponseRecorder struct { - Code int // the HTTP response code from WriteHeader - HeaderMap http.Header // the HTTP response headers - Body *bytes.Buffer // if non-nil, the bytes.Buffer to append written data to - Flushed bool -} - // NewRecorder returns an initialized ResponseRecorder. -func NewRecorder() *ResponseRecorder { - return &ResponseRecorder{ +func NewRecorder() *httptest.ResponseRecorder { + return &httptest.ResponseRecorder{ HeaderMap: make(http.Header), Body: new(bytes.Buffer), } } -// DefaultRemoteAddr is the default remote address to return in RemoteAddr if -// an explicit DefaultRemoteAddr isn't set on ResponseRecorder. -const DefaultRemoteAddr = "1.2.3.4" - -// Header returns the response headers. -func (rw *ResponseRecorder) Header() http.Header { - return rw.HeaderMap -} - -// Write always succeeds and writes to rw.Body, if not nil. -func (rw *ResponseRecorder) Write(buf []byte) (int, error) { - if rw.Body != nil { - rw.Body.Write(buf) - } - if rw.Code == 0 { - rw.Code = http.StatusOK - } - return len(buf), nil -} - -// WriteHeader sets rw.Code. -func (rw *ResponseRecorder) WriteHeader(code int) { - rw.Code = code -} - -// Flush sets rw.Flushed to true. -func (rw *ResponseRecorder) Flush() { - rw.Flushed = true -} - // ---------------------------------------------------------------------------- type FlashMessage struct { @@ -74,7 +30,7 @@ type FlashMessage struct { func TestFlashes(t *testing.T) { var req *http.Request - var rsp *ResponseRecorder + var rsp *httptest.ResponseRecorder var hdr http.Header var err error var ok bool @@ -84,6 +40,10 @@ func TestFlashes(t *testing.T) { store := NewCookieStore([]byte("secret-key")) + if store.Options.SameSite != http.SameSiteNoneMode { + t.Fatalf("cookie store error: default same site is not set to None") + } + // Round 1 ---------------------------------------------------------------- req, _ = http.NewRequest("GET", "http://localhost:8080/", nil) @@ -112,11 +72,18 @@ func TestFlashes(t *testing.T) { t.Fatal("No cookies. Header:", hdr) } + if !strings.Contains(cookies[0], "SameSite=None") || !strings.Contains(cookies[0], "Secure") { + t.Fatal("Set-Cookie does not contains SameSite=None with Secure, cookie string:", cookies[0]) + } + + if _, err = store.Get(req, "session:key"); err.Error() != "sessions: invalid character in cookie name: session:key" { + t.Fatalf("Expected error due to invalid cookie name") + } + // Round 2 ---------------------------------------------------------------- req, _ = http.NewRequest("GET", "http://localhost:8080/", nil) req.Header.Add("Cookie", cookies[0]) - rsp = NewRecorder() // Get a session. if session, err = store.Get(req, "session-key"); err != nil { t.Fatalf("Error getting session: %v", err) @@ -176,7 +143,6 @@ func TestFlashes(t *testing.T) { req, _ = http.NewRequest("GET", "http://localhost:8080/", nil) req.Header.Add("Cookie", cookies[0]) - rsp = NewRecorder() // Get a session. if session, err = store.Get(req, "session-key"); err != nil { t.Fatalf("Error getting session: %v", err) @@ -190,6 +156,62 @@ func TestFlashes(t *testing.T) { if custom.Type != 42 || custom.Message != "foo" { t.Errorf("Expected %#v, got %#v", FlashMessage{42, "foo"}, custom) } + + // Round 5 ---------------------------------------------------------------- + // Check if a request shallow copy resets the request context data store. + + req, _ = http.NewRequest("GET", "http://localhost:8080/", nil) + + // Get a session. + if session, err = store.Get(req, "session-key"); err != nil { + t.Fatalf("Error getting session: %v", err) + } + + // Put a test value into the session data store. + session.Values["test"] = "test-value" + + // Create a shallow copy of the request. + req = req.WithContext(req.Context()) + + // Get the session again. + if session, err = store.Get(req, "session-key"); err != nil { + t.Fatalf("Error getting session: %v", err) + } + + // Check if the previous inserted value still exists. + if session.Values["test"] == nil { + t.Fatalf("Session test value is lost in the request context!") + } + + // Check if the previous inserted value has the same value. + if session.Values["test"] != "test-value" { + t.Fatalf("Session test value is changed in the request context!") + } +} + +func TestCookieStoreMapPanic(t *testing.T) { + defer func() { + err := recover() + if err != nil { + t.Fatal(err) + } + }() + + store := NewCookieStore([]byte("aaa0defe5d2839cbc46fc4f080cd7adc")) + req, err := http.NewRequest("GET", "http://www.example.com", nil) + if err != nil { + t.Fatal("failed to create request", err) + } + w := httptest.NewRecorder() + + session := NewSession(store, "hello") + + session.Values["data"] = "hello-world" + + err = session.Save(req, w) + if err != nil { + t.Fatal("failed to save session", err) + } } func init() { diff --git a/store.go b/store.go index 5eaea81..24db822 100644 --- a/store.go +++ b/store.go @@ -6,15 +6,19 @@ package sessions import ( "encoding/base32" - "io" "net/http" "os" - "strings" + "path/filepath" "sync" "github.com/gorilla/securecookie" ) +const ( + // File name prefix for session files. + sessionFilePrefix = "session_" +) + // Store is an interface for custom session stores. // // See CookieStore and FilesystemStore for examples. @@ -46,17 +50,19 @@ type Store interface { // It is recommended to use an authentication key with 32 or 64 bytes. // The encryption key, if set, must be either 16, 24, or 32 bytes to select // AES-128, AES-192, or AES-256 modes. -// -// Use the convenience function securecookie.GenerateRandomKey() to create -// strong keys. func NewCookieStore(keyPairs ...[]byte) *CookieStore { - return &CookieStore{ + cs := &CookieStore{ Codecs: securecookie.CodecsFromPairs(keyPairs...), Options: &Options{ - Path: "/", - MaxAge: 86400 * 30, + Path: "/", + MaxAge: 86400 * 30, + SameSite: http.SameSiteNoneMode, + Secure: true, }, } + + cs.MaxAge(cs.Options.MaxAge) + return cs } // CookieStore stores sessions using secure cookies. @@ -109,6 +115,20 @@ func (s *CookieStore) Save(r *http.Request, w http.ResponseWriter, return nil } +// MaxAge sets the maximum age for the store and the underlying cookie +// implementation. Individual sessions can be deleted by setting Options.MaxAge +// = -1 for that session. +func (s *CookieStore) MaxAge(age int) { + s.Options.MaxAge = age + + // Set the maxAge for each securecookie instance. + for _, codec := range s.Codecs { + if sc, ok := codec.(*securecookie.SecureCookie); ok { + sc.MaxAge(age) + } + } +} + // FilesystemStore ------------------------------------------------------------ var fileMutex sync.RWMutex @@ -123,10 +143,7 @@ func NewFilesystemStore(path string, keyPairs ...[]byte) *FilesystemStore { if path == "" { path = os.TempDir() } - if path[len(path)-1] != '/' { - path += "/" - } - return &FilesystemStore{ + fs := &FilesystemStore{ Codecs: securecookie.CodecsFromPairs(keyPairs...), Options: &Options{ Path: "/", @@ -134,11 +151,14 @@ func NewFilesystemStore(path string, keyPairs ...[]byte) *FilesystemStore { }, path: path, } + + fs.MaxAge(fs.Options.MaxAge) + return fs } // FilesystemStore stores sessions in the filesystem. // -// It also serves as a referece for custom stores. +// It also serves as a reference for custom stores. // // This store is still experimental and not well tested. Feedback is welcome. type FilesystemStore struct { @@ -186,15 +206,30 @@ func (s *FilesystemStore) New(r *http.Request, name string) (*Session, error) { return session, err } +var base32RawStdEncoding = base32.StdEncoding.WithPadding(base32.NoPadding) + // Save adds a single session to the response. +// +// If the Options.MaxAge of the session is <= 0 then the session file will be +// deleted from the store path. With this process it enforces the properly +// session cookie handling so no need to trust in the cookie management in the +// web browser. func (s *FilesystemStore) Save(r *http.Request, w http.ResponseWriter, session *Session) error { + // Delete if max-age is <= 0 + if session.Options.MaxAge <= 0 { + if err := s.erase(session); err != nil && !os.IsNotExist(err) { + return err + } + http.SetCookie(w, NewCookie(session.Name(), "", session.Options)) + return nil + } + if session.ID == "" { // Because the ID is used in the filename, encode it to // use alphanumeric characters only. - session.ID = strings.TrimRight( - base32.StdEncoding.EncodeToString( - securecookie.GenerateRandomKey(32)), "=") + session.ID = base32RawStdEncoding.EncodeToString( + securecookie.GenerateRandomKey(32)) } if err := s.save(session); err != nil { return err @@ -208,6 +243,20 @@ func (s *FilesystemStore) Save(r *http.Request, w http.ResponseWriter, return nil } +// MaxAge sets the maximum age for the store and the underlying cookie +// implementation. Individual sessions can be deleted by setting Options.MaxAge +// = -1 for that session. +func (s *FilesystemStore) MaxAge(age int) { + s.Options.MaxAge = age + + // Set the maxAge for each securecookie instance. + for _, codec := range s.Codecs { + if sc, ok := codec.(*securecookie.SecureCookie); ok { + sc.MaxAge(age) + } + } +} + // save writes encoded session.Values to a file. func (s *FilesystemStore) save(session *Session) error { encoded, err := securecookie.EncodeMulti(session.Name(), session.Values, @@ -215,44 +264,35 @@ func (s *FilesystemStore) save(session *Session) error { if err != nil { return err } - filename := s.path + "session_" + session.ID + filename := filepath.Join(s.path, sessionFilePrefix+filepath.Base(session.ID)) fileMutex.Lock() defer fileMutex.Unlock() - fp, err := os.OpenFile(filename, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) - if err != nil { - return err - } - if _, err = fp.Write([]byte(encoded)); err != nil { - return err - } - fp.Close() - return nil + return os.WriteFile(filename, []byte(encoded), 0600) } // load reads a file and decodes its content into session.Values. func (s *FilesystemStore) load(session *Session) error { - filename := s.path + "session_" + session.ID - fp, err := os.OpenFile(filename, os.O_RDONLY, 0400) + filename := filepath.Join(s.path, sessionFilePrefix+filepath.Base(session.ID)) + fileMutex.RLock() + defer fileMutex.RUnlock() + fdata, err := os.ReadFile(filepath.Clean(filename)) if err != nil { return err } - defer fp.Close() - var fdata []byte - buf := make([]byte, 128) - for { - var n int - n, err = fp.Read(buf[0:]) - fdata = append(fdata, buf[0:n]...) - if err != nil { - if err == io.EOF { - break - } - return err - } - } if err = securecookie.DecodeMulti(session.Name(), string(fdata), &session.Values, s.Codecs...); err != nil { return err } return nil } + +// delete session file +func (s *FilesystemStore) erase(session *Session) error { + filename := filepath.Join(s.path, sessionFilePrefix+filepath.Base(session.ID)) + + fileMutex.RLock() + defer fileMutex.RUnlock() + + err := os.Remove(filename) + return err +} diff --git a/store_test.go b/store_test.go index 022acba..09580bd 100644 --- a/store_test.go +++ b/store_test.go @@ -1,3 +1,7 @@ +// 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 sessions import ( @@ -59,6 +63,10 @@ func TestGH2MaxLength(t *testing.T) { w := httptest.NewRecorder() session, err := store.New(req, "my session") + if err != nil { + t.Fatal("failed to create session", err) + } + session.Values["big"] = make([]byte, base64.StdEncoding.DecodedLen(4096*2)) err = session.Save(req, w) if err == nil { @@ -71,3 +79,55 @@ func TestGH2MaxLength(t *testing.T) { t.Fatal("failed to Save:", err) } } + +// Test delete filesystem store with max-age: -1 +func TestGH8FilesystemStoreDelete(t *testing.T) { + store := NewFilesystemStore("", []byte("some key")) + req, err := http.NewRequest("GET", "http://www.example.com", nil) + if err != nil { + t.Fatal("failed to create request", err) + } + w := httptest.NewRecorder() + + session, err := store.New(req, "hello") + if err != nil { + t.Fatal("failed to create session", err) + } + + err = session.Save(req, w) + if err != nil { + t.Fatal("failed to save session", err) + } + + session.Options.MaxAge = -1 + err = session.Save(req, w) + if err != nil { + t.Fatal("failed to delete session", err) + } +} + +// Test delete filesystem store with max-age: 0 +func TestGH8FilesystemStoreDelete2(t *testing.T) { + store := NewFilesystemStore("", []byte("some key")) + req, err := http.NewRequest("GET", "http://www.example.com", nil) + if err != nil { + t.Fatal("failed to create request", err) + } + w := httptest.NewRecorder() + + session, err := store.New(req, "hello") + if err != nil { + t.Fatal("failed to create session", err) + } + + err = session.Save(req, w) + if err != nil { + t.Fatal("failed to save session", err) + } + + session.Options.MaxAge = 0 + err = session.Save(req, w) + if err != nil { + t.Fatal("failed to delete session", err) + } +}