Add --port

This commit is contained in:
2026-09-12 23:48:03 +02:00
parent a180fe4b52
commit da36645aaf
14 changed files with 677 additions and 40 deletions
+29
View File
@@ -36,9 +36,14 @@ Options take **one hyphen with a single letter** and **two with a full word**:
Every option can also be set from the environment as `SEND_MAX_SIZE` and so on. Every option can also be set from the environment as `SEND_MAX_SIZE` and so on.
`./send --help` lists them all. `./send --help` lists them all.
`--port` is a convenience over `--listen`: it replaces only the port, so
`./send -p 9000` listens on `127.0.0.1:9000` and `--listen 0.0.0.0 -p 9000`
listens on all interfaces.
| Option | Default | Meaning | | Option | Default | Meaning |
|---|---|---| |---|---|---|
| `-l`, `--listen` | `127.0.0.1:8080` | address to listen on | | `-l`, `--listen` | `127.0.0.1:8080` | address to listen on |
| `-p`, `--port` | — | port to listen on, replacing the one in `--listen` |
| `-d`, `--data` | `./data` | data directory | | `-d`, `--data` | `./data` | data directory |
| `-b`, `--base-url` | `/` | path prefix when mounted under a subdirectory | | `-b`, `--base-url` | `/` | path prefix when mounted under a subdirectory |
| `-u`, `--public-url` | — | absolute base URL used in generated links | | `-u`, `--public-url` | — | absolute base URL used in generated links |
@@ -76,6 +81,23 @@ 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 and on `SIGHUP`. It must stay mode `0600` — the server refuses to start
otherwise, since it holds credential material. otherwise, since it holds credential material.
### Remembering a token
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.
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.
Callers sending `Authorization: Bearer` are never given a cookie; an API client
keeps its own credentials.
## Uploading ## Uploading
From the browser, just use the page. It works with JavaScript disabled; with it From the browser, just use the page. It works with JavaScript disabled; with it
@@ -119,6 +141,7 @@ file is accepted.
| `GET /d/{id}` | the file, as an attachment; supports resuming | | `GET /d/{id}` | the file, as an attachment; supports resuming |
| `GET /i/{id}` | a page showing name, size, expiry and digest | | `GET /i/{id}` | a page showing name, size, expiry and digest |
| `POST /api/d/{id}/delete` | delete, with `token=` in the form or `Authorization: Bearer` | | `POST /api/d/{id}/delete` | delete, with `token=` in the form or `Authorization: Bearer` |
| `POST /api/forget` | clear a remembered token |
Deleting accepts the object's delete token, the token that uploaded it, or any Deleting accepts the object's delete token, the token that uploaded it, or any
admin token. admin token.
@@ -172,6 +195,12 @@ Worth knowing if you are going to run this somewhere real.
protection — so the upload handler maintains a per-read deadline instead. protection — so the upload handler maintains a per-read deadline instead.
- **`X-Forwarded-For` is ignored** unless the peer is a configured - **`X-Forwarded-For` is ignored** unless the peer is a configured
`--trusted-proxy`, and then only to skip further trusted hops. `--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
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.
- Rate limiting is per client address, with a separate bound on uploads in - Rate limiting is per client address, with a separate bound on uploads in
flight. Both are in memory and reset on restart. flight. Both are in memory and reset on restart.
+21
View File
@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"net" "net"
"net/url" "net/url"
"strconv"
"strings" "strings"
"time" "time"
) )
@@ -13,6 +14,7 @@ import (
// Config is the fully-resolved server configuration. // Config is the fully-resolved server configuration.
type Config struct { type Config struct {
Listen string Listen string
Port int // overrides the port in Listen when set
DataDir string DataDir string
BasePath string // normalised: always "/" or "/prefix/" BasePath string // normalised: always "/" or "/prefix/"
PublicURL string // absolute origin+path for generated links; "" => relative PublicURL string // absolute origin+path for generated links; "" => relative
@@ -40,6 +42,8 @@ const EnvPrefix = "SEND_"
func (c *Config) Register(s *Set) { func (c *Config) Register(s *Set) {
s.String(&c.Listen, "listen", "l", "127.0.0.1:8080", "ADDR", s.String(&c.Listen, "listen", "l", "127.0.0.1:8080", "ADDR",
"address to listen on; keep it on loopback behind a reverse proxy") "address to listen on; keep it on loopback behind a reverse proxy")
s.Int(&c.Port, "port", "p", 0,
"port to listen on, replacing the one in --listen")
s.String(&c.DataDir, "data", "d", "./data", "DIR", s.String(&c.DataDir, "data", "d", "./data", "DIR",
"directory holding uploaded objects and their metadata") "directory holding uploaded objects and their metadata")
s.String(&c.BasePath, "base-url", "b", "/", "PATH", s.String(&c.BasePath, "base-url", "b", "/", "PATH",
@@ -78,6 +82,23 @@ func (c *Config) Register(s *Set) {
func (c *Config) Normalise() error { func (c *Config) Normalise() error {
c.BasePath = NormalisePath(c.BasePath) c.BasePath = NormalisePath(c.BasePath)
// --port is a convenience over --listen: it replaces only the port, so the
// host stays wherever --listen (or its default) put it.
if c.Port != 0 {
if c.Port < 1 || c.Port > 65535 {
return fmt.Errorf("--port: %d is not a port number", c.Port)
}
host, _, err := net.SplitHostPort(c.Listen)
if err != nil {
// --listen held a bare host, which is fine once a port is supplied.
host = strings.TrimSpace(c.Listen)
}
c.Listen = net.JoinHostPort(host, strconv.Itoa(c.Port))
}
if _, _, err := net.SplitHostPort(c.Listen); err != nil {
return fmt.Errorf("--listen: %q is not an address:port (use --port to set just the port)", c.Listen)
}
if c.PublicURL != "" { if c.PublicURL != "" {
u, err := url.Parse(c.PublicURL) u, err := url.Parse(c.PublicURL)
if err != nil { if err != nil {
+40
View File
@@ -165,3 +165,43 @@ func TestDefaultExpiryMustFitWithinMax(t *testing.T) {
t.Error("an unlimited default under a finite maximum was accepted") t.Error("an unlimited default under a finite maximum was accepted")
} }
} }
func TestPortOverridesListen(t *testing.T) {
cases := []struct {
listen string
port int
want string
}{
{"127.0.0.1:8080", 0, "127.0.0.1:8080"},
{"127.0.0.1:8080", 9000, "127.0.0.1:9000"},
{"0.0.0.0:8080", 3000, "0.0.0.0:3000"},
{"localhost", 3000, "localhost:3000"}, // a bare host is fine with --port
{"[::1]:8080", 3000, "[::1]:3000"},
}
for _, c := range cases {
cfg := &Config{
Listen: c.listen, Port: c.port, BasePath: "/", DataDir: "d",
SweepInterval: time.Minute, MaxConcurrent: 1,
}
if err := cfg.Normalise(); err != nil {
t.Errorf("listen %q port %d: %v", c.listen, c.port, err)
continue
}
if cfg.Listen != c.want {
t.Errorf("listen %q port %d => %q, want %q", c.listen, c.port, cfg.Listen, c.want)
}
}
for _, c := range []struct {
listen string
port int
}{{"127.0.0.1:8080", 70000}, {"127.0.0.1:8080", -1}, {"localhost", 0}} {
cfg := &Config{
Listen: c.listen, Port: c.port, BasePath: "/", DataDir: "d",
SweepInterval: time.Minute, MaxConcurrent: 1,
}
if err := cfg.Normalise(); err == nil {
t.Errorf("listen %q port %d was accepted", c.listen, c.port)
}
}
}
+3 -1
View File
@@ -222,7 +222,9 @@ func (s *Set) PrintUsage(w io.Writer, header string) {
} }
fmt.Fprintf(w, "%s\n", name) fmt.Fprintf(w, "%s\n", name)
fmt.Fprintf(w, " %s", sp.usage) fmt.Fprintf(w, " %s", sp.usage)
if sp.def != "" && sp.def != "false" { // A zero default means "unset" for the options that have one, and
// printing it would read as a real value.
if sp.def != "" && sp.def != "false" && sp.def != "0" {
fmt.Fprintf(w, " (default %s)", sp.def) fmt.Fprintf(w, " (default %s)", sp.def)
} }
fmt.Fprintf(w, "\n [%s]\n", s.envName(sp.long)) fmt.Fprintf(w, "\n [%s]\n", s.envName(sp.long))
+100
View File
@@ -0,0 +1,100 @@
package server
import (
"net"
"net/http"
"strings"
"time"
)
// tokenCookie remembers a caller's token so it does not have to be pasted for
// every upload.
//
// It is HttpOnly, so a script on this origin cannot read it back — which is the
// reason to prefer it over localStorage, where any injected script could
// exfiltrate the credential. The page never needs to see the value: the server
// resolves it and renders who the caller is.
const tokenCookie = "send_token"
// rememberFor is how long a remembered token survives. Tokens are revoked by
// deleting them from the token file, so a long window costs nothing.
const rememberFor = 365 * 24 * time.Hour
// cookieCredential returns the remembered token, if any.
func cookieCredential(r *http.Request) string {
c, err := r.Cookie(tokenCookie)
if err != nil {
return ""
}
return strings.TrimSpace(c.Value)
}
// credential resolves the caller's token from an explicit header first, then
// from the remembered cookie. Upload additionally accepts a form field, which
// takes precedence over both.
func credential(r *http.Request) string {
if t := bearer(r); t != "" {
return t
}
return cookieCredential(r)
}
// remember 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{
Name: tokenCookie,
Value: token,
Path: s.cfg.BasePath,
MaxAge: int(rememberFor.Seconds()),
HttpOnly: true,
Secure: s.isHTTPS(r),
SameSite: http.SameSiteStrictMode,
})
}
// forget clears a remembered token.
func (s *Server) forget(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: tokenCookie,
Value: "",
Path: s.cfg.BasePath,
MaxAge: -1,
HttpOnly: true,
Secure: s.isHTTPS(r),
SameSite: http.SameSiteStrictMode,
})
}
// isHTTPS decides whether the cookie may carry the Secure attribute. Setting it
// on a plain-HTTP development server would stop the browser storing the cookie
// at all, so it is only set when the connection is genuinely secure.
func (s *Server) isHTTPS(r *http.Request) bool {
if strings.HasPrefix(s.cfg.PublicURL, "https://") {
return true // the operator said so, and they are behind the proxy
}
if r.TLS != nil {
return true
}
// Believed only from a proxy that is trusted for forwarded headers at all.
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
if ip := net.ParseIP(host); ip != nil && s.cfg.TrustsProxy(ip) {
return r.Header.Get("X-Forwarded-Proto") == "https"
}
}
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)
}
+42 -14
View File
@@ -26,24 +26,16 @@ func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request) {
return return
} }
secret := bearer(r) presented := s.deleteCredentials(w, r)
if secret == "" { if len(presented) == 0 {
// A small form post; the 4 KiB cap keeps this from being a way to
// stream a body into memory.
r.Body = http.MaxBytesReader(w, r.Body, maxFieldBytes)
if err := r.ParseForm(); err == nil {
secret = strings.TrimSpace(r.PostFormValue("token"))
}
}
if secret == "" {
s.fail(w, r, http.StatusUnauthorized, "A delete token or an owning token is required.") s.fail(w, r, http.StatusUnauthorized, "A delete token or an owning token is required.")
return return
} }
if !s.authorised(m, presented) {
if !s.mayDelete(m, secret) {
s.fail(w, r, http.StatusForbidden, "That token cannot delete this file.") s.fail(w, r, http.StatusForbidden, "That token cannot delete this file.")
return return
} }
if err := s.store.Delete(id); err != nil { if err := s.store.Delete(id); err != nil {
s.log.Error("deleting object", "id", id, "err", err) s.log.Error("deleting object", "id", id, "err", err)
s.fail(w, r, http.StatusInternalServerError, "Could not delete the file.") s.fail(w, r, http.StatusInternalServerError, "Could not delete the file.")
@@ -62,8 +54,44 @@ func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request) {
}) })
} }
// mayDelete checks the presented secret against the object's delete token // deleteCredentials collects every secret the request carries.
// first, then against the token file. //
// Three can legitimately arrive at once — the object's delete token in the
// form, a token in the header, and a remembered token in the cookie — and any
// one of them may be the sufficient one. They are all collected so that the
// first one present cannot shadow the others.
func (s *Server) deleteCredentials(w http.ResponseWriter, r *http.Request) []string {
var out []string
add := func(secret string) {
if secret = strings.TrimSpace(secret); secret != "" {
out = append(out, secret)
}
}
add(bearer(r))
add(cookieCredential(r))
// A small form post; the cap keeps this from being a way to stream a body
// into memory. A non-form body simply fails to parse and is ignored.
r.Body = http.MaxBytesReader(w, r.Body, maxFieldBytes)
if err := r.ParseForm(); err == nil {
add(r.PostFormValue("token"))
}
return out
}
// authorised reports whether any of the presented secrets may delete m.
func (s *Server) authorised(m *store.Meta, presented []string) bool {
for _, secret := range presented {
if s.mayDelete(m, secret) {
return true
}
}
return false
}
// mayDelete checks one secret against the object's delete token first, then
// against the token file.
func (s *Server) mayDelete(m *store.Meta, secret string) bool { func (s *Server) mayDelete(m *store.Meta, secret string) bool {
if auth.EqualHash(m.DeleteHash, auth.HashSecret(secret)) { if auth.EqualHash(m.DeleteHash, auth.HashSecret(secret)) {
return true return true
+21 -4
View File
@@ -49,23 +49,39 @@ type indexPage struct {
MaxExpiry string MaxExpiry string
DefaultExpiry string DefaultExpiry string
AbsBase 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
} }
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
// The page always renders the anonymous tier; the script refreshes it from // A remembered token is resolved server-side, so the page can show the real
// /api/limits once a token is entered. // limits without the cookie ever being readable by a script.
lim := auth.Anonymous(s.cfg) remembered := cookieCredential(r)
lim, err := s.limitsFor(remembered)
stale := false
if err != nil {
// The token was revoked or the file was edited; drop the cookie rather
// than leave the caller wondering why uploads fail.
s.forget(w, r)
lim, stale = auth.Anonymous(s.cfg), true
}
s.render(w, http.StatusOK, "index.html", indexPage{ s.render(w, http.StatusOK, "index.html", indexPage{
page: s.page("Upload", true), page: s.page("Upload", true),
MaxSize: config.FormatSize(lim.MaxSize), MaxSize: config.FormatSize(lim.MaxSize),
MaxExpiry: config.FormatDuration(lim.MaxExpiry), MaxExpiry: config.FormatDuration(lim.MaxExpiry),
DefaultExpiry: config.FormatDuration(lim.DefaultExpiry), DefaultExpiry: config.FormatDuration(lim.DefaultExpiry),
AbsBase: s.absBase(r), AbsBase: s.absBase(r),
TokenName: lim.Name,
AllowVanity: lim.AllowVanity,
Stale: stale,
}) })
} }
type limitsJSON struct { type limitsJSON struct {
Name string `json:"name"` Name string `json:"name"`
Remembered bool `json:"remembered"`
MaxSize *int64 `json:"max_size"` // null means unlimited MaxSize *int64 `json:"max_size"` // null means unlimited
MaxExpiry string `json:"max_expiry"` MaxExpiry string `json:"max_expiry"`
DefaultExpiry string `json:"default_expiry"` DefaultExpiry string `json:"default_expiry"`
@@ -81,13 +97,14 @@ func (s *Server) handleLimits(w http.ResponseWriter, r *http.Request) {
s.fail(w, r, http.StatusTooManyRequests, "Too many requests; try again shortly.") s.fail(w, r, http.StatusTooManyRequests, "Too many requests; try again shortly.")
return return
} }
lim, err := s.limitsFor(bearer(r)) lim, err := s.limitsFor(credential(r))
if err != nil { if err != nil {
s.fail(w, r, http.StatusUnauthorized, "Unrecognised token.") s.fail(w, r, http.StatusUnauthorized, "Unrecognised token.")
return return
} }
out := limitsJSON{ out := limitsJSON{
Name: lim.Name, Name: lim.Name,
Remembered: lim.Name != "" && bearer(r) == "",
MaxExpiry: config.FormatDuration(lim.MaxExpiry), MaxExpiry: config.FormatDuration(lim.MaxExpiry),
DefaultExpiry: config.FormatDuration(lim.DefaultExpiry), DefaultExpiry: config.FormatDuration(lim.DefaultExpiry),
AllowVanity: lim.AllowVanity, AllowVanity: lim.AllowVanity,
+8 -1
View File
@@ -62,6 +62,7 @@ func (s *Server) routes() http.Handler {
mux.HandleFunc("GET /d/{id}", s.handleDownload) mux.HandleFunc("GET /d/{id}", s.handleDownload)
mux.HandleFunc("GET /i/{id}", s.handleInfo) mux.HandleFunc("GET /i/{id}", s.handleInfo)
mux.HandleFunc("POST /api/d/{id}/delete", s.handleDelete) mux.HandleFunc("POST /api/d/{id}/delete", s.handleDelete)
mux.HandleFunc("POST /api/forget", s.handleForget)
mux.Handle("GET /static/", http.StripPrefix("/static/", s.staticHandler())) mux.Handle("GET /static/", http.StripPrefix("/static/", s.staticHandler()))
mux.HandleFunc("/", s.handleNotFound) mux.HandleFunc("/", s.handleNotFound)
@@ -94,8 +95,14 @@ func (s *Server) staticHandler() http.Handler {
// appCSP locks the application pages down to their own origin. The frontend has // appCSP locks the application pages down to their own origin. The frontend has
// no inline script and no third-party anything, so this can be strict. // no inline script and no third-party anything, so this can be strict.
//
// connect-src is not optional here: the upload page talks to /api/upload and
// /api/limits over XMLHttpRequest, and every fetch-directive left unlisted
// falls back to default-src, so omitting it makes the browser block every
// upload before it reaches the network. See TestAppCSPAllowsWhatThePageDoes.
const appCSP = "default-src 'none'; script-src 'self'; style-src 'self'; " + const appCSP = "default-src 'none'; script-src 'self'; style-src 'self'; " +
"img-src 'self' data:; form-action 'self'; base-uri 'none'; frame-ancestors 'none'" "img-src 'self' data:; connect-src 'self'; form-action 'self'; " +
"base-uri 'none'; frame-ancestors 'none'"
func (s *Server) securityHeaders(next http.Handler) http.Handler { func (s *Server) securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+310
View File
@@ -647,3 +647,313 @@ func assertNoDebris(t *testing.T, dir string) {
t.Errorf("leftover object directory: %s", e.Name()) t.Errorf("leftover object directory: %s", e.Name())
} }
} }
// --- 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) {
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.Body.Close()
cookie := findCookie(resp, tokenCookie)
if cookie == nil {
t.Fatal("no token cookie was set")
}
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")
}
if cookie.SameSite != http.SameSiteStrictMode {
t.Error("the token cookie is not SameSite=Strict, so it is CSRF-exposed")
}
// The cookie alone is now enough to claim a vanity name, which anonymous
// callers cannot do.
req, _ := http.NewRequest("POST", h.ts.URL+"/api/upload", strings.NewReader("two"))
req.Header.Set("Accept", "application/json")
req.Header.Set("Vanity", "remembered")
req.AddCookie(cookie)
resp, 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 res := decode[uploadResult](t, resp); res.ID != "remembered" {
t.Errorf("id = %q, want remembered", res.ID)
}
}
// A typed token wins over whatever the browser remembered.
func TestExplicitTokenBeatsTheCookie(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{"token": h.admin, "remember": "1"}, "b.bin", "y")
defer resp.Body.Close()
res := decode[uploadResult](t, resp)
m, err := h.store.Get(res.ID, h.now)
if err != nil {
t.Fatal(err)
}
if m.Owner != "boss" {
t.Errorf("owner = %q, want boss: the cookie shadowed the typed 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.
func TestStaleCookieIsDropped(t *testing.T) {
h := newHarness(t, nil)
req, _ := http.NewRequest("GET", h.ts.URL+"/", nil)
req.AddCookie(&http.Cookie{Name: tokenCookie, Value: "a-token-that-was-revoked"})
resp, err := h.ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %s, want the page to still render", resp.Status)
}
if c := findCookie(resp, tokenCookie); c == nil || c.MaxAge >= 0 {
t.Error("a stale cookie was not dropped")
}
page, _ := io.ReadAll(resp.Body)
if strings.Contains(string(page), "Uploading as") {
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) {
h := newHarness(t, nil)
req, _ := http.NewRequest("GET", h.ts.URL+"/", nil)
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()
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), h.token) {
t.Error("the page echoes the token back into the HTML")
}
}
// 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.
res := decode[uploadResult](t, h.upload(t, []byte("x"), nil))
form := strings.NewReader("token=" + res.DeleteToken)
req, _ := http.NewRequest("POST", h.ts.URL+"/api/d/"+res.ID+"/delete", form)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
req.AddCookie(&http.Cookie{Name: tokenCookie, Value: h.token})
resp, err := h.ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %s, want 200: the cookie shadowed the delete token", resp.Status)
}
}
// 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 {
return c
}
}
return nil
}
// formUpload posts the multipart form the browser would, with fields ordered
// ahead of the file part.
func (h *harness) formUpload(t *testing.T, fields map[string]string, filename, content string) *http.Response {
t.Helper()
return h.formUploadWith(t, nil, fields, filename, content)
}
func (h *harness) formUploadWith(t *testing.T, cookie *http.Cookie, fields map[string]string, filename, content string) *http.Response {
t.Helper()
var body bytes.Buffer
mw := multipart.NewWriter(&body)
for k, v := range fields {
mw.WriteField(k, v)
}
fw, err := mw.CreateFormFile("file", filename)
if err != nil {
t.Fatal(err)
}
fw.Write([]byte(content))
mw.Close()
req, _ := http.NewRequest("POST", h.ts.URL+"/api/upload", &body)
req.Header.Set("Content-Type", mw.FormDataContentType())
req.Header.Set("Accept", "application/json")
if cookie != nil {
req.AddCookie(cookie)
}
resp, err := h.ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
return resp
}
// --- content security policy ---------------------------------------------
// The page's own behaviour and its CSP have to agree, and nothing in a Go test
// or a curl invocation enforces CSP — only a browser does. This reads the
// script that is actually shipped, works out which fetch directives the page
// needs, and checks the policy grants them.
//
// It exists because omitting connect-src once made the browser block every
// upload while every server-side test still passed.
func TestAppCSPAllowsWhatThePageDoes(t *testing.T) {
h := newHarness(t, nil)
resp, err := h.ts.Client().Get(h.ts.URL + "/static/app.js")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
script, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
// Which directive each capability the script might use depends on. Every
// fetch directive falls back to default-src when unlisted, and default-src
// here is 'none', so anything the script does must be granted explicitly.
needs := []struct {
directive string
used bool
because string
}{
{"connect-src", bytes.Contains(script, []byte("XMLHttpRequest")) ||
bytes.Contains(script, []byte("fetch(")), "the page makes XHR or fetch calls"},
{"script-src", true, "the page loads an external script"},
{"style-src", true, "the page loads an external stylesheet"},
{"form-action", true, "the page posts a form"},
}
page, err := h.ts.Client().Get(h.ts.URL + "/")
if err != nil {
t.Fatal(err)
}
page.Body.Close()
csp := page.Header.Get("Content-Security-Policy")
if csp == "" {
t.Fatal("the upload page carries no Content-Security-Policy")
}
directives := map[string]string{}
for _, d := range strings.Split(csp, ";") {
name, value, _ := strings.Cut(strings.TrimSpace(d), " ")
directives[strings.ToLower(name)] = strings.TrimSpace(value)
}
if directives["default-src"] != "'none'" {
t.Errorf("default-src = %q, want 'none': the checks below assume it denies by default",
directives["default-src"])
}
for _, n := range needs {
if !n.used {
continue
}
value, ok := directives[n.directive]
if !ok {
t.Errorf("CSP has no %s, but %s; the browser will fall back to default-src and block it",
n.directive, n.because)
continue
}
if !strings.Contains(value, "'self'") {
t.Errorf("CSP %s = %q, which does not allow this origin, but %s",
n.directive, value, n.because)
}
}
}
// The download policy is the opposite case: it must stay maximally restrictive,
// since it governs bytes a stranger uploaded.
func TestDownloadCSPStaysInert(t *testing.T) {
h := newHarness(t, nil)
res := decode[uploadResult](t, h.upload(t, []byte("<script>alert(1)</script>"), nil))
get, err := h.ts.Client().Get(h.ts.URL + "/d/" + res.ID)
if err != nil {
t.Fatal(err)
}
get.Body.Close()
csp := get.Header.Get("Content-Security-Policy")
if !strings.Contains(csp, "default-src 'none'") || !strings.Contains(csp, "sandbox") {
t.Errorf("download CSP = %q, want default-src 'none' and sandbox", csp)
}
for _, forbidden := range []string{"connect-src", "script-src 'self'", "'unsafe-inline'"} {
if strings.Contains(csp, forbidden) {
t.Errorf("download CSP contains %q; uploaded bytes must be granted nothing", forbidden)
}
}
}
+38 -2
View File
@@ -38,6 +38,12 @@ type uploadRequest struct {
vanity string vanity string
expiry string expiry string
filename 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) { func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
@@ -75,6 +81,10 @@ func (s *Server) uploadRaw(w http.ResponseWriter, r *http.Request, ip string) {
expiry: strings.TrimSpace(r.Header.Get("Expiry")), expiry: strings.TrimSpace(r.Header.Get("Expiry")),
filename: filenameFromDisposition(r.Header.Get("Content-Disposition")), filename: filenameFromDisposition(r.Header.Get("Content-Disposition")),
} }
req.explicit = req.token != ""
if req.token == "" {
req.token = cookieCredential(r)
}
s.storeUpload(w, r, req, r.Body, ip) s.storeUpload(w, r, req, r.Body, ip)
} }
@@ -92,6 +102,7 @@ func (s *Server) uploadMultipart(w http.ResponseWriter, r *http.Request, boundar
} }
mr := multipart.NewReader(r.Body, boundary) mr := multipart.NewReader(r.Body, boundary)
req := uploadRequest{token: bearer(r)} req := uploadRequest{token: bearer(r)}
req.explicit = req.token != ""
for n := 0; ; n++ { for n := 0; ; n++ {
if n > maxFieldCount { if n > maxFieldCount {
@@ -112,6 +123,12 @@ func (s *Server) uploadMultipart(w http.ResponseWriter, r *http.Request, boundar
if req.filename == "" { if req.filename == "" {
req.filename = part.FileName() req.filename = part.FileName()
} }
// Nothing explicit was supplied, so fall back to what the browser
// remembered. This happens after the fields precisely so a typed
// token still wins.
if req.token == "" {
req.token = cookieCredential(r)
}
s.storeUpload(w, r, req, part, ip) s.storeUpload(w, r, req, part, ip)
return return
} }
@@ -124,9 +141,11 @@ func (s *Server) uploadMultipart(w http.ResponseWriter, r *http.Request, boundar
} }
switch part.FormName() { switch part.FormName() {
case "token": case "token":
if value != "" { if v := strings.TrimSpace(value); v != "" {
req.token = value req.token, req.explicit = v, true
} }
case "remember":
req.remember = true
case "vanity": case "vanity":
req.vanity = strings.TrimSpace(value) req.vanity = strings.TrimSpace(value)
case "expiry": case "expiry":
@@ -250,11 +269,28 @@ func (s *Server) storeUpload(w http.ResponseWriter, r *http.Request, req uploadR
} }
committed = true committed = true
s.updateRemembered(w, r, req, lim)
s.log.Info("stored", "id", m.ID, "bytes", m.Size, "owner", orAnonymous(lim.Name), s.log.Info("stored", "id", m.ID, "bytes", m.Size, "owner", orAnonymous(lim.Name),
"ip", ip, "expires", m.Expires) "ip", ip, "expires", m.Expires)
s.respondUploaded(w, r, m, secret) 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 { func orAnonymous(name string) string {
if name == "" { if name == "" {
return "(anonymous)" return "(anonymous)"
+10 -2
View File
@@ -8,6 +8,7 @@ import (
"flag" "flag"
"fmt" "fmt"
"log/slog" "log/slog"
"net"
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
@@ -99,6 +100,13 @@ func serve(args []string) error {
ErrorLog: slog.NewLogLogger(log.Handler(), slog.LevelWarn), ErrorLog: slog.NewLogLogger(log.Handler(), slog.LevelWarn),
} }
// Bind before announcing anything: logging "listening" and only then
// failing to bind reads as a server that started and then died.
ln, err := net.Listen("tcp", cfg.Listen)
if err != nil {
return err
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop() defer stop()
@@ -106,14 +114,14 @@ func serve(args []string) error {
go reloadOnHUP(ctx, tokens, log) go reloadOnHUP(ctx, tokens, log)
log.Info("listening", log.Info("listening",
"addr", cfg.Listen, "data", cfg.DataDir, "base", cfg.BasePath, "addr", ln.Addr().String(), "data", cfg.DataDir, "base", cfg.BasePath,
"objects", st.Count(), "stored", config.FormatBytes(st.Total()), "objects", st.Count(), "stored", config.FormatBytes(st.Total()),
"max_size", config.FormatSize(cfg.MaxSize), "max_size", config.FormatSize(cfg.MaxSize),
"max_expiry", config.FormatDuration(cfg.MaxExpiry)) "max_expiry", config.FormatDuration(cfg.MaxExpiry))
errc := make(chan error, 1) errc := make(chan error, 1)
go func() { go func() {
err := httpSrv.ListenAndServe() err := httpSrv.Serve(ln)
if errors.Is(err, http.ErrServerClosed) { if errors.Is(err, http.ErrServerClosed) {
err = nil err = nil
} }
+11 -13
View File
@@ -32,27 +32,25 @@
errorBox.hidden = !msg; errorBox.hidden = !msg;
} }
// --- token memory ------------------------------------------------------- // --- credentials --------------------------------------------------------
// Kept in localStorage only so the field survives a reload; it is never sent // A token is remembered in an HttpOnly cookie the server sets, not here:
// anywhere but this origin's upload endpoint. // this script cannot read it back, so an injected script cannot steal it
try { // either. The page is told who it is by the server when it renders, and the
var saved = localStorage.getItem('send.token'); // limits panel is refreshed from /api/limits, which reads the same cookie.
if (saved && tokenInput) tokenInput.value = saved;
} catch (e) { /* private mode; not important */ }
function refreshLimits() { function refreshLimits() {
var token = tokenInput ? tokenInput.value.trim() : ''; var token = tokenInput ? tokenInput.value.trim() : '';
try {
if (token) localStorage.setItem('send.token', token);
else localStorage.removeItem('send.token');
} catch (e) { /* ignore */ }
var xhr = new XMLHttpRequest(); var xhr = new XMLHttpRequest();
xhr.open('GET', base + 'api/limits'); xhr.open('GET', base + 'api/limits');
xhr.setRequestHeader('Accept', 'application/json'); xhr.setRequestHeader('Accept', 'application/json');
// Sent only when the field holds something; otherwise the cookie answers.
if (token) xhr.setRequestHeader('Authorization', 'Bearer ' + token); if (token) xhr.setRequestHeader('Authorization', 'Bearer ' + token);
xhr.onload = function () { xhr.onload = function () {
if (xhr.status !== 200) return; if (xhr.status !== 200) {
if (xhr.status === 401 && token) showError('That token is not recognised.');
return;
}
showError('');
var l; var l;
try { l = JSON.parse(xhr.responseText); } catch (e) { return; } try { l = JSON.parse(xhr.responseText); } catch (e) { return; }
limits = l; limits = l;
+24
View File
@@ -142,3 +142,27 @@ pre {
.warn p { margin: .25rem 0 .75rem; font-size: .875rem; } .warn p { margin: .25rem 0 .75rem; font-size: .875rem; }
.result .field { margin-top: 1rem; } .result .field { margin-top: 1rem; }
.check { display: flex; align-items: center; gap: .5rem; margin-bottom: 1rem; font-size: .875rem; }
.check input { margin: 0; }
.notice {
margin: 0 0 1rem;
padding: .625rem .875rem;
background: var(--card);
border: 1px solid var(--line);
border-left: 3px solid var(--accent);
border-radius: 6px;
font-size: .875rem;
}
.notice .forget-form, .notice form { display: inline; }
button.link {
padding: 0;
background: none;
border: 0;
color: var(--accent);
font: inherit;
text-decoration: underline;
cursor: pointer;
}
+20 -3
View File
@@ -1,10 +1,27 @@
{{define "content"}} {{define "content"}}
{{if .Stale}}
<p class="notice">The token remembered on this device no longer exists. It has been forgotten.</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>
</div>
{{end}}
<form id="upload" class="card" method="post" action="{{.Base}}api/upload" enctype="multipart/form-data"> <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 <!-- Field order is load-bearing: the server streams this body and must know
the credentials and options before the file part arrives. --> the credentials and options before the file part arrives. -->
<label class="field"> <label class="field">
<span>Token <em>optional</em></span> <span>Token <em>optional</em></span>
<input type="password" name="token" id="token" autocomplete="off" placeholder="anonymous"> <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> </label>
<div class="row"> <div class="row">
@@ -15,7 +32,7 @@
<label class="field" id="vanity-field"> <label class="field" id="vanity-field">
<span>Vanity name <em>token only</em></span> <span>Vanity name <em>token only</em></span>
<input type="text" name="vanity" id="vanity" placeholder="auto" autocomplete="off" <input type="text" name="vanity" id="vanity" placeholder="auto" autocomplete="off"
pattern="[A-Za-z0-9][A-Za-z0-9._-]{1,63}"> pattern="[A-Za-z0-9][A-Za-z0-9._-]{1,63}"{{if not .AllowVanity}} disabled{{end}}>
</label> </label>
</div> </div>
@@ -39,7 +56,7 @@
<dt>Maximum size</dt><dd id="limit-size">{{.MaxSize}}</dd> <dt>Maximum size</dt><dd id="limit-size">{{.MaxSize}}</dd>
<dt>Longest lifetime</dt><dd id="limit-expiry">{{.MaxExpiry}}</dd> <dt>Longest lifetime</dt><dd id="limit-expiry">{{.MaxExpiry}}</dd>
<dt>Default lifetime</dt><dd id="limit-default">{{.DefaultExpiry}}</dd> <dt>Default lifetime</dt><dd id="limit-default">{{.DefaultExpiry}}</dd>
<dt>Vanity names</dt><dd id="limit-vanity">requires a token</dd> <dt>Vanity names</dt><dd id="limit-vanity">{{if .AllowVanity}}allowed{{else}}requires a token{{end}}</dd>
</dl> </dl>
</section> </section>