diff --git a/README.md b/README.md index 0c5109f..adb6702 100644 --- a/README.md +++ b/README.md @@ -81,22 +81,27 @@ as `Authorization: Bearer `, or paste it into the form's token field. and on `SIGHUP`. It must stay mode `0600` — the server refuses to start otherwise, since it holds credential material. -### Remembering a token +### Logging in -Tick **Remember this token on this device** and the server sets a cookie, so the -token only has to be pasted once. The upload page then says who you are and -shows your real limits; **Forget** clears it, as does unticking the box on your -next upload. It works with JavaScript disabled, because the browser sends the -cookie either way. +Open **Log in**, paste a token, and the browser keeps it until you log out. The +header then shows who you are, the upload page shows your real limits, and +**Administration** appears if the token is an admin one. Tick *stay logged in* +and it survives a browser restart; leave it unticked and it dies with the +browser, which is the right choice on a machine that is not yours. -The cookie is `HttpOnly`, which means the page's own script cannot read it — the -server resolves the identity and renders it instead. That is deliberately -unlike `localStorage`, where any script injected into the origin could read the -token straight out and walk away with it. It is also `SameSite=Strict`, so no -other site can make your browser upload or delete anything with it attached. +The upload form also keeps a collapsed **Use a different token for this upload** +field. That one applies to a single upload and never changes the session, so a +quick upload under another token does not mean logging in and out. -Callers sending `Authorization: Bearer` are never given a cookie; an API client -keeps its own credentials. +The session cookie is `HttpOnly`, so the page's own script cannot read it — the +server resolves the session and renders it. That is deliberately unlike +`localStorage`, where any script injected into the origin could read the token +straight out. It is also `SameSite=Strict`, so no other site can make your +browser upload or delete anything with it attached. + +Nothing about the session involves JavaScript: every page states who you are +because the server rendered it that way. Callers sending `Authorization: Bearer` +are never given a cookie; an API client keeps its own credentials. ## Uploading @@ -143,8 +148,10 @@ file is accepted. | `GET /d/{id}` | the file, as an attachment; supports resuming | | `GET /i/{id}` | a page showing name, size, expiry, digest — and where a delete token is used | | `POST /api/d/{id}/delete` | delete, with `token=` in the form or `Authorization: Bearer` | -| `POST /api/forget` | clear a remembered token | +| `GET /login`, `POST /login` | start a browser session with a token | +| `POST /logout` | end it | | `GET /admin` | administration page; admin tokens only | +| `GET /api/limits` | what the presented credential may do; for scripts, the pages do not use it | Deleting accepts the object's delete token, the token that uploaded it, or any admin token. @@ -225,12 +232,20 @@ Worth knowing if you are going to run this somewhere real. protection — so the upload handler maintains a per-read deadline instead. - **`X-Forwarded-For` is ignored** unless the peer is a configured `--trusted-proxy`, and then only to skip further trusted hops. -- **A remembered token lives in an `HttpOnly`, `SameSite=Strict` cookie**, not - in `localStorage`, so neither an injected script nor another website can get - at it. `Secure` is set whenever the service knows it is being served over +- **The session lives in an `HttpOnly`, `SameSite=Strict` cookie**, not in + `localStorage`, so neither an injected script nor another website can get at + it. `Secure` is set whenever the service knows it is being served over HTTPS — from `--public-url`, from a TLS connection, or from a trusted proxy's - `X-Forwarded-Proto`. On browsers old enough to ignore `SameSite` entirely - (pre-2017) the cookie would be CSRF-exposed; nothing here defends that case. + `X-Forwarded-Proto`. +- **Every `POST` must be same-origin.** `SameSite` covers requests that need a + cookie, but logging in needs none: without this check a hostile page could + sign a visitor into an account it controls and collect what they upload next. + Browsers label their own requests with `Sec-Fetch-Site`, falling back to + `Origin`; a request carrying neither is not a browser and is allowed through, + since a bearer token cannot be attached by a third party anyway. +- **Failed credential attempts are throttled per address** — a wrong delete + token or a wrong login — while correct ones are never delayed, because only + failures consume the budget. - Rate limiting is per client address, with a separate bound on uploads in flight. Both are in memory and reset on restart. diff --git a/internal/server/cookie.go b/internal/server/cookie.go index 3987fb9..ed93fc0 100644 --- a/internal/server/cookie.go +++ b/internal/server/cookie.go @@ -16,7 +16,7 @@ import ( // resolves it and renders who the caller is. const tokenCookie = "send_token" -// rememberFor is how long a remembered token survives. Tokens are revoked by +// rememberFor is how long a persisted login survives. Tokens are revoked by // deleting them from the token file, so a long window costs nothing. const rememberFor = 365 * 24 * time.Hour @@ -39,22 +39,28 @@ func credential(r *http.Request) string { return cookieCredential(r) } -// remember stores the token in a cookie. +// logIn stores the token in a cookie. // // SameSite=Strict is what makes accepting a cookie as a credential safe here: // without it, any site could make the browser post an upload or a deletion with // the cookie attached. Scoping the path to the mount point keeps the credential // out of requests to the rest of the host when running under a subdirectory. -func (s *Server) remember(w http.ResponseWriter, r *http.Request, token string) { - http.SetCookie(w, &http.Cookie{ +// +// 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) { + c := &http.Cookie{ Name: tokenCookie, Value: token, Path: s.cfg.BasePath, - MaxAge: int(rememberFor.Seconds()), HttpOnly: true, Secure: s.isHTTPS(r), SameSite: http.SameSiteStrictMode, - }) + } + if persist { + c.MaxAge = int(rememberFor.Seconds()) + } + http.SetCookie(w, c) } // forget clears a remembered token. @@ -88,13 +94,3 @@ func (s *Server) isHTTPS(r *http.Request) bool { } return false } - -// handleForget drops the remembered token and returns to the upload page. -func (s *Server) handleForget(w http.ResponseWriter, r *http.Request) { - s.forget(w, r) - if wantsJSON(r) { - writeJSON(w, http.StatusOK, map[string]string{"status": "forgotten"}) - return - } - http.Redirect(w, r, s.cfg.BasePath, http.StatusSeeOther) -} diff --git a/internal/server/delete.go b/internal/server/delete.go index 4f334f6..972cd18 100644 --- a/internal/server/delete.go +++ b/internal/server/delete.go @@ -38,7 +38,7 @@ func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request) { // Only failures are throttled, so a correct token is never delayed. // The info page is publicly shareable and now carries a credential // field, which is reason enough not to let it be hammered freely. - if !s.deleteLimiter.allow(clientIP(r, s.cfg), s.now()) { + if !s.authLimiter.allow(clientIP(r, s.cfg), s.now()) { s.refuse(w, r, m, from, http.StatusTooManyRequests, "Too many failed attempts; try again shortly.") return diff --git a/internal/server/login.go b/internal/server/login.go new file mode 100644 index 0000000..3f0da7a --- /dev/null +++ b/internal/server/login.go @@ -0,0 +1,156 @@ +package server + +import ( + "net/http" + "net/url" + "strings" + + "send/internal/config" +) + +// loginPage backs both the form and its error redisplay. +type loginPage struct { + page + Error string + Next string + + // Limits describe what the presented credential would be allowed to do, + // shown once logged in so the upload page does not have to guess. + MaxSize string + MaxExpiry string + Vanity bool +} + +// loginDestinations is the allowlist for the post-login redirect. Restricting +// it to known page names means the parameter can never name somewhere else. +var loginDestinations = map[string]string{ + "": "", + "admin": "admin", +} + +func destination(next string) string { + page, ok := loginDestinations[next] + if !ok { + return "" + } + return page +} + +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(cookieCredential(r)); err == nil && !lim.Anonymous() { + s.render(w, http.StatusOK, "login.html", loginPage{ + page: s.page(r, "Log in", false), + Next: next, + MaxSize: config.FormatSize(lim.MaxSize), + MaxExpiry: config.FormatDuration(lim.MaxExpiry), + Vanity: lim.AllowVanity, + }) + return + } + s.render(w, http.StatusOK, "login.html", loginPage{ + page: s.page(r, "Log in", false), + Next: next, + }) +} + +func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxFieldBytes) + if err := r.ParseForm(); err != nil { + s.fail(w, r, http.StatusBadRequest, "Malformed form submission.") + return + } + token := strings.TrimSpace(r.PostFormValue("token")) + next := destination(r.PostFormValue("next")) + + if token == "" { + s.loginFailed(w, r, next, http.StatusBadRequest, "Enter a token.") + return + } + // Only failures are throttled, so logging in normally is never delayed. + lim, err := s.limitsFor(token) + if err != nil { + if !s.authLimiter.allow(clientIP(r, s.cfg), s.now()) { + s.loginFailed(w, r, next, http.StatusTooManyRequests, + "Too many failed attempts; try again shortly.") + return + } + s.log.Info("failed login", "ip", clientIP(r, s.cfg)) + s.loginFailed(w, r, next, http.StatusUnauthorized, "That token is not recognised.") + return + } + + s.logIn(w, r, token, r.PostFormValue("persist") != "") + s.log.Info("logged in", "name", lim.Name, "ip", clientIP(r, s.cfg)) + + if wantsJSON(r) { + writeJSON(w, http.StatusOK, map[string]string{"status": "logged in", "name": lim.Name}) + return + } + http.Redirect(w, r, s.cfg.BasePath+next, http.StatusSeeOther) +} + +func (s *Server) loginFailed(w http.ResponseWriter, r *http.Request, next string, status int, msg string) { + if wantsJSON(r) { + s.fail(w, r, status, msg) + return + } + s.render(w, status, "login.html", loginPage{ + page: s.page(r, "Log in", false), + Error: msg, + Next: next, + }) +} + +func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { + s.forget(w, r) + if wantsJSON(r) { + writeJSON(w, http.StatusOK, map[string]string{"status": "logged out"}) + return + } + http.Redirect(w, r, s.cfg.BasePath, http.StatusSeeOther) +} + +// sameOrigin guards the state-changing routes against cross-site form posts. +// +// The cookie is SameSite=Strict, which already stops another site from acting +// as a logged-in user. This covers the case that does not need a cookie at all: +// a hostile page posting to /login to sign a visitor into an account the +// attacker controls, so that the visitor's uploads land under it. +// +// Browsers label their own requests; API clients send neither header, and their +// bearer tokens are not attachable by a third party anyway. So an absent label +// is allowed and a present one must say same-origin. +func sameOrigin(r *http.Request) bool { + switch r.Header.Get("Sec-Fetch-Site") { + case "same-origin", "none": + return true + case "": // older browser, or not a browser at all; fall through to Origin + default: + return false + } + + origin := r.Header.Get("Origin") + if origin == "" || origin == "null" { + return origin == "" + } + u, err := url.Parse(origin) + if err != nil { + return false + } + return u.Host == r.Host +} + +func (s *Server) requireSameOrigin(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && !sameOrigin(r) { + s.log.Info("rejected cross-origin post", "path", r.URL.Path, + "origin", r.Header.Get("Origin"), "ip", clientIP(r, s.cfg)) + s.fail(w, r, http.StatusForbidden, "Cross-site form submissions are not accepted.") + return + } + next.ServeHTTP(w, r) + }) +} diff --git a/internal/server/pages.go b/internal/server/pages.go index 6663ab6..e4363e7 100644 --- a/internal/server/pages.go +++ b/internal/server/pages.go @@ -49,19 +49,18 @@ type indexPage struct { MaxExpiry string DefaultExpiry string AbsBase string - TokenName string // the remembered token's name, if there is one AllowVanity bool - Stale bool // a remembered token that no longer exists + MaxSizeBytes int64 // 0 when unlimited; the script checks against it + Stale bool // a login whose token no longer exists } 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. - remembered := cookieCredential(r) - lim, err := s.limitsFor(remembered) + lim, err := s.limitsFor(cookieCredential(r)) stale := false if err != nil { - // The token was revoked or the file was edited; drop the cookie rather + // 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 @@ -73,8 +72,8 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { MaxExpiry: config.FormatDuration(lim.MaxExpiry), DefaultExpiry: config.FormatDuration(lim.DefaultExpiry), AbsBase: s.absBase(r), - TokenName: lim.Name, AllowVanity: lim.AllowVanity, + MaxSizeBytes: lim.MaxSize, Stale: stale, }) } diff --git a/internal/server/server.go b/internal/server/server.go index 0e9df3c..62c4a3e 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -24,11 +24,13 @@ type Server struct { tokens *auth.File log *slog.Logger - pages map[string]*template.Template - handler http.Handler - limiter *limiter - deleteLimiter *limiter // consumed only by failed deletions - slots chan struct{} // bounds uploads in flight + pages map[string]*template.Template + handler http.Handler + limiter *limiter + // authLimiter is consumed only by failed credential attempts - a wrong + // delete token or a wrong login - so correct ones are never delayed. + authLimiter *limiter + slots chan struct{} // bounds uploads in flight now func() time.Time // swappable in tests } @@ -39,15 +41,15 @@ func New(cfg *config.Config, st *store.Store, tokens *auth.File, log *slog.Logge return nil, err } s := &Server{ - cfg: cfg, - store: st, - tokens: tokens, - log: log, - pages: pages, - limiter: newLimiter(cfg.UploadRate, cfg.UploadBurst), - deleteLimiter: newLimiter(120, 20), - slots: make(chan struct{}, cfg.MaxConcurrent), - now: time.Now, + cfg: cfg, + store: st, + tokens: tokens, + log: log, + pages: pages, + limiter: newLimiter(cfg.UploadRate, cfg.UploadBurst), + authLimiter: newLimiter(120, 20), + slots: make(chan struct{}, cfg.MaxConcurrent), + now: time.Now, } s.handler = s.routes() return s, nil @@ -65,11 +67,14 @@ func (s *Server) routes() http.Handler { mux.HandleFunc("GET /d/{id}", s.handleDownload) mux.HandleFunc("GET /i/{id}", s.handleInfo) mux.HandleFunc("POST /api/d/{id}/delete", s.handleDelete) - mux.HandleFunc("POST /api/forget", s.handleForget) + mux.HandleFunc("GET /login", s.handleLoginPage) + mux.HandleFunc("POST /login", s.handleLogin) + mux.HandleFunc("POST /logout", s.handleLogout) mux.Handle("GET /static/", http.StripPrefix("/static/", s.staticHandler())) mux.HandleFunc("/", s.handleNotFound) var h http.Handler = mux + h = s.requireSameOrigin(h) h = s.securityHeaders(h) if s.cfg.BasePath == "/" { @@ -153,7 +158,8 @@ func bearer(r *http.Request) string { // --- rendering ----------------------------------------------------------- -var pageNames = []string{"index.html", "result.html", "info.html", "error.html", "admin.html"} +var pageNames = []string{"index.html", "result.html", "info.html", "error.html", + "admin.html", "login.html"} // parsePages pairs each page with the shared layout. They cannot all be parsed // into one template set because every page defines "content". @@ -175,17 +181,22 @@ type page struct { Base string Title string Script bool - Admin bool // show the administration link in the header + + // User is the logged-in token's name, empty when nobody is logged in. The + // header renders the whole session state from these two fields, so every + // page agrees about who you are without any script involved. + User string + Admin bool } -// page builds the common fields, resolving whether the caller is an admin so -// the header can offer the link only to someone who can use it. +// page builds the common fields, resolving the session so the header can show +// who is logged in and offer only the links they can use. func (s *Server) page(r *http.Request, title string, script bool) page { - admin := false - if lim, err := s.limitsFor(credential(r)); err == nil { - admin = lim.Admin + p := page{Base: s.cfg.BasePath, Title: title, Script: script} + if lim, err := s.limitsFor(cookieCredential(r)); err == nil { + p.User, p.Admin = lim.Name, lim.Admin } - return page{Base: s.cfg.BasePath, Title: title, Script: script, Admin: admin} + return p } func (s *Server) render(w http.ResponseWriter, status int, name string, data any) { diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 30131a5..63f78e7 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -651,57 +651,165 @@ func assertNoDebris(t *testing.T, dir string) { // --- remembered tokens --------------------------------------------------- -// A browser form post that carries a token and the remember box gets a cookie -// back, and that cookie then authenticates later uploads on its own. -func TestTokenIsRememberedInACookie(t *testing.T) { +// Logging in is what stores a token; uploading never touches the cookie. +func TestLoginStoresTheToken(t *testing.T) { h := newHarness(t, nil) - resp := h.formUpload(t, map[string]string{"token": h.token, "remember": "1"}, "a.bin", "one") - if resp.StatusCode != http.StatusCreated { - t.Fatalf("status = %s", resp.Status) - } + resp := h.postForm(t, "/login", url.Values{"token": {h.token}, "persist": {"1"}}, nil) resp.Body.Close() + if resp.StatusCode != http.StatusSeeOther { + t.Fatalf("status = %s, want 303", resp.Status) + } + if loc := resp.Header.Get("Location"); loc != "/" { + t.Errorf("Location = %q, want /", loc) + } cookie := findCookie(resp, tokenCookie) if cookie == nil { - t.Fatal("no token cookie was set") + t.Fatal("logging in set no cookie") } if cookie.Value != h.token { t.Error("the cookie does not hold the token") } if !cookie.HttpOnly { - t.Error("the token cookie is readable by scripts") + t.Error("the session cookie is readable by scripts") } if cookie.SameSite != http.SameSiteStrictMode { - t.Error("the token cookie is not SameSite=Strict, so it is CSRF-exposed") + t.Error("the session cookie is not SameSite=Strict, so it is CSRF-exposed") + } + if cookie.MaxAge <= 0 { + t.Error("'stay logged in' did not persist the cookie") } - // The cookie alone is now enough to claim a vanity name, which anonymous - // callers cannot do. + // The session alone is now enough to claim a custom name. req, _ := http.NewRequest("POST", h.ts.URL+"/api/upload", strings.NewReader("two")) req.Header.Set("Accept", "application/json") - req.Header.Set("Vanity", "remembered") + req.Header.Set("Vanity", "session-upload") req.AddCookie(cookie) - resp, err := h.ts.Client().Do(req) + up, err := h.ts.Client().Do(req) if err != nil { t.Fatal(err) } - if resp.StatusCode != http.StatusCreated { - t.Fatalf("upload with only the cookie: status = %s", resp.Status) + if up.StatusCode != http.StatusCreated { + t.Fatalf("upload with only the session: status = %s", up.Status) } - if res := decode[uploadResult](t, resp); res.ID != "remembered" { - t.Errorf("id = %q, want remembered", res.ID) + if res := decode[uploadResult](t, up); res.ID != "session-upload" { + t.Errorf("id = %q, want session-upload", res.ID) } } -// A typed token wins over whatever the browser remembered. -func TestExplicitTokenBeatsTheCookie(t *testing.T) { +// Without "stay logged in" the cookie must die with the browser. +func TestLoginWithoutPersistIsASessionCookie(t *testing.T) { h := newHarness(t, nil) - resp := h.formUpload(t, map[string]string{"token": h.token, "remember": "1"}, "a.bin", "x") - cookie := findCookie(resp, tokenCookie) + resp := h.postForm(t, "/login", url.Values{"token": {h.token}}, nil) + resp.Body.Close() + c := findCookie(resp, tokenCookie) + if c == nil { + t.Fatal("no cookie was set") + } + if c.MaxAge != 0 || !c.Expires.IsZero() { + t.Errorf("cookie carries a lifetime (MaxAge=%d), want a session cookie", c.MaxAge) + } +} + +func TestLoginRejectsAnUnknownToken(t *testing.T) { + h := newHarness(t, nil) + resp := h.postForm(t, "/login", url.Values{"token": {"not-a-token"}}, nil) + raw, _ := io.ReadAll(resp.Body) resp.Body.Close() - resp = h.formUploadWith(t, cookie, map[string]string{"token": h.admin, "remember": "1"}, "b.bin", "y") + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("status = %s, want 401", resp.Status) + } + if findCookie(resp, tokenCookie) != nil { + t.Error("a rejected login still set a cookie") + } + if !strings.Contains(string(raw), "not recognised") { + t.Error("the login page does not say what went wrong") + } +} + +// Guessing a token at the login form is throttled; a correct one is not. +func TestFailedLoginsAreThrottled(t *testing.T) { + h := newHarness(t, nil) + h.authLimiter = newLimiter(1, 3) + + var last *http.Response + for range 5 { + if last != nil { + last.Body.Close() + } + last = h.postForm(t, "/login", url.Values{"token": {"guess"}}, nil) + } + if last.StatusCode != http.StatusTooManyRequests { + t.Fatalf("repeated guesses => %s, want 429", last.Status) + } + last.Body.Close() + + resp := h.postForm(t, "/login", url.Values{"token": {h.token}}, nil) + resp.Body.Close() + if resp.StatusCode != http.StatusSeeOther { + t.Fatalf("a correct token was throttled: %s", resp.Status) + } +} + +// The post-login destination is an allowlisted page name, never a URL, so it +// cannot be turned into an open redirect. +func TestLoginRedirectIsAllowlisted(t *testing.T) { + h := newHarness(t, nil) + for _, c := range []struct{ next, want string }{ + {"admin", "/admin"}, + {"", "/"}, + {"https://evil.example.com", "/"}, + {"//evil.example.com", "/"}, + {"../../etc", "/"}, + } { + resp := h.postForm(t, "/login", url.Values{"token": {h.admin}, "next": {c.next}}, nil) + resp.Body.Close() + if loc := resp.Header.Get("Location"); loc != c.want { + t.Errorf("next=%q => Location %q, want %q", c.next, loc, c.want) + } + } +} + +func TestLogoutEndsTheSession(t *testing.T) { + h := newHarness(t, nil) + resp := h.postForm(t, "/logout", url.Values{}, &http.Cookie{Name: tokenCookie, Value: h.token}) + resp.Body.Close() + + if resp.StatusCode != http.StatusSeeOther { + t.Fatalf("status = %s, want 303", resp.Status) + } + c := findCookie(resp, tokenCookie) + if c == nil || c.MaxAge >= 0 || c.Value != "" { + t.Fatalf("the session cookie was not cleared: %v", c) + } +} + +// Uploading must never change the session, in either direction. +func TestUploadNeverTouchesTheSession(t *testing.T) { + h := newHarness(t, nil) + + resp := h.formUpload(t, map[string]string{"token": h.token}, "a.bin", "one") + resp.Body.Close() + if c := findCookie(resp, tokenCookie); c != nil { + 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}, + map[string]string{}, "b.bin", "two") + resp.Body.Close() + if c := findCookie(resp, tokenCookie); c != nil { + t.Errorf("an upload cleared the session: %v", c) + } +} + +// 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} + + resp := h.formUploadWith(t, cookie, map[string]string{"token": h.admin}, "b.bin", "y") defer resp.Body.Close() res := decode[uploadResult](t, resp) @@ -710,42 +818,11 @@ func TestExplicitTokenBeatsTheCookie(t *testing.T) { t.Fatal(err) } if m.Owner != "boss" { - t.Errorf("owner = %q, want boss: the cookie shadowed the typed token", m.Owner) + t.Errorf("owner = %q, want boss: the session shadowed the one-off token", m.Owner) } } -// Leaving the box unchecked clears a token the browser had remembered. -func TestUncheckingRememberForgetsTheCookie(t *testing.T) { - h := newHarness(t, nil) - resp := h.formUpload(t, map[string]string{"token": h.token, "remember": "1"}, "a.bin", "x") - cookie := findCookie(resp, tokenCookie) - resp.Body.Close() - - resp = h.formUploadWith(t, cookie, map[string]string{}, "b.bin", "y") - defer resp.Body.Close() - cleared := findCookie(resp, tokenCookie) - if cleared == nil || cleared.MaxAge >= 0 { - t.Fatalf("the cookie was not cleared: %v", cleared) - } -} - -func TestForgetEndpointClearsTheCookie(t *testing.T) { - h := newHarness(t, nil) - req, _ := http.NewRequest("POST", h.ts.URL+"/api/forget", nil) - req.Header.Set("Accept", "application/json") - req.AddCookie(&http.Cookie{Name: tokenCookie, Value: h.token}) - resp, err := h.ts.Client().Do(req) - if err != nil { - t.Fatal(err) - } - defer resp.Body.Close() - c := findCookie(resp, tokenCookie) - if c == nil || c.MaxAge >= 0 || c.Value != "" { - t.Fatalf("the cookie was not cleared: %v", c) - } -} - -// A revoked token left in a cookie must not wedge the page. +// A session whose token has since been revoked must not wedge the page. func TestStaleCookieIsDropped(t *testing.T) { h := newHarness(t, nil) req, _ := http.NewRequest("GET", h.ts.URL+"/", nil) @@ -762,14 +839,17 @@ func TestStaleCookieIsDropped(t *testing.T) { t.Error("a stale cookie was not dropped") } page, _ := io.ReadAll(resp.Body) - if strings.Contains(string(page), "Uploading as") { + if !strings.Contains(string(page), "no longer valid") { + t.Error("the page does not explain that the session ended") + } + if !strings.Contains(string(page), "anonymous") { t.Error("the page claims an identity it could not resolve") } } -// The index page resolves a remembered token server-side, so the limits shown -// are the caller's real ones even though the cookie is unreadable by script. -func TestIndexShowsTheRememberedIdentity(t *testing.T) { +// The session is resolved server-side, so every page agrees about who you are +// even though the cookie is unreadable by script. +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}) @@ -779,8 +859,8 @@ func TestIndexShowsTheRememberedIdentity(t *testing.T) { } defer resp.Body.Close() page, _ := io.ReadAll(resp.Body) - if !strings.Contains(string(page), "Uploading as friend") { - t.Error("the page does not show the remembered identity") + if !strings.Contains(string(page), ">friend<") { + t.Error("the page does not show who is logged in") } if strings.Contains(string(page), h.token) { t.Error("the page echoes the token back into the HTML") @@ -790,7 +870,7 @@ func TestIndexShowsTheRememberedIdentity(t *testing.T) { // The per-object delete token must still work when a cookie is also present. func TestCookieDoesNotShadowTheDeleteToken(t *testing.T) { h := newHarness(t, nil) - // Uploaded anonymously, so the remembered token owns nothing here. + // Uploaded anonymously, so the logged-in token owns nothing here. res := decode[uploadResult](t, h.upload(t, []byte("x"), nil)) form := strings.NewReader("token=" + res.DeleteToken) @@ -808,17 +888,6 @@ func TestCookieDoesNotShadowTheDeleteToken(t *testing.T) { } } -// An API caller sending a bearer token manages its own credentials and should -// not be handed a cookie it never asked for. -func TestBearerCallersAreNotGivenACookie(t *testing.T) { - h := newHarness(t, nil) - resp := h.upload(t, []byte("x"), map[string]string{"Authorization": "Bearer " + h.token}) - defer resp.Body.Close() - if c := findCookie(resp, tokenCookie); c != nil { - t.Errorf("a cookie was set for a bearer-token upload: %v", c) - } -} - func findCookie(resp *http.Response, name string) *http.Cookie { for _, c := range resp.Cookies() { if c.Name == name { @@ -1064,7 +1133,7 @@ func TestAdminPageAccessControl(t *testing.T) { } // The cookie is the credential a browser actually uses for this page. -func TestAdminPageAcceptsTheRememberedCookie(t *testing.T) { +func TestAdminPageAcceptsTheSession(t *testing.T) { h := newHarness(t, nil) req, _ := http.NewRequest("GET", h.ts.URL+"/admin", nil) req.AddCookie(&http.Cookie{Name: tokenCookie, Value: h.admin}) @@ -1276,6 +1345,9 @@ func TestFormFieldsOverrideTheHeaders(t *testing.T) { // --- deleting from the info page ----------------------------------------- +// postForm submits a form the way a browser would, without following the +// redirect: where these posts send you, and what they set on the way, is +// usually the thing under test. func (h *harness) postForm(t *testing.T, path string, form url.Values, cookie *http.Cookie) *http.Response { t.Helper() req, _ := http.NewRequest("POST", h.ts.URL+path, strings.NewReader(form.Encode())) @@ -1284,7 +1356,11 @@ func (h *harness) postForm(t *testing.T, path string, form url.Values, cookie *h if cookie != nil { req.AddCookie(cookie) } - resp, err := h.ts.Client().Do(req) + client := *h.ts.Client() + client.CheckRedirect = func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + } + resp, err := client.Do(req) if err != nil { t.Fatal(err) } @@ -1398,7 +1474,7 @@ func TestInfoPageOffersADirectButtonToAnOwner(t *testing.T) { // delayed by someone else's failed attempts. func TestFailedDeletesAreThrottledAndSuccessIsNot(t *testing.T) { h := newHarness(t, nil) - h.deleteLimiter = newLimiter(1, 3) + h.authLimiter = newLimiter(1, 3) res := decode[uploadResult](t, h.upload(t, []byte("x"), nil)) @@ -1423,3 +1499,128 @@ func TestFailedDeletesAreThrottledAndSuccessIsNot(t *testing.T) { t.Fatalf("the correct token was throttled: %s", resp.Status) } } + +// --- cross-site posts ---------------------------------------------------- + +// A login form needs no cookie to submit, so SameSite does not cover it: a +// hostile page could otherwise sign a visitor into an account it controls and +// collect whatever they upload next. Browsers label their own requests, and +// those labels are checked on every state-changing route. +func TestCrossOriginPostsAreRejected(t *testing.T) { + h := newHarness(t, nil) + + paths := []string{"/login", "/logout", "/api/upload", "/api/d/anything/delete"} + hostile := []map[string]string{ + {"Origin": "https://evil.example.com"}, + {"Sec-Fetch-Site": "cross-site"}, + {"Sec-Fetch-Site": "same-site"}, + } + for _, path := range paths { + for _, headers := range hostile { + req, _ := http.NewRequest("POST", h.ts.URL+path, strings.NewReader("token=x")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + for k, v := range headers { + req.Header.Set(k, v) + } + resp, err := h.ts.Client().Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusForbidden { + t.Errorf("POST %s with %v => %s, want 403", path, headers, resp.Status) + } + } + } +} + +// The page's own posts, and API clients that label nothing, must still work. +func TestSameOriginAndUnlabelledPostsAreAccepted(t *testing.T) { + h := newHarness(t, nil) + for _, headers := range []map[string]string{ + {}, // curl and friends + {"Sec-Fetch-Site": "same-origin"}, // the page itself + {"Sec-Fetch-Site": "none"}, // typed into the bar + {"Origin": "http://" + strings.TrimPrefix(h.ts.URL, "http://")}, // older browser + } { + req, _ := http.NewRequest("POST", h.ts.URL+"/login", + strings.NewReader(url.Values{"token": {h.token}}.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + for k, v := range headers { + req.Header.Set(k, v) + } + resp, err := h.ts.Client().Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Errorf("POST /login with %v => %s, want 200", headers, resp.Status) + } + } +} + +// The header is the only navigation there is, so it has to tell the truth +// about the session on every page. +func TestHeaderReflectsTheSession(t *testing.T) { + h := newHarness(t, nil) + + for _, c := range []struct { + who string + token string + present []string + absent []string + }{ + {"anonymous", "", []string{`href="/login"`}, []string{`action="/logout"`, `href="/admin"`}}, + {"a plain token", h.token, []string{`action="/logout"`, ">friend<"}, []string{`href="/login"`, `href="/admin"`}}, + {"an admin token", h.admin, []string{`action="/logout"`, `href="/admin"`, ">boss<"}, []string{`href="/login"`}}, + } { + 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}) + } + resp, err := h.ts.Client().Do(req) + if err != nil { + t.Fatal(err) + } + raw, _ := io.ReadAll(resp.Body) + resp.Body.Close() + page := string(raw) + + for _, want := range c.present { + if !strings.Contains(page, want) { + t.Errorf("GET %s as %s: missing %q", path, c.who, want) + } + } + for _, unwanted := range c.absent { + if strings.Contains(page, unwanted) { + t.Errorf("GET %s as %s: unexpectedly offers %q", path, c.who, unwanted) + } + } + } + } +} + +// The upload form keeps a one-off token field, so a quick upload under another +// token does not require logging in and out. +func TestUploadPageKeepsTheOneOffTokenField(t *testing.T) { + h := newHarness(t, nil) + resp := h.get(t, "/", "") + raw, _ := io.ReadAll(resp.Body) + resp.Body.Close() + page := string(raw) + + if !strings.Contains(page, `name="token"`) { + t.Error("the upload form has no one-off token field") + } + if !strings.Contains(page, "does not log you in") { + t.Error("the form does not explain that the field is one-off") + } + // The limits are rendered, not fetched, so no script is needed to show them. + if !strings.Contains(page, `data-max-size="1048576"`) { + t.Error("the form does not carry the server-rendered size limit") + } +} diff --git a/internal/server/upload.go b/internal/server/upload.go index 49c6b0c..9b5c2cc 100644 --- a/internal/server/upload.go +++ b/internal/server/upload.go @@ -38,12 +38,6 @@ type uploadRequest struct { vanity string expiry string filename string - - // remember is set by the form's checkbox. It decides whether a token used - // here is stored in a cookie for next time, and unchecking it is how a - // remembered token is cleared from the upload page itself. - remember bool - explicit bool // the token was typed or sent, not read back from the cookie } func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) { @@ -81,7 +75,6 @@ func (s *Server) uploadRaw(w http.ResponseWriter, r *http.Request, ip string) { expiry: strings.TrimSpace(r.Header.Get("Expiry")), filename: filenameFromDisposition(r.Header.Get("Content-Disposition")), } - req.explicit = req.token != "" if req.token == "" { req.token = cookieCredential(r) } @@ -111,7 +104,6 @@ func (s *Server) uploadMultipart(w http.ResponseWriter, r *http.Request, boundar vanity: strings.TrimSpace(r.Header.Get("Vanity")), expiry: strings.TrimSpace(r.Header.Get("Expiry")), } - req.explicit = req.token != "" for n := 0; ; n++ { if n > maxFieldCount { @@ -151,10 +143,8 @@ func (s *Server) uploadMultipart(w http.ResponseWriter, r *http.Request, boundar switch part.FormName() { case "token": if v := strings.TrimSpace(value); v != "" { - req.token, req.explicit = v, true + req.token = v } - case "remember": - req.remember = true case "vanity": if v := strings.TrimSpace(value); v != "" { req.vanity = v @@ -282,28 +272,11 @@ func (s *Server) storeUpload(w http.ResponseWriter, r *http.Request, req uploadR } committed = true - s.updateRemembered(w, r, req, lim) - s.log.Info("stored", "id", m.ID, "bytes", m.Size, "owner", orAnonymous(lim.Name), "ip", ip, "expires", m.Expires) s.respondUploaded(w, r, m, secret) } -// updateRemembered applies the form's "remember" checkbox to the cookie. It -// only ever acts on a browser form post: an API caller sending a bearer token -// has its own way of keeping credentials and should not be handed a cookie. -func (s *Server) updateRemembered(w http.ResponseWriter, r *http.Request, req uploadRequest, lim auth.Limits) { - if bearer(r) != "" { - return - } - switch { - case req.remember && req.explicit && lim.Name != "": - s.remember(w, r, req.token) - case !req.remember && cookieCredential(r) != "": - s.forget(w, r) - } -} - func orAnonymous(name string) string { if name == "" { return "(anonymous)" diff --git a/internal/store/id.go b/internal/store/id.go index 2cacbb9..4315bce 100644 --- a/internal/store/id.go +++ b/internal/store/id.go @@ -17,6 +17,7 @@ var vanityRe = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{1,63}$`) // allowed into the object namespace. var reserved = map[string]bool{ "d": true, "i": true, "api": true, "static": true, "admin": true, + "login": true, "logout": true, "favicon.ico": true, "robots.txt": true, "index.html": true, "sitemap.xml": true, "tokens.json": true, "objects": true, } diff --git a/web/static/app.js b/web/static/app.js index 4721da3..7e93e4f 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -40,7 +40,6 @@ if (!form) return; var fileInput = document.getElementById('file'); - var tokenInput = document.getElementById('token'); var drop = document.getElementById('drop'); var dropHint = document.getElementById('drop-hint'); var progress = document.getElementById('progress'); @@ -49,8 +48,9 @@ var submit = document.getElementById('submit'); var errorBox = document.getElementById('error'); - // Limits as rendered for an anonymous caller; refreshed when a token is typed. - var limits = { max_size: null, allow_vanity: false }; + // The session's limits are rendered by the server, so this script never has + // to ask who the visitor is. Zero means unlimited. + var maxSize = parseInt(form.dataset.maxSize, 10) || 0; function formatSize(n) { if (n === null || n === undefined) return 'unlimited'; @@ -64,54 +64,6 @@ errorBox.hidden = !msg; } - // --- credentials -------------------------------------------------------- - // A token is remembered in an HttpOnly cookie the server sets, not here: - // this script cannot read it back, so an injected script cannot steal it - // either. The page is told who it is by the server when it renders, and the - // limits panel is refreshed from /api/limits, which reads the same cookie. - function refreshLimits() { - var token = tokenInput ? tokenInput.value.trim() : ''; - - var xhr = new XMLHttpRequest(); - xhr.open('GET', base + 'api/limits'); - xhr.setRequestHeader('Accept', 'application/json'); - // Sent only when the field holds something; otherwise the cookie answers. - if (token) xhr.setRequestHeader('Authorization', 'Bearer ' + token); - xhr.onload = function () { - if (xhr.status !== 200) { - if (xhr.status === 401 && token) showError('That token is not recognised.'); - return; - } - showError(''); - var l; - try { l = JSON.parse(xhr.responseText); } catch (e) { return; } - limits = l; - set('limit-size', l.max_size === null ? 'unlimited' : formatSize(l.max_size)); - set('limit-expiry', l.max_expiry || 'never'); - set('limit-default', l.default_expiry || 'never'); - set('limit-vanity', l.allow_vanity ? 'allowed' : 'requires a token'); - var vanity = document.getElementById('vanity'); - if (vanity) vanity.disabled = !l.allow_vanity; - var expiry = document.getElementById('expiry'); - if (expiry) expiry.placeholder = l.default_expiry || 'never'; - }; - xhr.send(); - } - - function set(id, text) { - var el = document.getElementById(id); - if (el) el.textContent = text; - } - - if (tokenInput) { - var debounce; - tokenInput.addEventListener('input', function () { - clearTimeout(debounce); - debounce = setTimeout(refreshLimits, 400); - }); - } - refreshLimits(); - // --- drag and drop ------------------------------------------------------ function describeSelection() { var f = fileInput.files[0]; @@ -144,10 +96,10 @@ var file = fileInput.files[0]; if (!file) return; // let the browser's own validation speak - if (limits.max_size && file.size > limits.max_size) { + if (maxSize && file.size > maxSize) { e.preventDefault(); - showError('That file is ' + formatSize(file.size) + '; the limit is ' + - formatSize(limits.max_size) + '.'); + showError('That file is ' + formatSize(file.size) + '; your limit is ' + + formatSize(maxSize) + '.'); return; } diff --git a/web/static/style.css b/web/static/style.css index bdb9e77..a458fb9 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -190,7 +190,17 @@ button.link { /* Administration --------------------------------------------------------- */ -header nav { float: right; font-size: .875rem; } +header { display: flex; align-items: baseline; gap: 1rem; flex-wrap: wrap; } +header nav { + margin-left: auto; + display: flex; + align-items: baseline; + gap: .875rem; + font-size: .875rem; +} +header nav form { display: inline; } +header nav .who { color: var(--muted); } +header nav .who::before { content: "\1F511\00a0"; } dl.stats { grid-template-columns: auto 1fr; } dl.stats em { font-style: normal; color: var(--muted); } @@ -235,3 +245,15 @@ p.error { .card .actions { margin: 1.25rem 0; } .card > .field:last-child { margin-bottom: 0; } + +/* One-off token, tucked away so the common path stays a single button. */ +.onceoff { margin-bottom: 1rem; } +.onceoff summary { font-weight: 400; font-size: .875rem; color: var(--muted); } +.onceoff .field { max-width: 24rem; } + +.limits .hint { margin-top: .75rem; } +.limits dl, .card dl { row-gap: .375rem; } + +/* Actions that mix a link-button with a form-button. */ +.actions { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; } +.actions form { margin: 0; } diff --git a/web/templates/index.html b/web/templates/index.html index d63a2ad..14189f3 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -1,45 +1,39 @@ {{define "content"}} {{if .Stale}} -

The token remembered on this device no longer exists. It has been forgotten.

+

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

{{end}} -{{if .TokenName}} -
- Uploading as {{.TokenName}}. -
-
-{{end}} +
- - - - - +
+ +

Choose a file, or drop one here.

+
-
-
- -

Choose a file, or drop one here.

-
+
+ Use a different token for this upload +

+ Applies to this upload only and does not log you in. + {{if not .User}}To keep a token for this browser, log in instead.{{end}} +

+ +