diff --git a/CHANGELOG.md b/CHANGELOG.md index 74b0d3e0..811cee34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,3 +8,4 @@ Most recent version is listed first. - add build/test cache: https://github.com/komuw/goweb/pull/24 - harmonize timeouts: https://github.com/komuw/goweb/pull/25 - add panic middleware: https://github.com/komuw/goweb/pull/26 +- cookies: https://github.com/komuw/goweb/pull/27 diff --git a/cookie/cookie.go b/cookie/cookie.go new file mode 100644 index 00000000..e6b016e2 --- /dev/null +++ b/cookie/cookie.go @@ -0,0 +1,100 @@ +// Package cookie provides utilities for using HTTP cookies. +package cookie + +import ( + "net/http" + "time" +) + +const ( + serverCookieHeader = "Set-Cookie" + clientCookieHeader = "Cookie" + _ = clientCookieHeader // silence unused var linter + maxCookiesPerDomain = 50 + maxCookieSize = 4096 // bytes + // see: https://datatracker.ietf.org/doc/html/rfc6265#section-6.1 +) + +// Set creates a cookie on the HTTP response. +// +// If domain is an empty string, the cookie is set for the current host(excluding subdomains) +// else it is set for the given domain and its subdomains. +// If mAge <= 0, a session cookie is created. +// If jsAccess is false, the cookie will be in-accesible to Javascript. +// In most cases you should set it to false(exceptions are rare, like when setting a csrf cookie) +func Set( + w http.ResponseWriter, + name string, + value string, + domain string, + mAge time.Duration, + jsAccess bool, +) { + expires := time.Now().Add(mAge) + maxAge := int(mAge.Seconds()) + + if mAge <= 0 { + // this is a session cookie + expires = time.Time{} + maxAge = 0 + } + + httpOnly := true + if jsAccess { + httpOnly = false + } + + c := &http.Cookie{ + Name: name, + Value: value, + // If Domain is omitted(empty string), it defaults to the current host, excluding including subdomains. + // If a domain is specified, then subdomains are always included. + Domain: domain, + // Expires is relative to the client the cookie is being set on, not the server. + // Session cookies are those that do not specify the Expires or Max-Age attribute. + Expires: expires, + // Every browser that supports MaxAge will ignore Expires regardless of it's value + // https://datatracker.ietf.org/doc/html/rfc2616#section-13.2.4 + MaxAge: maxAge, + Path: "/", + + // Security + HttpOnly: httpOnly, // If true, makes cookie inaccessible to JS. Should be false for csrf cookies. + Secure: true, // https only. + SameSite: http.SameSiteStrictMode, + } + + // Session cookies are those that do not specify the Expires or Max-Age attribute. + // Session cookies are removed when the client shuts down(session ends). + // The browser defines when the "current session" ends, + // some browsers use session restoring when restarting. + // This can cause session cookies to last indefinitely. + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#session_cookie + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#define_the_lifetime_of_a_cookie + + http.SetCookie(w, c) +} + +// Delete removes the named cookie. +func Delete(w http.ResponseWriter, name, domain string) { + h := w.Header().Values(serverCookieHeader) + if len(h) <= 0 { + return + } + + if len(h) >= maxCookiesPerDomain || len(h[0]) > maxCookieSize { + // cookies exceed limits set out in RFC6265 + w.Header().Del(serverCookieHeader) + return + } + + c := &http.Cookie{ + Name: name, + Value: "", + Domain: domain, + Path: "/", + MaxAge: -1, + Expires: time.Unix(0, 0), + } + http.SetCookie(w, c) +} diff --git a/cookie/cookie_test.go b/cookie/cookie_test.go new file mode 100644 index 00000000..d151d52c --- /dev/null +++ b/cookie/cookie_test.go @@ -0,0 +1,139 @@ +package cookie + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/akshayjshah/attest" +) + +func setHandler(name, value, domain string, mAge time.Duration, jsAccess bool) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + Set(w, name, value, domain, mAge, jsAccess) + fmt.Fprint(w, "hello") + } +} + +func TestSet(t *testing.T) { + t.Parallel() + + t.Run("set succeds", func(t *testing.T) { + t.Parallel() + + name := "logId" + value := "skmHajue8k" + domain := "localhost" + mAge := 1 * time.Minute + handler := setHandler(name, value, domain, mAge, false) + + rec := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/someUri", nil) + handler.ServeHTTP(rec, r) + + res := rec.Result() + defer res.Body.Close() + + attest.Equal(t, res.StatusCode, http.StatusOK) + attest.Equal(t, len(res.Cookies()), 1) + attest.Equal(t, res.Cookies()[0].Name, name) + + cookie := res.Cookies()[0] + now := time.Now() + + attest.True(t, cookie.MaxAge >= 1) + attest.True(t, cookie.Expires.Sub(now) > 1) + attest.Equal(t, cookie.HttpOnly, true) + }) + + t.Run("session cookie", func(t *testing.T) { + t.Parallel() + + name := "logId" + value := "skmHajue8k" + domain := "localhost" + mAge := 0 * time.Minute + handler := setHandler(name, value, domain, mAge, false) + + rec := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/someUri", nil) + handler.ServeHTTP(rec, r) + + res := rec.Result() + defer res.Body.Close() + + attest.Equal(t, res.StatusCode, http.StatusOK) + attest.Equal(t, len(res.Cookies()), 1) + attest.Equal(t, res.Cookies()[0].Name, name) + + cookie := res.Cookies()[0] + attest.Equal(t, cookie.MaxAge, 0) + attest.Equal(t, cookie.Expires, time.Time{}) + attest.Equal(t, cookie.HttpOnly, true) + }) + + t.Run("js accesible cookie", func(t *testing.T) { + t.Parallel() + + name := "csrf" + value := "skmHajue8k" + domain := "localhost" + mAge := 1 * time.Minute + jsAccess := true + handler := setHandler(name, value, domain, mAge, jsAccess) + + rec := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/someUri", nil) + handler.ServeHTTP(rec, r) + + res := rec.Result() + defer res.Body.Close() + + attest.Equal(t, res.StatusCode, http.StatusOK) + attest.Equal(t, len(res.Cookies()), 1) + attest.Equal(t, res.Cookies()[0].Name, name) + + cookie := res.Cookies()[0] + now := time.Now() + + attest.True(t, cookie.MaxAge >= 1) + attest.True(t, cookie.Expires.Sub(now) > 1) + attest.Equal(t, cookie.HttpOnly, false) + }) +} + +func deleteHandler(name, value, domain string, mAge time.Duration) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + Set(w, name, value, domain, mAge, false) + Delete(w, name, domain) + fmt.Fprint(w, "hello") + } +} + +func TestDelete(t *testing.T) { + t.Parallel() + + t.Run("delete", func(t *testing.T) { + t.Parallel() + + name := "logId" + domain := "localhost" + value := "skmHajue8k" + mAge := 1 * time.Minute + rec := httptest.NewRecorder() + handler := deleteHandler(name, value, domain, mAge) + + r := httptest.NewRequest(http.MethodGet, "/someUri", nil) + handler.ServeHTTP(rec, r) + res := rec.Result() + defer res.Body.Close() + + attest.Equal(t, res.StatusCode, http.StatusOK) + attest.Equal(t, len(res.Cookies()), 2) // deleting cookies is done by appending to existing cookies. + + cookie := res.Cookies()[1] + attest.True(t, cookie.MaxAge < 0) + }) +} diff --git a/middleware/security.go b/middleware/security.go index d1a60ffe..1a1dcea3 100644 --- a/middleware/security.go +++ b/middleware/security.go @@ -35,7 +35,7 @@ const ( // usage: // middleware.Security(yourHandler(), "example.com") // -func Security(wrappedHandler http.HandlerFunc, host string) http.HandlerFunc { +func Security(wrappedHandler http.HandlerFunc, domain string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -66,10 +66,10 @@ func Security(wrappedHandler http.HandlerFunc, host string) http.HandlerFunc { // content is only permitted from: // - the document's origin(and subdomains) // - images may load from anywhere - // - media is allowed from host(and its subdomains) + // - media is allowed from domain(and its subdomains) // - executable scripts is only allowed from self(& subdomains). // - DOM xss(eg setting innerHtml) is blocked by require-trusted-types. - getCsp(host, nonce), + getCsp(domain, nonce), ) w.Header().Set( @@ -129,7 +129,7 @@ func GetCspNonce(c context.Context) string { return defaultNonce } -func getCsp(host, nonce string) string { +func getCsp(domain, nonce string) string { return fmt.Sprintf(` default-src 'self' %s *.%s; img-src *; @@ -137,7 +137,7 @@ media-src %s *.%s; object-src 'none'; base-uri 'none'; require-trusted-types-for 'script'; -script-src 'self' %s *.%s 'unsafe-inline' 'nonce-%s';`, host, host, host, host, host, host, nonce) +script-src 'self' %s *.%s 'unsafe-inline' 'nonce-%s';`, domain, domain, domain, domain, domain, domain, nonce) } func getSts(age time.Duration) string { diff --git a/middleware/security_test.go b/middleware/security_test.go index 3a680d39..18ea5e85 100644 --- a/middleware/security_test.go +++ b/middleware/security_test.go @@ -29,8 +29,8 @@ func TestSecurity(t *testing.T) { t.Parallel() msg := "hello" - host := "example.com" - wrappedHandler := Security(echoHandler(msg), host) + domain := "example.com" + wrappedHandler := Security(echoHandler(msg), domain) rec := httptest.NewRecorder() r := httptest.NewRequest(http.MethodGet, "/someUri", nil) @@ -50,8 +50,8 @@ func TestSecurity(t *testing.T) { t.Parallel() msg := "hello" - host := "example.com" - wrappedHandler := Security(echoHandler(msg), host) + domain := "example.com" + wrappedHandler := Security(echoHandler(msg), domain) rec := httptest.NewRecorder() r := httptest.NewRequest(http.MethodGet, "/someUri", nil) @@ -63,7 +63,7 @@ func TestSecurity(t *testing.T) { expect := map[string]string{ permissionsPolicyHeader: "interest-cohort=()", - cspHeader: getCsp(host, res.Header.Get(nonceHeader)), + cspHeader: getCsp(domain, res.Header.Get(nonceHeader)), xContentOptionsHeader: "nosniff", xFrameHeader: "DENY", corpHeader: "same-site", @@ -86,8 +86,8 @@ func TestGetCspNonce(t *testing.T) { t.Parallel() msg := "hello" - host := "example.com" - wrappedHandler := Security(echoHandler(msg), host) + domain := "example.com" + wrappedHandler := Security(echoHandler(msg), domain) rec := httptest.NewRecorder() r := httptest.NewRequest(http.MethodGet, "/someUri", nil)