Add explicit login functionality

This commit is contained in:
2026-09-13 00:50:15 +02:00
parent 74bfdbfd8a
commit 1b77cdd165
14 changed files with 629 additions and 258 deletions
+34 -19
View File
@@ -81,22 +81,27 @@ as `Authorization: Bearer <token>`, 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.
+12 -16
View File
@@ -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)
}
+1 -1
View File
@@ -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
+156
View File
@@ -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)
})
}
+5 -6
View File
@@ -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,
})
}
+22 -11
View File
@@ -27,7 +27,9 @@ type Server struct {
pages map[string]*template.Template
handler http.Handler
limiter *limiter
deleteLimiter *limiter // consumed only by failed deletions
// 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
@@ -45,7 +47,7 @@ func New(cfg *config.Config, st *store.Store, tokens *auth.File, log *slog.Logge
log: log,
pages: pages,
limiter: newLimiter(cfg.UploadRate, cfg.UploadBurst),
deleteLimiter: newLimiter(120, 20),
authLimiter: newLimiter(120, 20),
slots: make(chan struct{}, cfg.MaxConcurrent),
now: time.Now,
}
@@ -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) {
+278 -77
View File
@@ -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), "<em>anonymous</em>") {
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 <strong>friend</strong>") {
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")
}
}
+1 -28
View File
@@ -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)"
+1
View File
@@ -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,
}
+6 -54
View File
@@ -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;
}
+23 -1
View File
@@ -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; }
+28 -32
View File
@@ -1,45 +1,39 @@
{{define "content"}}
{{if .Stale}}
<p class="notice">The token remembered on this device no longer exists. It has been forgotten.</p>
<p class="notice">Your login is no longer valid — that token has been removed. You have been logged out.</p>
{{end}}
{{if .TokenName}}
<div class="notice" id="identity">
Uploading as <strong>{{.TokenName}}</strong>.
<form method="post" action="{{.Base}}api/forget"><button type="submit" class="link">Forget</button></form>
<form id="upload" class="card" method="post" action="{{.Base}}api/upload"
enctype="multipart/form-data" data-max-size="{{.MaxSizeBytes}}">
<div class="drop" id="drop">
<input type="file" name="file" id="file" required>
<p class="hint" id="drop-hint">Choose a file, or drop one here.</p>
</div>
{{end}}
<form id="upload" class="card" method="post" action="{{.Base}}api/upload" enctype="multipart/form-data">
<!-- Field order is load-bearing: the server streams this body and must know
the credentials and options before the file part arrives. -->
<label class="field">
<span>Token <em>optional</em></span>
<input type="password" name="token" id="token" autocomplete="off"
placeholder="{{if .TokenName}}remembered — type to replace{{else}}anonymous{{end}}">
</label>
<label class="check">
<input type="checkbox" name="remember" id="remember" value="1" checked>
<span>Remember this token on this device</span>
</label>
<div class="row">
<label class="field">
<span>Expires in</span>
<input type="text" name="expiry" id="expiry" placeholder="{{.DefaultExpiry}}" autocomplete="off">
</label>
<label class="field" id="vanity-field">
<span>Vanity name <em>token only</em></span>
<label class="field">
<span>Custom name <em>{{if .AllowVanity}}optional{{else}}needs a token{{end}}</em></span>
<input type="text" name="vanity" id="vanity" placeholder="auto" autocomplete="off"
pattern="[A-Za-z0-9][A-Za-z0-9._-]{1,63}"{{if not .AllowVanity}} disabled{{end}}>
</label>
</div>
<div class="drop" id="drop">
<input type="file" name="file" id="file" required>
<p class="hint" id="drop-hint">Choose a file, or drop one here.</p>
</div>
<details class="onceoff">
<summary>Use a different token for this upload</summary>
<p class="hint">
Applies to this upload only and does not log you in.
{{if not .User}}To keep a token for this browser, <a href="{{.Base}}login">log in</a> instead.{{end}}
</p>
<label class="field">
<span>Token</span>
<input type="password" name="token" autocomplete="off">
</label>
</details>
<div class="progress" id="progress" hidden>
<div class="bar"><div class="fill" id="bar-fill"></div></div>
@@ -51,13 +45,15 @@
</form>
<section class="limits">
<h2>Current limits</h2>
<dl id="limits">
<dt>Maximum size</dt><dd id="limit-size">{{.MaxSize}}</dd>
<dt>Longest lifetime</dt><dd id="limit-expiry">{{.MaxExpiry}}</dd>
<dt>Default lifetime</dt><dd id="limit-default">{{.DefaultExpiry}}</dd>
<dt>Vanity names</dt><dd id="limit-vanity">{{if .AllowVanity}}allowed{{else}}requires a token{{end}}</dd>
<h2>Your limits</h2>
<dl>
<dt>Uploading as</dt><dd>{{if .User}}{{.User}}{{else}}<em>anonymous</em>{{end}}</dd>
<dt>Maximum size</dt><dd>{{.MaxSize}}</dd>
<dt>Longest lifetime</dt><dd>{{.MaxExpiry}}</dd>
<dt>Default lifetime</dt><dd>{{.DefaultExpiry}}</dd>
<dt>Custom names</dt><dd>{{if .AllowVanity}}allowed{{else}}need a token{{end}}</dd>
</dl>
{{if not .User}}<p class="hint"><a href="{{.Base}}login">Log in</a> with a token to raise these.</p>{{end}}
</section>
<section class="cli">
+9 -1
View File
@@ -9,7 +9,15 @@
<body data-base="{{.Base}}">
<header>
<a class="brand" href="{{.Base}}">Uncensored&nbsp;Send</a>
{{if .Admin}}<nav><a href="{{.Base}}admin">Administration</a></nav>{{end}}
<nav>
{{if .User}}
{{if .Admin}}<a href="{{.Base}}admin">Administration</a>{{end}}
<span class="who">{{.User}}</span>
<form method="post" action="{{.Base}}logout"><button type="submit" class="link">Log out</button></form>
{{else}}
<a href="{{.Base}}login">Log in</a>
{{end}}
</nav>
</header>
<main>
{{template "content" .}}
+41
View File
@@ -0,0 +1,41 @@
{{define "content"}}
{{if .User}}
<section class="card">
<h1>Logged in</h1>
<p>You are logged in as <strong>{{.User}}</strong>.</p>
<dl>
<dt>Maximum size</dt><dd>{{.MaxSize}}</dd>
<dt>Longest lifetime</dt><dd>{{.MaxExpiry}}</dd>
<dt>Custom names</dt><dd>{{if .Vanity}}allowed{{else}}not allowed{{end}}</dd>
</dl>
<p class="actions">
<a class="button" href="{{.Base}}">Upload a file</a>
<form method="post" action="{{.Base}}logout"><button type="submit" class="link">Log out</button></form>
</p>
</section>
{{else}}
<section class="card">
<h1>Log in</h1>
<p class="hint">
A token raises your size and lifetime limits and lets you choose custom
names. Logging in keeps it for this browser, so you do not have to paste it
for every upload. Without one you can still upload anonymously.
</p>
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<form method="post" action="{{.Base}}login">
<input type="hidden" name="next" value="{{.Next}}">
<label class="field">
<span>Token</span>
<input type="password" name="token" autocomplete="current-password" required autofocus>
</label>
<label class="check">
<input type="checkbox" name="persist" value="1" checked>
<span>Stay logged in on this device</span>
</label>
<button type="submit">Log in</button>
</form>
</section>
{{end}}
{{end}}