Skip to content

Commit

Permalink
Cookies (#27)
Browse files Browse the repository at this point in the history
cookies
  • Loading branch information
komuw authored Jun 14, 2022
1 parent 0a69e65 commit b502e10
Show file tree
Hide file tree
Showing 5 changed files with 252 additions and 12 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
100 changes: 100 additions & 0 deletions cookie/cookie.go
Original file line number Diff line number Diff line change
@@ -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)
}
139 changes: 139 additions & 0 deletions cookie/cookie_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
10 changes: 5 additions & 5 deletions middleware/security.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -129,15 +129,15 @@ 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 *;
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 {
Expand Down
14 changes: 7 additions & 7 deletions middleware/security_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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",
Expand All @@ -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)
Expand Down

0 comments on commit b502e10

Please sign in to comment.