package server import ( "bytes" "encoding/json" "fmt" "io" "log/slog" "mime" "mime/multipart" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" "send/internal/auth" "send/internal/config" "send/internal/store" ) // clock is the fixed instant tests start from; s.now is swapped so expiry can // be exercised without sleeping. var clock = time.Date(2026, 9, 12, 10, 0, 0, 0, time.UTC) type harness struct { *Server ts *httptest.Server dir string now time.Time token string // a token allowing vanity names admin string } func newHarness(t *testing.T, tweak func(*config.Config)) *harness { t.Helper() dir := t.TempDir() cfg := config.Config{} fs := config.NewSet("test", "SEND_TEST_") cfg.Register(fs) if err := fs.Parse(nil); err != nil { t.Fatal(err) } cfg.DataDir = dir cfg.MaxSize = 1 << 20 cfg.MaxExpiry = 72 * time.Hour cfg.DefaultExpiry = 72 * time.Hour cfg.MinFreeBytes = 0 cfg.UploadRate = 100000 cfg.UploadBurst = 100000 if tweak != nil { tweak(&cfg) } if err := cfg.Normalise(); err != nil { t.Fatal(err) } st, err := store.Open(cfg.DataDir) if err != nil { t.Fatal(err) } tokens, err := auth.Load(cfg.TokensPath) if err != nil { t.Fatal(err) } h := &harness{dir: dir, now: clock} for _, spec := range []struct { name string admin bool dst *string }{{"friend", false, &h.token}, {"boss", true, &h.admin}} { secret, err := store.NewSecret() if err != nil { t.Fatal(err) } if err := tokens.Add(&auth.Token{ Name: spec.name, Hash: auth.HashSecret(secret), AllowVanity: true, Admin: spec.admin, }); err != nil { t.Fatal(err) } *spec.dst = secret } log := slog.New(slog.NewTextHandler(io.Discard, nil)) srv, err := New(&cfg, st, tokens, log) if err != nil { t.Fatal(err) } srv.now = func() time.Time { return h.now } h.Server = srv h.ts = httptest.NewServer(srv) t.Cleanup(h.ts.Close) return h } // upload posts a raw body, the way curl does. func (h *harness) upload(t *testing.T, body []byte, headers map[string]string) *http.Response { t.Helper() return h.uploadReader(t, bytes.NewReader(body), headers) } func (h *harness) uploadReader(t *testing.T, body io.Reader, headers map[string]string) *http.Response { t.Helper() req, err := http.NewRequest("POST", h.ts.URL+"/api/upload", body) if err != nil { t.Fatal(err) } 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) } return resp } func decode[T any](t *testing.T, resp *http.Response) T { t.Helper() defer resp.Body.Close() var v T if err := json.NewDecoder(resp.Body).Decode(&v); err != nil { t.Fatalf("decoding %s response: %v", resp.Status, err) } return v } func TestRoundTrip(t *testing.T) { h := newHarness(t, nil) payload := bytes.Repeat([]byte("godot"), 4096) resp := h.upload(t, payload, map[string]string{ "Content-Disposition": `attachment; filename="MyGame.zip"`, }) if resp.StatusCode != http.StatusCreated { t.Fatalf("upload status = %s", resp.Status) } res := decode[uploadResult](t, resp) if res.Filename != "MyGame.zip" { t.Errorf("filename = %q, want MyGame.zip", res.Filename) } if res.Size != int64(len(payload)) { t.Errorf("size = %d, want %d", res.Size, len(payload)) } if res.DeleteToken == "" { t.Error("no delete token returned") } get, err := h.ts.Client().Get(h.ts.URL + "/d/" + res.ID) if err != nil { t.Fatal(err) } defer get.Body.Close() got, _ := io.ReadAll(get.Body) if !bytes.Equal(got, payload) { t.Errorf("downloaded %d bytes, want %d", len(got), len(payload)) } // An uploaded file must never come back as something a browser will run. if ct := get.Header.Get("Content-Type"); ct != "application/octet-stream" { t.Errorf("Content-Type = %q", ct) } if get.Header.Get("X-Content-Type-Options") != "nosniff" { t.Error("missing nosniff") } if !strings.Contains(get.Header.Get("Content-Security-Policy"), "sandbox") { t.Errorf("CSP = %q", get.Header.Get("Content-Security-Policy")) } disp, params, err := mime.ParseMediaType(get.Header.Get("Content-Disposition")) if err != nil || disp != "attachment" || params["filename"] != "MyGame.zip" { t.Errorf("Content-Disposition = %q (%v)", get.Header.Get("Content-Disposition"), err) } } func TestHTMLUploadIsServedInert(t *testing.T) { h := newHarness(t, nil) resp := h.upload(t, []byte(""), map[string]string{ "Content-Disposition": `attachment; filename="evil.html"`, }) res := decode[uploadResult](t, resp) get, err := h.ts.Client().Get(h.ts.URL + "/d/" + res.ID) if err != nil { t.Fatal(err) } defer get.Body.Close() if ct := get.Header.Get("Content-Type"); ct != "application/octet-stream" { t.Errorf("HTML served as %q; it must never be text/html", ct) } if !strings.HasPrefix(get.Header.Get("Content-Disposition"), "attachment") { t.Error("HTML was not served as an attachment") } } func TestRangeRequest(t *testing.T) { h := newHarness(t, nil) payload := bytes.Repeat([]byte("abcdefgh"), 1024) res := decode[uploadResult](t, h.upload(t, payload, nil)) req, _ := http.NewRequest("GET", h.ts.URL+"/d/"+res.ID, nil) req.Header.Set("Range", "bytes=0-1023") get, err := h.ts.Client().Do(req) if err != nil { t.Fatal(err) } defer get.Body.Close() if get.StatusCode != http.StatusPartialContent { t.Fatalf("status = %s, want 206", get.Status) } body, _ := io.ReadAll(get.Body) if len(body) != 1024 || !bytes.Equal(body, payload[:1024]) { t.Errorf("got %d bytes, want the first 1024", len(body)) } if cr := get.Header.Get("Content-Range"); cr != fmt.Sprintf("bytes 0-1023/%d", len(payload)) { t.Errorf("Content-Range = %q", cr) } } // An oversized body must be refused on bytes actually written, never on a // declared length. This sends a chunked body, so there is no Content-Length to // consult even if the code wanted to. func TestOversizeChunkedUploadIsRefused(t *testing.T) { h := newHarness(t, func(c *config.Config) { c.MaxSize = 4096 }) // A plain io.Reader (not a *bytes.Buffer) makes the client use chunked // encoding with no declared length. body := io.LimitReader(zeroes{}, 1<<20) resp := h.uploadReader(t, struct{ io.Reader }{body}, nil) defer resp.Body.Close() if resp.StatusCode != http.StatusRequestEntityTooLarge { t.Fatalf("status = %s, want 413", resp.Status) } if n := h.store.Count(); n != 0 { t.Errorf("%d objects stored after a refused upload", n) } assertNoDebris(t, h.dir) } // A body one byte over the cap is refused; exactly at the cap is accepted. func TestSizeLimitBoundary(t *testing.T) { h := newHarness(t, func(c *config.Config) { c.MaxSize = 1000 }) resp := h.upload(t, bytes.Repeat([]byte("x"), 1000), nil) if resp.StatusCode != http.StatusCreated { t.Fatalf("exactly at the limit: status = %s, want 201", resp.Status) } resp.Body.Close() resp = h.upload(t, bytes.Repeat([]byte("x"), 1001), nil) defer resp.Body.Close() if resp.StatusCode != http.StatusRequestEntityTooLarge { t.Fatalf("one byte over: status = %s, want 413", resp.Status) } } type zeroes struct{} func (zeroes) Read(p []byte) (int, error) { return len(p), nil } func TestAnonymousCannotClaimVanity(t *testing.T) { h := newHarness(t, nil) resp := h.upload(t, []byte("hi"), map[string]string{"Vanity": "my-file"}) defer resp.Body.Close() if resp.StatusCode != http.StatusForbidden { t.Fatalf("status = %s, want 403", resp.Status) } if h.store.Exists("my-file") { t.Error("the name was claimed despite the refusal") } } func TestVanityCollision(t *testing.T) { h := newHarness(t, nil) hdr := map[string]string{ "Vanity": "my-file", "Authorization": "Bearer " + h.token, } resp := h.upload(t, []byte("first"), hdr) if resp.StatusCode != http.StatusCreated { t.Fatalf("first upload: status = %s", resp.Status) } resp.Body.Close() resp = h.upload(t, []byte("second"), hdr) defer resp.Body.Close() if resp.StatusCode != http.StatusConflict { t.Fatalf("second upload: status = %s, want 409", resp.Status) } // The first object must be untouched. get, err := h.ts.Client().Get(h.ts.URL + "/d/my-file") if err != nil { t.Fatal(err) } defer get.Body.Close() body, _ := io.ReadAll(get.Body) if string(body) != "first" { t.Errorf("content = %q, want %q", body, "first") } } func TestUnknownTokenIsRejected(t *testing.T) { h := newHarness(t, nil) resp := h.upload(t, []byte("hi"), map[string]string{"Authorization": "Bearer nope"}) defer resp.Body.Close() if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("status = %s, want 401", resp.Status) } } // Expiry is enforced on read, not only by the sweeper, which never runs here. func TestExpiryIsCheckedOnRead(t *testing.T) { h := newHarness(t, nil) res := decode[uploadResult](t, h.upload(t, []byte("ephemeral"), map[string]string{ "Expiry": "1h", })) status := func(when time.Duration) int { h.now = clock.Add(when) get, err := h.ts.Client().Get(h.ts.URL + "/d/" + res.ID) if err != nil { t.Fatal(err) } get.Body.Close() return get.StatusCode } if got := status(59 * time.Minute); got != http.StatusOK { t.Fatalf("before expiry: status = %d, want 200", got) } if got := status(61 * time.Minute); got != http.StatusNotFound { t.Fatalf("after expiry: status = %d, want 404", got) } // The read path also reclaims the space. if _, err := os.Stat(filepath.Join(h.dir, "objects", res.ID)); !os.IsNotExist(err) { t.Error("expired object was not removed on read") } } func TestExpiryBeyondLimitIsRefused(t *testing.T) { h := newHarness(t, nil) // max 72h for anonymous for _, req := range []string{"30d", "never"} { resp := h.upload(t, []byte("hi"), map[string]string{"Expiry": req}) if resp.StatusCode != http.StatusBadRequest { t.Errorf("Expiry: %s => status %s, want 400", req, resp.Status) } resp.Body.Close() } } func TestTokenMayOutliveTheAnonymousLimit(t *testing.T) { h := newHarness(t, nil) forever := "never" if err := h.tokens.Remove("friend"); err != nil { t.Fatal(err) } secret, _ := store.NewSecret() if err := h.tokens.Add(&auth.Token{ Name: "friend", Hash: auth.HashSecret(secret), MaxExpiry: &forever, DefaultExpiry: &forever, AllowVanity: true, }); err != nil { t.Fatal(err) } res := decode[uploadResult](t, h.upload(t, []byte("keep me"), map[string]string{ "Authorization": "Bearer " + secret, "Expiry": "never", })) if res.Expires != "" { t.Errorf("expires = %q, want empty (never)", res.Expires) } } func TestPathTraversalIsRejected(t *testing.T) { h := newHarness(t, nil) // As a requested vanity name. for _, name := range []string{"../etc/passwd", "..", ".", "/absolute", "a/b", `a\b`, "ok..name"} { resp := h.upload(t, []byte("x"), map[string]string{ "Vanity": name, "Authorization": "Bearer " + h.token, }) if resp.StatusCode == http.StatusCreated { t.Errorf("vanity %q was accepted", name) } resp.Body.Close() } // As a download path. Some of these are normalised away into a redirect to // the index, which is harmless; what matters is that no stored bytes are // ever served, so the check is for an object response rather than a status. for _, path := range []string{ "/d/..%2f..%2fetc%2fpasswd", "/d/.", "/d/..", "/d/%2e%2e", "/d/../tokens.json", "/d/%2e%2e%2ftokens.json", "/d/objects", } { get, err := h.ts.Client().Get(h.ts.URL + path) if err != nil { continue // the client itself may refuse to send it, which is fine } get.Body.Close() if get.Header.Get("Content-Disposition") != "" { t.Errorf("GET %s served an object", path) } } } func TestReservedNamesAreRejected(t *testing.T) { h := newHarness(t, nil) for _, name := range []string{"api", "static", "d", "i", "robots.txt"} { resp := h.upload(t, []byte("x"), map[string]string{ "Vanity": name, "Authorization": "Bearer " + h.token, }) if resp.StatusCode != http.StatusBadRequest { t.Errorf("vanity %q => %s, want 400", name, resp.Status) } resp.Body.Close() } } func TestDeleteRequiresTheRightToken(t *testing.T) { h := newHarness(t, nil) res := decode[uploadResult](t, h.upload(t, []byte("delete me"), nil)) del := func(token string) int { form := strings.NewReader("token=" + token) 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") resp, err := h.ts.Client().Do(req) if err != nil { t.Fatal(err) } resp.Body.Close() return resp.StatusCode } if got := del("wrong-token"); got != http.StatusForbidden { t.Errorf("wrong token => %d, want 403", got) } if got := del(h.token); got != http.StatusForbidden { t.Errorf("a non-owning, non-admin token => %d, want 403", got) } if got := del(res.DeleteToken); got != http.StatusOK { t.Errorf("correct token => %d, want 200", got) } // The token is single-use because the object it names is gone. if got := del(res.DeleteToken); got != http.StatusNotFound { t.Errorf("reused token => %d, want 404", got) } assertNoDebris(t, h.dir) } func TestAdminMayDeleteAnything(t *testing.T) { h := newHarness(t, nil) res := decode[uploadResult](t, h.upload(t, []byte("someone else's"), nil)) req, _ := http.NewRequest("POST", h.ts.URL+"/api/d/"+res.ID+"/delete", nil) req.Header.Set("Authorization", "Bearer "+h.admin) req.Header.Set("Accept", "application/json") resp, err := h.ts.Client().Do(req) if err != nil { t.Fatal(err) } resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("admin delete => %s, want 200", resp.Status) } } func TestMultipartUpload(t *testing.T) { h := newHarness(t, nil) var body bytes.Buffer mw := multipart.NewWriter(&body) // Order matters: the server needs these before the file part arrives. mw.WriteField("token", h.token) mw.WriteField("expiry", "2h") mw.WriteField("vanity", "from-the-form") fw, err := mw.CreateFormFile("file", "notes (draft).txt") if err != nil { t.Fatal(err) } fw.Write([]byte("hello from a browser")) 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") resp, err := h.ts.Client().Do(req) if err != nil { t.Fatal(err) } if resp.StatusCode != http.StatusCreated { t.Fatalf("status = %s", resp.Status) } res := decode[uploadResult](t, resp) if res.ID != "from-the-form" { t.Errorf("id = %q, want from-the-form", res.ID) } if res.Filename != "notes (draft).txt" { t.Errorf("filename = %q", res.Filename) } } // A form post with no Accept: application/json gets the HTML success page, so // the no-JS path works. func TestFormPostRendersHTML(t *testing.T) { h := newHarness(t, nil) var body bytes.Buffer mw := multipart.NewWriter(&body) fw, _ := mw.CreateFormFile("file", "thing.bin") fw.Write([]byte("data")) mw.Close() req, _ := http.NewRequest("POST", h.ts.URL+"/api/upload", &body) req.Header.Set("Content-Type", mw.FormDataContentType()) req.Header.Set("Accept", "text/html,application/xhtml+xml") resp, err := h.ts.Client().Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") { t.Fatalf("Content-Type = %q, want HTML", ct) } page, _ := io.ReadAll(resp.Body) if !strings.Contains(string(page), "Delete token") { t.Error("the success page does not show the delete token") } } func TestBasePathMounting(t *testing.T) { h := newHarness(t, func(c *config.Config) { c.BasePath = "/send" }) get, err := h.ts.Client().Get(h.ts.URL + "/send/") if err != nil { t.Fatal(err) } defer get.Body.Close() if get.StatusCode != http.StatusOK { t.Fatalf("GET /send/ => %s", get.Status) } page, _ := io.ReadAll(get.Body) if !strings.Contains(string(page), `href="/send/static/style.css"`) { t.Error("page links do not carry the base path") } // The bare prefix redirects to the slashed form. noRedirect := *h.ts.Client() noRedirect.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } resp, err := noRedirect.Get(h.ts.URL + "/send") if err != nil { t.Fatal(err) } resp.Body.Close() if resp.StatusCode != http.StatusMovedPermanently { t.Errorf("GET /send => %s, want 301", resp.Status) } } // An upload that dies mid-flight must leave nothing visible behind, and the // sweeper must eventually reclaim the directory. func TestAbandonedUploadIsInvisibleAndSwept(t *testing.T) { h := newHarness(t, nil) up, err := h.store.Reserve("half-done") if err != nil { t.Fatal(err) } up.Write([]byte("partial")) // Deliberately no Commit and no Abort: this is what a killed process leaves. get, err := h.ts.Client().Get(h.ts.URL + "/d/half-done") if err != nil { t.Fatal(err) } get.Body.Close() if get.StatusCode != http.StatusNotFound { t.Errorf("an uncommitted object was visible: %s", get.Status) } dir := filepath.Join(h.dir, "objects", "half-done") old := clock.Add(-48 * time.Hour) if err := os.Chtimes(dir, old, old); err != nil { t.Fatal(err) } h.store.Sweep(clock) if _, err := os.Stat(dir); !os.IsNotExist(err) { t.Error("abandoned upload directory was not swept") } } func TestQuotaRefusesUploads(t *testing.T) { h := newHarness(t, func(c *config.Config) { c.MaxTotalBytes = 100 }) resp := h.upload(t, bytes.Repeat([]byte("x"), 80), nil) if resp.StatusCode != http.StatusCreated { t.Fatalf("first upload => %s", resp.Status) } resp.Body.Close() // Only 20 bytes of quota remain, so this is truncated to the remainder and // refused rather than allowed to overshoot. resp = h.upload(t, bytes.Repeat([]byte("x"), 80), nil) defer resp.Body.Close() if resp.StatusCode != http.StatusRequestEntityTooLarge { t.Fatalf("over quota => %s, want 413", resp.Status) } } func TestRateLimit(t *testing.T) { h := newHarness(t, func(c *config.Config) { c.UploadRate = 1 c.UploadBurst = 2 }) var last *http.Response for range 3 { if last != nil { last.Body.Close() } last = h.upload(t, []byte("x"), nil) } defer last.Body.Close() if last.StatusCode != http.StatusTooManyRequests { t.Fatalf("third upload => %s, want 429", last.Status) } } // assertNoDebris checks that no object directory was left behind. func assertNoDebris(t *testing.T, dir string) { t.Helper() entries, err := os.ReadDir(filepath.Join(dir, "objects")) if err != nil { t.Fatal(err) } for _, e := range entries { 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 friend") { 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(""), 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) } } }