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.
`./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 |
|---|---|---|
| `-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 |
| `-b`, `--base-url` | `/` | path prefix when mounted under a subdirectory |
| `-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
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
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 /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/forget` | clear a remembered token |
Deleting accepts the object's delete token, the token that uploaded it, or any
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.
- **`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
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
flight. Both are in memory and reset on restart.
+21
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"net"
"net/url"
"strconv"
"strings"
"time"
)
@@ -13,6 +14,7 @@ import (
// Config is the fully-resolved server configuration.
type Config struct {
Listen string
Port int // overrides the port in Listen when set
DataDir string
BasePath string // normalised: always "/" or "/prefix/"
PublicURL string // absolute origin+path for generated links; "" => relative
@@ -40,6 +42,8 @@ const EnvPrefix = "SEND_"
func (c *Config) Register(s *Set) {
s.String(&c.Listen, "listen", "l", "127.0.0.1:8080", "ADDR",
"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",
"directory holding uploaded objects and their metadata")
s.String(&c.BasePath, "base-url", "b", "/", "PATH",
@@ -78,6 +82,23 @@ func (c *Config) Register(s *Set) {
func (c *Config) Normalise() error {
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 != "" {
u, err := url.Parse(c.PublicURL)
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")
}
}
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", 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, "\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
}
secret := bearer(r)
if secret == "" {
// 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 == "" {
presented := s.deleteCredentials(w, r)
if len(presented) == 0 {
s.fail(w, r, http.StatusUnauthorized, "A delete token or an owning token is required.")
return
}
if !s.mayDelete(m, secret) {
if !s.authorised(m, presented) {
s.fail(w, r, http.StatusForbidden, "That token cannot delete this file.")
return
}
if err := s.store.Delete(id); err != nil {
s.log.Error("deleting object", "id", id, "err", err)
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
// first, then against the token file.
// deleteCredentials collects every secret the request carries.
//
// 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 {
if auth.EqualHash(m.DeleteHash, auth.HashSecret(secret)) {
return true
+21 -4
View File
@@ -49,23 +49,39 @@ 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
}
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
// The page always renders the anonymous tier; the script refreshes it from
// /api/limits once a token is entered.
lim := auth.Anonymous(s.cfg)
// 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)
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{
page: s.page("Upload", true),
MaxSize: config.FormatSize(lim.MaxSize),
MaxExpiry: config.FormatDuration(lim.MaxExpiry),
DefaultExpiry: config.FormatDuration(lim.DefaultExpiry),
AbsBase: s.absBase(r),
TokenName: lim.Name,
AllowVanity: lim.AllowVanity,
Stale: stale,
})
}
type limitsJSON struct {
Name string `json:"name"`
Remembered bool `json:"remembered"`
MaxSize *int64 `json:"max_size"` // null means unlimited
MaxExpiry string `json:"max_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.")
return
}
lim, err := s.limitsFor(bearer(r))
lim, err := s.limitsFor(credential(r))
if err != nil {
s.fail(w, r, http.StatusUnauthorized, "Unrecognised token.")
return
}
out := limitsJSON{
Name: lim.Name,
Remembered: lim.Name != "" && bearer(r) == "",
MaxExpiry: config.FormatDuration(lim.MaxExpiry),
DefaultExpiry: config.FormatDuration(lim.DefaultExpiry),
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 /i/{id}", s.handleInfo)
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.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
// 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'; " +
"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 {
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())
}
}
// --- 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
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) {
@@ -75,6 +81,10 @@ 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)
}
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)
req := uploadRequest{token: bearer(r)}
req.explicit = req.token != ""
for n := 0; ; n++ {
if n > maxFieldCount {
@@ -112,6 +123,12 @@ func (s *Server) uploadMultipart(w http.ResponseWriter, r *http.Request, boundar
if req.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)
return
}
@@ -124,9 +141,11 @@ func (s *Server) uploadMultipart(w http.ResponseWriter, r *http.Request, boundar
}
switch part.FormName() {
case "token":
if value != "" {
req.token = value
if v := strings.TrimSpace(value); v != "" {
req.token, req.explicit = v, true
}
case "remember":
req.remember = true
case "vanity":
req.vanity = strings.TrimSpace(value)
case "expiry":
@@ -250,11 +269,28 @@ 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)"
+10 -2
View File
@@ -8,6 +8,7 @@ import (
"flag"
"fmt"
"log/slog"
"net"
"net/http"
"os"
"os/signal"
@@ -99,6 +100,13 @@ func serve(args []string) error {
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)
defer stop()
@@ -106,14 +114,14 @@ func serve(args []string) error {
go reloadOnHUP(ctx, tokens, log)
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()),
"max_size", config.FormatSize(cfg.MaxSize),
"max_expiry", config.FormatDuration(cfg.MaxExpiry))
errc := make(chan error, 1)
go func() {
err := httpSrv.ListenAndServe()
err := httpSrv.Serve(ln)
if errors.Is(err, http.ErrServerClosed) {
err = nil
}
+11 -13
View File
@@ -32,27 +32,25 @@
errorBox.hidden = !msg;
}
// --- token memory -------------------------------------------------------
// Kept in localStorage only so the field survives a reload; it is never sent
// anywhere but this origin's upload endpoint.
try {
var saved = localStorage.getItem('send.token');
if (saved && tokenInput) tokenInput.value = saved;
} catch (e) { /* private mode; not important */ }
// --- 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() : '';
try {
if (token) localStorage.setItem('send.token', token);
else localStorage.removeItem('send.token');
} catch (e) { /* ignore */ }
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) return;
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;
+24
View File
@@ -142,3 +142,27 @@ pre {
.warn p { margin: .25rem 0 .75rem; font-size: .875rem; }
.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"}}
{{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">
<!-- 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="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>
<div class="row">
@@ -15,7 +32,7 @@
<label class="field" id="vanity-field">
<span>Vanity name <em>token only</em></span>
<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>
</div>
@@ -39,7 +56,7 @@
<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">requires a token</dd>
<dt>Vanity names</dt><dd id="limit-vanity">{{if .AllowVanity}}allowed{{else}}requires a token{{end}}</dd>
</dl>
</section>