Encrypt cookies
This commit is contained in:
+143
-17
@@ -126,6 +126,17 @@ func (h *harness) uploadReader(t *testing.T, body io.Reader, headers map[string]
|
||||
return resp
|
||||
}
|
||||
|
||||
// session builds the cookie a browser would be holding for this token. The
|
||||
// value is sealed, so a test cannot simply write the token into it.
|
||||
func (h *harness) session(t *testing.T, token string) *http.Cookie {
|
||||
t.Helper()
|
||||
sealed, err := h.sessions.seal(token)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &http.Cookie{Name: tokenCookie, Value: sealed}
|
||||
}
|
||||
|
||||
func decode[T any](t *testing.T, resp *http.Response) T {
|
||||
t.Helper()
|
||||
defer resp.Body.Close()
|
||||
@@ -709,8 +720,13 @@ func TestLoginStoresTheToken(t *testing.T) {
|
||||
if cookie == nil {
|
||||
t.Fatal("logging in set no cookie")
|
||||
}
|
||||
if cookie.Value != h.token {
|
||||
t.Error("the cookie does not hold the token")
|
||||
// The whole point of sealing it: the credential is not sitting in the
|
||||
// browser's cookie jar for anyone glancing at a developer console.
|
||||
if strings.Contains(cookie.Value, h.token) {
|
||||
t.Error("the cookie carries the token in the clear")
|
||||
}
|
||||
if got := h.sessions.open(cookie.Value); got != h.token {
|
||||
t.Errorf("the cookie does not open to the token (got %q)", got)
|
||||
}
|
||||
if !cookie.HttpOnly {
|
||||
t.Error("the session cookie is readable by scripts")
|
||||
@@ -815,7 +831,7 @@ func TestLoginRedirectIsAllowlisted(t *testing.T) {
|
||||
|
||||
func TestLogoutEndsTheSession(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
resp := h.postForm(t, "/logout", url.Values{}, &http.Cookie{Name: tokenCookie, Value: h.token})
|
||||
resp := h.postForm(t, "/logout", url.Values{}, h.session(t, h.token))
|
||||
resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusSeeOther {
|
||||
@@ -837,7 +853,7 @@ func TestUploadNeverTouchesTheSession(t *testing.T) {
|
||||
t.Errorf("an upload with a one-off token set a session cookie: %v", c)
|
||||
}
|
||||
|
||||
resp = h.formUploadWith(t, &http.Cookie{Name: tokenCookie, Value: h.token},
|
||||
resp = h.formUploadWith(t, h.session(t, h.token),
|
||||
map[string]string{}, "b.bin", "two")
|
||||
resp.Body.Close()
|
||||
if c := findCookie(resp, tokenCookie); c != nil {
|
||||
@@ -848,7 +864,7 @@ func TestUploadNeverTouchesTheSession(t *testing.T) {
|
||||
// A one-off token on the form wins over the logged-in session.
|
||||
func TestExplicitTokenBeatsTheCookie(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
cookie := &http.Cookie{Name: tokenCookie, Value: h.token}
|
||||
cookie := h.session(t, h.token)
|
||||
|
||||
resp := h.formUploadWith(t, cookie, map[string]string{"token": h.admin}, "b.bin", "y")
|
||||
defer resp.Body.Close()
|
||||
@@ -863,6 +879,116 @@ func TestExplicitTokenBeatsTheCookie(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The cookie is sealed with a key this server holds, so one from anywhere else
|
||||
// is not a session. This is also the upgrade path: every cookie written in the
|
||||
// old plain-text format arrives here.
|
||||
func TestCookieFromAnotherKeyIsNotASession(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
|
||||
// A cookie holding the raw token, exactly as the previous format wrote it.
|
||||
plain := &http.Cookie{Name: tokenCookie, Value: h.token}
|
||||
req, _ := http.NewRequest("GET", h.ts.URL+"/", nil)
|
||||
req.AddCookie(plain)
|
||||
resp, err := h.ts.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
page, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
|
||||
if strings.Contains(string(page), ">friend<") {
|
||||
t.Error("a plain-text cookie was accepted as a session")
|
||||
}
|
||||
if c := findCookie(resp, tokenCookie); c == nil || c.MaxAge >= 0 {
|
||||
t.Error("the unusable cookie was not cleared, so the browser keeps sending it")
|
||||
}
|
||||
|
||||
// Nor does it work anywhere the cookie is a credential.
|
||||
res := h.formUploadWith(t, plain, map[string]string{"vanity": "should-not-work"}, "f.txt", "x")
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("upload with a plain-text cookie = %s, want 403", res.Status)
|
||||
}
|
||||
|
||||
// A cookie sealed by a different server is just as dead.
|
||||
other, err := newSealer(filepath.Join(t.TempDir(), "session.key"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sealed, err := other.seal(h.token)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := h.sessions.open(sealed); got != "" {
|
||||
t.Errorf("a cookie from another key opened to %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The key outlives the process: a restart must not log everyone out.
|
||||
func TestSessionKeyIsReusedAcrossRestarts(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "session.key")
|
||||
|
||||
first, err := newSealer(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sealed, err := first.seal("a-token")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
second, err := newSealer(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := second.open(sealed); got != "a-token" {
|
||||
t.Errorf("after a restart the cookie opened to %q, want the token back", got)
|
||||
}
|
||||
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if perm := info.Mode().Perm(); perm != 0o600 {
|
||||
t.Errorf("session key mode = %o, want 600: it is credential material", perm)
|
||||
}
|
||||
|
||||
// A key that is present but unusable must stop the server rather than be
|
||||
// replaced, which would silently log out every session.
|
||||
if err := os.WriteFile(path, []byte("not a key"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := newSealer(path); err == nil {
|
||||
t.Error("a corrupt session key was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// Two seals of the same token must differ, or the cookie becomes a stable
|
||||
// fingerprint of which token a visitor holds.
|
||||
func TestSealingIsNotDeterministic(t *testing.T) {
|
||||
s, err := newSealer(filepath.Join(t.TempDir(), "session.key"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := s.seal("a-token")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := s.seal("a-token")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first == second {
|
||||
t.Error("sealing the same token twice gave the same cookie")
|
||||
}
|
||||
for _, c := range []string{first, second} {
|
||||
if got := s.open(c); got != "a-token" {
|
||||
t.Errorf("cookie opened to %q, want the token", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A session whose token has since been revoked must not wedge the page.
|
||||
func TestStaleCookieIsDropped(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
@@ -893,7 +1019,7 @@ func TestStaleCookieIsDropped(t *testing.T) {
|
||||
func TestIndexShowsWhoIsLoggedIn(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
req, _ := http.NewRequest("GET", h.ts.URL+"/", nil)
|
||||
req.AddCookie(&http.Cookie{Name: tokenCookie, Value: h.token})
|
||||
req.AddCookie(h.session(t, h.token))
|
||||
resp, err := h.ts.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -918,7 +1044,7 @@ func TestCookieDoesNotShadowTheDeleteToken(t *testing.T) {
|
||||
req, _ := http.NewRequest("POST", h.ts.URL+"/d/"+res.ID+"/delete", form)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.AddCookie(&http.Cookie{Name: tokenCookie, Value: h.token})
|
||||
req.AddCookie(h.session(t, h.token))
|
||||
resp, err := h.ts.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -1305,7 +1431,7 @@ func TestOwnerDeletesFromTheListing(t *testing.T) {
|
||||
req, _ := http.NewRequest("POST", h.ts.URL+"/d/"+res.ID+"/delete", strings.NewReader("from=files"))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "text/html")
|
||||
req.AddCookie(&http.Cookie{Name: tokenCookie, Value: h.token})
|
||||
req.AddCookie(h.session(t, h.token))
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -1327,7 +1453,7 @@ func TestOwnerDeletesFromTheListing(t *testing.T) {
|
||||
func TestFilesPageAcceptsTheSession(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
req, _ := http.NewRequest("GET", h.ts.URL+"/files", nil)
|
||||
req.AddCookie(&http.Cookie{Name: tokenCookie, Value: h.admin})
|
||||
req.AddCookie(h.session(t, h.admin))
|
||||
resp, err := h.ts.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -1406,7 +1532,7 @@ func TestAdminDeleteReturnsToTheListing(t *testing.T) {
|
||||
req, _ := http.NewRequest("POST", h.ts.URL+"/d/"+res.ID+"/delete", form)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "text/html")
|
||||
req.AddCookie(&http.Cookie{Name: tokenCookie, Value: h.admin})
|
||||
req.AddCookie(h.session(t, h.admin))
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -1433,7 +1559,7 @@ func TestAdminDeleteStillRequiresAdmin(t *testing.T) {
|
||||
req, _ := http.NewRequest("POST", h.ts.URL+"/d/"+res.ID+"/delete", form)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.AddCookie(&http.Cookie{Name: tokenCookie, Value: h.token})
|
||||
req.AddCookie(h.session(t, h.token))
|
||||
resp, err := h.ts.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -1460,7 +1586,7 @@ func TestFilesLinkIsShownToEveryoneLoggedIn(t *testing.T) {
|
||||
} {
|
||||
req, _ := http.NewRequest("GET", h.ts.URL+"/", nil)
|
||||
if c.token != "" {
|
||||
req.AddCookie(&http.Cookie{Name: tokenCookie, Value: c.token})
|
||||
req.AddCookie(h.session(t, c.token))
|
||||
}
|
||||
resp, err := h.ts.Client().Do(req)
|
||||
if err != nil {
|
||||
@@ -1654,7 +1780,7 @@ func TestInfoPageOffersADirectButtonToAnOwner(t *testing.T) {
|
||||
} {
|
||||
req, _ := http.NewRequest("GET", h.ts.URL+"/i/"+res.ID, nil)
|
||||
if c.token != "" {
|
||||
req.AddCookie(&http.Cookie{Name: tokenCookie, Value: c.token})
|
||||
req.AddCookie(h.session(t, c.token))
|
||||
}
|
||||
resp, err := h.ts.Client().Do(req)
|
||||
if err != nil {
|
||||
@@ -1670,7 +1796,7 @@ func TestInfoPageOffersADirectButtonToAnOwner(t *testing.T) {
|
||||
|
||||
// And that button actually works with no token field at all.
|
||||
resp := h.postForm(t, "/d/"+res.ID+"/delete",
|
||||
url.Values{"from": {"info"}}, &http.Cookie{Name: tokenCookie, Value: h.token})
|
||||
url.Values{"from": {"info"}}, h.session(t, h.token))
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("owner delete => %s", resp.Status)
|
||||
@@ -1787,7 +1913,7 @@ func TestHeaderReflectsTheSession(t *testing.T) {
|
||||
for _, path := range []string{"/", "/login"} {
|
||||
req, _ := http.NewRequest("GET", h.ts.URL+path, nil)
|
||||
if c.token != "" {
|
||||
req.AddCookie(&http.Cookie{Name: tokenCookie, Value: c.token})
|
||||
req.AddCookie(h.session(t, c.token))
|
||||
}
|
||||
resp, err := h.ts.Client().Do(req)
|
||||
if err != nil {
|
||||
@@ -2010,7 +2136,7 @@ func TestPassphraseSessionsAreMemoised(t *testing.T) {
|
||||
resp.Body.Close()
|
||||
|
||||
req, _ := http.NewRequest("GET", h.ts.URL+"/", nil)
|
||||
req.AddCookie(&http.Cookie{Name: tokenCookie, Value: passphrase})
|
||||
req.AddCookie(h.session(t, passphrase))
|
||||
first, err := h.ts.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -2028,7 +2154,7 @@ func TestRotationEndsLiveSessions(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
|
||||
// A logged-in browser, and a page render proving the session works.
|
||||
session := &http.Cookie{Name: tokenCookie, Value: h.token}
|
||||
session := h.session(t, h.token)
|
||||
req, _ := http.NewRequest("GET", h.ts.URL+"/", nil)
|
||||
req.AddCookie(session)
|
||||
resp, err := h.ts.Client().Do(req)
|
||||
|
||||
Reference in New Issue
Block a user