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
+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,
})
}
+34 -23
View File
@@ -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) {
+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)"