Encrypt cookies
This commit is contained in:
@@ -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.**
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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,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"
|
||||||
|
|||||||
Reference in New Issue
Block a user