diff --git a/README.md b/README.md index e5878ad..e99bca1 100644 --- a/README.md +++ b/README.md @@ -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 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 **Read `./uncensored-send --help` rather than this file.** diff --git a/internal/server/admin.go b/internal/server/admin.go index d316826..9530ce5 100644 --- a/internal/server/admin.go +++ b/internal/server/admin.go @@ -88,7 +88,7 @@ func compareExpiry(a, b *time.Time) int { } 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 { s.fail(w, r, http.StatusUnauthorized, "Unrecognised token.") return diff --git a/internal/server/cookie.go b/internal/server/cookie.go index f6c2faa..d23b7bf 100644 --- a/internal/server/cookie.go +++ b/internal/server/cookie.go @@ -21,22 +21,40 @@ const tokenCookie = "uncensored_send_token" const rememberFor = 365 * 24 * time.Hour // 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) if err != nil { 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 // from the remembered cookie. Upload additionally accepts a form field, which // takes precedence over both. -func credential(r *http.Request) string { +func (s *Server) credential(r *http.Request) string { if t := bearer(r); t != "" { return t } - return cookieCredential(r) + return s.cookieCredential(r) } // 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 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{ Name: tokenCookie, - Value: token, + Value: sealed, Path: s.cfg.BasePath, HttpOnly: true, 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()) } http.SetCookie(w, c) + return nil } // forget clears a remembered token. diff --git a/internal/server/delete.go b/internal/server/delete.go index 4bec009..52f9ed1 100644 --- a/internal/server/delete.go +++ b/internal/server/delete.go @@ -98,7 +98,7 @@ func (s *Server) deleteCredentials(w http.ResponseWriter, r *http.Request) []str } 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 // into memory. A non-form body simply fails to parse and is ignored. diff --git a/internal/server/login.go b/internal/server/login.go index cf91076..aa93655 100644 --- a/internal/server/login.go +++ b/internal/server/login.go @@ -40,7 +40,7 @@ func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) { next := destination(r.URL.Query().Get("next")) // 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{ page: s.page(r, "Log in", false), Next: next, @@ -82,7 +82,14 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { 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)) if wantsJSON(r) { diff --git a/internal/server/pages.go b/internal/server/pages.go index cdcebc9..b2503c5 100644 --- a/internal/server/pages.go +++ b/internal/server/pages.go @@ -61,13 +61,19 @@ type indexPage struct { func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { // A remembered token is resolved server-side, so the page can show the real // 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 - if err != nil { + switch { + case err != nil: // The token was revoked or the file was edited; end the session rather // than leave the caller wondering why uploads fail. s.forget(w, r) 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{ @@ -122,7 +128,7 @@ func (s *Server) renderInfo(w http.ResponseWriter, r *http.Request, m *store.Met Size: config.FormatSize(m.Size), Expires: describeExpiry(m.Expires, s.now()), URL: s.objectURL(r, m.ID), - CanDelete: s.mayDelete(r, m, credential(r)), + CanDelete: s.mayDelete(r, m, s.credential(r)), Error: errMsg, }) } diff --git a/internal/server/server.go b/internal/server/server.go index 550cee5..8aa228c 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -37,6 +37,10 @@ type Server struct { // none. Resolved once: the assets cannot change while the process runs. 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 } @@ -45,6 +49,10 @@ func New(cfg *config.Config, st *store.Store, tokens *auth.File, log *slog.Logge if err != nil { return nil, err } + sessions, err := newSealer(sessionKeyPath(cfg.DataDir)) + if err != nil { + return nil, err + } s := &Server{ cfg: cfg, 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), now: time.Now, favicon: faviconFor(web.Static()), + sessions: sessions, } s.handler = s.routes() return s, nil @@ -245,7 +254,7 @@ func (s *Server) page(r *http.Request, title string, script bool) page { if 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 } return p diff --git a/internal/server/server_test.go b/internal/server/server_test.go index b700f35..fb6b3b8 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -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) diff --git a/internal/server/session.go b/internal/server/session.go new file mode 100644 index 0000000..55cb6f6 --- /dev/null +++ b/internal/server/session.go @@ -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) +} diff --git a/internal/server/upload.go b/internal/server/upload.go index 9b0d00f..b9956fc 100644 --- a/internal/server/upload.go +++ b/internal/server/upload.go @@ -76,7 +76,7 @@ func (s *Server) uploadRaw(w http.ResponseWriter, r *http.Request, ip string) { filename: filenameFromDisposition(r.Header.Get("Content-Disposition")), } if req.token == "" { - req.token = cookieCredential(r) + req.token = s.cookieCredential(r) } 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 // token still wins. if req.token == "" { - req.token = cookieCredential(r) + req.token = s.cookieCredential(r) } s.storeUpload(w, r, req, part, ip) return diff --git a/web/templates/index.html b/web/templates/index.html index a7161ee..b83ebd0 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -1,6 +1,6 @@ {{define "content"}} {{if .Stale}} -

Your login is no longer valid, that token has been removed. You have been logged out.

+

Your login is no longer valid. You have been logged out; log in again to carry on.

{{end}}