Encrypt cookies

This commit is contained in:
2026-09-13 11:45:15 +02:00
parent 99bdadf248
commit cb34bae784
11 changed files with 344 additions and 34 deletions
+6
View File
@@ -23,6 +23,12 @@ When you put it behind a reverse proxy that terminates TLS, set `--public-url`,
and make sure the proxy neither buffers request bodies nor imposes its own and make sure the proxy neither buffers request bodies nor imposes its own
upload limit. upload limit.
The data directory holds the uploads, `tokens.json`, and `session.key`, which
seals the login cookie so that it carries a session rather than the token
itself. The last two are credential material, written `0600`. Deleting
`session.key` logs every browser session out and costs nothing else; a new one
is generated on the next start.
## Options ## Options
**Read `./uncensored-send --help` rather than this file.** **Read `./uncensored-send --help` rather than this file.**
+1 -1
View File
@@ -88,7 +88,7 @@ func compareExpiry(a, b *time.Time) int {
} }
func (s *Server) handleFiles(w http.ResponseWriter, r *http.Request) { func (s *Server) handleFiles(w http.ResponseWriter, r *http.Request) {
lim, err := s.limitsFor(r, credential(r)) lim, err := s.limitsFor(r, s.credential(r))
if err != nil { if err != nil {
s.fail(w, r, http.StatusUnauthorized, "Unrecognised token.") s.fail(w, r, http.StatusUnauthorized, "Unrecognised token.")
return return
+29 -6
View File
@@ -21,22 +21,40 @@ const tokenCookie = "uncensored_send_token"
const rememberFor = 365 * 24 * time.Hour const rememberFor = 365 * 24 * time.Hour
// cookieCredential returns the remembered token, if any. // cookieCredential returns the remembered token, if any.
func cookieCredential(r *http.Request) string { //
// The cookie carries the token sealed, so this is also where an unreadable one
// - another server's key, or the older plain format - quietly becomes "no
// session" rather than a credential that cannot be resolved.
func (s *Server) cookieCredential(r *http.Request) string {
c, err := r.Cookie(tokenCookie) c, err := r.Cookie(tokenCookie)
if err != nil { if err != nil {
return "" return ""
} }
return strings.TrimSpace(c.Value) return s.sessions.open(strings.TrimSpace(c.Value))
}
// staleSession reports a cookie that is present but will not open: sealed with
// another server's key, or written in the plain-text format this replaced.
// There is no session in it, and leaving it in the browser means sending a dead
// credential on every request for a year, so the page treats it exactly as it
// treats a revoked token: say so once, and clear it.
func (s *Server) staleSession(r *http.Request) bool {
c, err := r.Cookie(tokenCookie)
if err != nil {
return false
}
value := strings.TrimSpace(c.Value)
return value != "" && s.sessions.open(value) == ""
} }
// credential resolves the caller's token from an explicit header first, then // credential resolves the caller's token from an explicit header first, then
// from the remembered cookie. Upload additionally accepts a form field, which // from the remembered cookie. Upload additionally accepts a form field, which
// takes precedence over both. // takes precedence over both.
func credential(r *http.Request) string { func (s *Server) credential(r *http.Request) string {
if t := bearer(r); t != "" { if t := bearer(r); t != "" {
return t return t
} }
return cookieCredential(r) return s.cookieCredential(r)
} }
// logIn stores the token in a cookie. // logIn stores the token in a cookie.
@@ -48,10 +66,14 @@ func credential(r *http.Request) string {
// //
// When persist is false the cookie carries no lifetime and the browser drops it // When persist is false the cookie carries no lifetime and the browser drops it
// when it closes, which is the right default on a machine that is not yours. // when it closes, which is the right default on a machine that is not yours.
func (s *Server) logIn(w http.ResponseWriter, r *http.Request, token string, persist bool) { func (s *Server) logIn(w http.ResponseWriter, r *http.Request, token string, persist bool) error {
sealed, err := s.sessions.seal(token)
if err != nil {
return err
}
c := &http.Cookie{ c := &http.Cookie{
Name: tokenCookie, Name: tokenCookie,
Value: token, Value: sealed,
Path: s.cfg.BasePath, Path: s.cfg.BasePath,
HttpOnly: true, HttpOnly: true,
Secure: s.isHTTPS(r), Secure: s.isHTTPS(r),
@@ -61,6 +83,7 @@ func (s *Server) logIn(w http.ResponseWriter, r *http.Request, token string, per
c.MaxAge = int(rememberFor.Seconds()) c.MaxAge = int(rememberFor.Seconds())
} }
http.SetCookie(w, c) http.SetCookie(w, c)
return nil
} }
// forget clears a remembered token. // forget clears a remembered token.
+1 -1
View File
@@ -98,7 +98,7 @@ func (s *Server) deleteCredentials(w http.ResponseWriter, r *http.Request) []str
} }
add(bearer(r)) add(bearer(r))
add(cookieCredential(r)) add(s.cookieCredential(r))
// A small form post; the cap keeps this from being a way to stream a body // A small form post; the cap keeps this from being a way to stream a body
// into memory. A non-form body simply fails to parse and is ignored. // into memory. A non-form body simply fails to parse and is ignored.
+9 -2
View File
@@ -40,7 +40,7 @@ func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) {
next := destination(r.URL.Query().Get("next")) next := destination(r.URL.Query().Get("next"))
// Already logged in: say so rather than showing an empty form. // Already logged in: say so rather than showing an empty form.
if lim, err := s.limitsFor(r, cookieCredential(r)); err == nil && !lim.Anonymous() { if lim, err := s.limitsFor(r, s.cookieCredential(r)); err == nil && !lim.Anonymous() {
s.render(w, http.StatusOK, "login.html", loginPage{ s.render(w, http.StatusOK, "login.html", loginPage{
page: s.page(r, "Log in", false), page: s.page(r, "Log in", false),
Next: next, Next: next,
@@ -82,7 +82,14 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
return return
} }
s.logIn(w, r, token, r.PostFormValue("persist") != "") if err := s.logIn(w, r, token, r.PostFormValue("persist") != ""); err != nil {
// Sealing needs nothing but randomness, so this is the machine failing
// rather than the caller: say so instead of leaving them logged out
// with no explanation.
s.log.Error("sealing the session", "err", err)
s.fail(w, r, http.StatusInternalServerError, "Could not start a session.")
return
}
s.log.Info("logged in", "name", lim.Name, "ip", clientIP(r, s.cfg)) s.log.Info("logged in", "name", lim.Name, "ip", clientIP(r, s.cfg))
if wantsJSON(r) { if wantsJSON(r) {
+9 -3
View File
@@ -61,13 +61,19 @@ type indexPage struct {
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
// A remembered token is resolved server-side, so the page can show the real // A remembered token is resolved server-side, so the page can show the real
// limits without the cookie ever being readable by a script. // limits without the cookie ever being readable by a script.
lim, err := s.limitsFor(r, cookieCredential(r)) lim, err := s.limitsFor(r, s.cookieCredential(r))
stale := false stale := false
if err != nil { switch {
case err != nil:
// The token was revoked or the file was edited; end the session rather // The token was revoked or the file was edited; end the session rather
// than leave the caller wondering why uploads fail. // than leave the caller wondering why uploads fail.
s.forget(w, r) s.forget(w, r)
lim, stale = auth.Anonymous(s.cfg), true lim, stale = auth.Anonymous(s.cfg), true
case s.staleSession(r):
// A cookie this server cannot open. Same treatment: it is not a
// session, and it should stop being sent.
s.forget(w, r)
stale = true
} }
s.render(w, http.StatusOK, "index.html", indexPage{ s.render(w, http.StatusOK, "index.html", indexPage{
@@ -122,7 +128,7 @@ func (s *Server) renderInfo(w http.ResponseWriter, r *http.Request, m *store.Met
Size: config.FormatSize(m.Size), Size: config.FormatSize(m.Size),
Expires: describeExpiry(m.Expires, s.now()), Expires: describeExpiry(m.Expires, s.now()),
URL: s.objectURL(r, m.ID), URL: s.objectURL(r, m.ID),
CanDelete: s.mayDelete(r, m, credential(r)), CanDelete: s.mayDelete(r, m, s.credential(r)),
Error: errMsg, Error: errMsg,
}) })
} }
+10 -1
View File
@@ -37,6 +37,10 @@ type Server struct {
// none. Resolved once: the assets cannot change while the process runs. // none. Resolved once: the assets cannot change while the process runs.
favicon string favicon string
// sessions seals the login cookie, so the token it remembers is not
// legible to anyone reading the browser's cookie jar.
sessions *sealer
now func() time.Time // swappable in tests now func() time.Time // swappable in tests
} }
@@ -45,6 +49,10 @@ func New(cfg *config.Config, st *store.Store, tokens *auth.File, log *slog.Logge
if err != nil { if err != nil {
return nil, err return nil, err
} }
sessions, err := newSealer(sessionKeyPath(cfg.DataDir))
if err != nil {
return nil, err
}
s := &Server{ s := &Server{
cfg: cfg, cfg: cfg,
store: st, store: st,
@@ -56,6 +64,7 @@ func New(cfg *config.Config, st *store.Store, tokens *auth.File, log *slog.Logge
slots: make(chan struct{}, cfg.MaxConcurrent), slots: make(chan struct{}, cfg.MaxConcurrent),
now: time.Now, now: time.Now,
favicon: faviconFor(web.Static()), favicon: faviconFor(web.Static()),
sessions: sessions,
} }
s.handler = s.routes() s.handler = s.routes()
return s, nil return s, nil
@@ -245,7 +254,7 @@ func (s *Server) page(r *http.Request, title string, script bool) page {
if s.favicon != "" { if s.favicon != "" {
p.Favicon = s.cfg.BasePath + "static/" + s.favicon p.Favicon = s.cfg.BasePath + "static/" + s.favicon
} }
if lim, err := s.limitsFor(r, cookieCredential(r)); err == nil { if lim, err := s.limitsFor(r, s.cookieCredential(r)); err == nil {
p.User, p.Admin = lim.Name, lim.Admin p.User, p.Admin = lim.Name, lim.Admin
} }
return p return p
+143 -17
View File
@@ -126,6 +126,17 @@ func (h *harness) uploadReader(t *testing.T, body io.Reader, headers map[string]
return resp 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 { func decode[T any](t *testing.T, resp *http.Response) T {
t.Helper() t.Helper()
defer resp.Body.Close() defer resp.Body.Close()
@@ -709,8 +720,13 @@ func TestLoginStoresTheToken(t *testing.T) {
if cookie == nil { if cookie == nil {
t.Fatal("logging in set no cookie") t.Fatal("logging in set no cookie")
} }
if cookie.Value != h.token { // The whole point of sealing it: the credential is not sitting in the
t.Error("the cookie does not hold the token") // 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 { if !cookie.HttpOnly {
t.Error("the session cookie is readable by scripts") t.Error("the session cookie is readable by scripts")
@@ -815,7 +831,7 @@ func TestLoginRedirectIsAllowlisted(t *testing.T) {
func TestLogoutEndsTheSession(t *testing.T) { func TestLogoutEndsTheSession(t *testing.T) {
h := newHarness(t, nil) 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() resp.Body.Close()
if resp.StatusCode != http.StatusSeeOther { 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) 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") map[string]string{}, "b.bin", "two")
resp.Body.Close() resp.Body.Close()
if c := findCookie(resp, tokenCookie); c != nil { 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. // A one-off token on the form wins over the logged-in session.
func TestExplicitTokenBeatsTheCookie(t *testing.T) { func TestExplicitTokenBeatsTheCookie(t *testing.T) {
h := newHarness(t, nil) 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") resp := h.formUploadWith(t, cookie, map[string]string{"token": h.admin}, "b.bin", "y")
defer resp.Body.Close() 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. // A session whose token has since been revoked must not wedge the page.
func TestStaleCookieIsDropped(t *testing.T) { func TestStaleCookieIsDropped(t *testing.T) {
h := newHarness(t, nil) h := newHarness(t, nil)
@@ -893,7 +1019,7 @@ func TestStaleCookieIsDropped(t *testing.T) {
func TestIndexShowsWhoIsLoggedIn(t *testing.T) { func TestIndexShowsWhoIsLoggedIn(t *testing.T) {
h := newHarness(t, nil) h := newHarness(t, nil)
req, _ := http.NewRequest("GET", h.ts.URL+"/", 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) resp, err := h.ts.Client().Do(req)
if err != nil { if err != nil {
t.Fatal(err) 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, _ := http.NewRequest("POST", h.ts.URL+"/d/"+res.ID+"/delete", form)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json") 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) resp, err := h.ts.Client().Do(req)
if err != nil { if err != nil {
t.Fatal(err) 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, _ := 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("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "text/html") 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) resp, err := client.Do(req)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -1327,7 +1453,7 @@ func TestOwnerDeletesFromTheListing(t *testing.T) {
func TestFilesPageAcceptsTheSession(t *testing.T) { func TestFilesPageAcceptsTheSession(t *testing.T) {
h := newHarness(t, nil) h := newHarness(t, nil)
req, _ := http.NewRequest("GET", h.ts.URL+"/files", 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) resp, err := h.ts.Client().Do(req)
if err != nil { if err != nil {
t.Fatal(err) 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, _ := http.NewRequest("POST", h.ts.URL+"/d/"+res.ID+"/delete", form)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "text/html") 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) resp, err := client.Do(req)
if err != nil { if err != nil {
t.Fatal(err) 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, _ := http.NewRequest("POST", h.ts.URL+"/d/"+res.ID+"/delete", form)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json") 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) resp, err := h.ts.Client().Do(req)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -1460,7 +1586,7 @@ func TestFilesLinkIsShownToEveryoneLoggedIn(t *testing.T) {
} { } {
req, _ := http.NewRequest("GET", h.ts.URL+"/", nil) req, _ := http.NewRequest("GET", h.ts.URL+"/", nil)
if c.token != "" { 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) resp, err := h.ts.Client().Do(req)
if err != nil { if err != nil {
@@ -1654,7 +1780,7 @@ func TestInfoPageOffersADirectButtonToAnOwner(t *testing.T) {
} { } {
req, _ := http.NewRequest("GET", h.ts.URL+"/i/"+res.ID, nil) req, _ := http.NewRequest("GET", h.ts.URL+"/i/"+res.ID, nil)
if c.token != "" { 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) resp, err := h.ts.Client().Do(req)
if err != nil { if err != nil {
@@ -1670,7 +1796,7 @@ func TestInfoPageOffersADirectButtonToAnOwner(t *testing.T) {
// And that button actually works with no token field at all. // And that button actually works with no token field at all.
resp := h.postForm(t, "/d/"+res.ID+"/delete", 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() resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
t.Fatalf("owner delete => %s", resp.Status) t.Fatalf("owner delete => %s", resp.Status)
@@ -1787,7 +1913,7 @@ func TestHeaderReflectsTheSession(t *testing.T) {
for _, path := range []string{"/", "/login"} { for _, path := range []string{"/", "/login"} {
req, _ := http.NewRequest("GET", h.ts.URL+path, nil) req, _ := http.NewRequest("GET", h.ts.URL+path, nil)
if c.token != "" { 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) resp, err := h.ts.Client().Do(req)
if err != nil { if err != nil {
@@ -2010,7 +2136,7 @@ func TestPassphraseSessionsAreMemoised(t *testing.T) {
resp.Body.Close() resp.Body.Close()
req, _ := http.NewRequest("GET", h.ts.URL+"/", nil) 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) first, err := h.ts.Client().Do(req)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -2028,7 +2154,7 @@ func TestRotationEndsLiveSessions(t *testing.T) {
h := newHarness(t, nil) h := newHarness(t, nil)
// A logged-in browser, and a page render proving the session works. // 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, _ := http.NewRequest("GET", h.ts.URL+"/", nil)
req.AddCookie(session) req.AddCookie(session)
resp, err := h.ts.Client().Do(req) resp, err := h.ts.Client().Do(req)
+133
View File
@@ -0,0 +1,133 @@
package server
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
)
// sessionKeyName is the file holding the key that seals session cookies. It
// sits beside the token file and is written just as tightly: anyone who can
// read it can mint a session for any token they already know.
const sessionKeyName = "session.key"
// sessionKeyPerm matches the token file rather than the rest of the data
// directory, which is group-writable by design.
const sessionKeyPerm fs.FileMode = 0o600
// sealer turns a token into an opaque cookie value and back.
//
// The point is not to defend the cookie from its own browser - a stolen cookie
// is a working session either way, exactly as it was when the token sat there
// in the clear. The point is that the token itself no longer appears in it, so
// reading the cookie jar over someone's shoulder, or in a screenshot of a
// developer console, does not hand over a credential that also works against
// the API from anywhere else.
type sealer struct {
aead cipher.AEAD
}
// newSealer loads the key at path, creating it on first run.
//
// A key that is present but unusable is an error rather than a reason to
// generate a new one: silently replacing it would log out every session, and
// an operator who wants that can delete the file and say so.
func newSealer(path string) (*sealer, error) {
key, err := readSessionKey(path)
if errors.Is(err, os.ErrNotExist) {
if key, err = createSessionKey(path); err != nil {
return nil, err
}
} else if err != nil {
return nil, err
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("session key: %w", err)
}
aead, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("session key: %w", err)
}
return &sealer{aead: aead}, nil
}
func sessionKeyPath(dataDir string) string {
return filepath.Join(dataDir, sessionKeyName)
}
func readSessionKey(path string) ([]byte, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
key, err := hex.DecodeString(strings.TrimSpace(string(raw)))
if err != nil {
return nil, fmt.Errorf("%s is not a hex key: %w", path, err)
}
if len(key) != 32 {
return nil, fmt.Errorf("%s holds a %d-byte key, want 32", path, len(key))
}
return key, nil
}
// createSessionKey writes a new key, refusing to clobber one that appeared in
// the meantime: two servers started at once must not end up with the file
// holding the key only one of them is using.
func createSessionKey(path string) ([]byte, error) {
key := make([]byte, 32)
if _, err := rand.Read(key); err != nil {
return nil, fmt.Errorf("generating a session key: %w", err)
}
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, sessionKeyPerm)
if err != nil {
if errors.Is(err, os.ErrExist) {
return readSessionKey(path)
}
return nil, fmt.Errorf("creating %s: %w", path, err)
}
defer f.Close()
if _, err := fmt.Fprintf(f, "%x\n", key); err != nil {
return nil, fmt.Errorf("writing %s: %w", path, err)
}
// The umask may have widened the mode; say what it has to be.
if err := f.Chmod(sessionKeyPerm); err != nil {
return nil, fmt.Errorf("securing %s: %w", path, err)
}
return key, f.Sync()
}
// seal returns the cookie value carrying token.
func (s *sealer) seal(token string) (string, error) {
nonce := make([]byte, s.aead.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return "", err
}
sealed := s.aead.Seal(nonce, nonce, []byte(token), nil)
return base64.RawURLEncoding.EncodeToString(sealed), nil
}
// open recovers the token from a cookie value. Anything that does not decrypt
// is treated as absent: a cookie from an older format or another key is not an
// error to report, it is simply not a session.
func (s *sealer) open(value string) string {
raw, err := base64.RawURLEncoding.DecodeString(value)
if err != nil || len(raw) < s.aead.NonceSize() {
return ""
}
nonce, body := raw[:s.aead.NonceSize()], raw[s.aead.NonceSize():]
token, err := s.aead.Open(nil, nonce, body, nil)
if err != nil {
return ""
}
return string(token)
}
+2 -2
View File
@@ -76,7 +76,7 @@ func (s *Server) uploadRaw(w http.ResponseWriter, r *http.Request, ip string) {
filename: filenameFromDisposition(r.Header.Get("Content-Disposition")), filename: filenameFromDisposition(r.Header.Get("Content-Disposition")),
} }
if req.token == "" { if req.token == "" {
req.token = cookieCredential(r) req.token = s.cookieCredential(r)
} }
s.storeUpload(w, r, req, r.Body, ip) s.storeUpload(w, r, req, r.Body, ip)
} }
@@ -128,7 +128,7 @@ func (s *Server) uploadMultipart(w http.ResponseWriter, r *http.Request, boundar
// remembered. This happens after the fields precisely so a typed // remembered. This happens after the fields precisely so a typed
// token still wins. // token still wins.
if req.token == "" { if req.token == "" {
req.token = cookieCredential(r) req.token = s.cookieCredential(r)
} }
s.storeUpload(w, r, req, part, ip) s.storeUpload(w, r, req, part, ip)
return return
+1 -1
View File
@@ -1,6 +1,6 @@
{{define "content"}} {{define "content"}}
{{if .Stale}} {{if .Stale}}
<p class="notice">Your login is no longer valid, that token has been removed. You have been logged out.</p> <p class="notice">Your login is no longer valid. You have been logged out; log in again to carry on.</p>
{{end}} {{end}}
<form id="upload" class="card" method="post" action="{{.Base}}upload" <form id="upload" class="card" method="post" action="{{.Base}}upload"