1888 lines
58 KiB
Go
1888 lines
58 KiB
Go
package server
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"mime"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"slices"
|
|
"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("<script>alert(1)</script>"), 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 ---------------------------------------------------
|
|
|
|
// Logging in is what stores a token; uploading never touches the cookie.
|
|
func TestLoginStoresTheToken(t *testing.T) {
|
|
h := newHarness(t, nil)
|
|
|
|
resp := h.postForm(t, "/login", url.Values{"token": {h.token}, "persist": {"1"}}, nil)
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusSeeOther {
|
|
t.Fatalf("status = %s, want 303", resp.Status)
|
|
}
|
|
if loc := resp.Header.Get("Location"); loc != "/" {
|
|
t.Errorf("Location = %q, want /", loc)
|
|
}
|
|
|
|
cookie := findCookie(resp, tokenCookie)
|
|
if cookie == nil {
|
|
t.Fatal("logging in set no cookie")
|
|
}
|
|
if cookie.Value != h.token {
|
|
t.Error("the cookie does not hold the token")
|
|
}
|
|
if !cookie.HttpOnly {
|
|
t.Error("the session cookie is readable by scripts")
|
|
}
|
|
if cookie.SameSite != http.SameSiteStrictMode {
|
|
t.Error("the session cookie is not SameSite=Strict, so it is CSRF-exposed")
|
|
}
|
|
if cookie.MaxAge <= 0 {
|
|
t.Error("'stay logged in' did not persist the cookie")
|
|
}
|
|
|
|
// The session alone is now enough to claim a custom name.
|
|
req, _ := http.NewRequest("POST", h.ts.URL+"/api/upload", strings.NewReader("two"))
|
|
req.Header.Set("Accept", "application/json")
|
|
req.Header.Set("Vanity", "session-upload")
|
|
req.AddCookie(cookie)
|
|
up, err := h.ts.Client().Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if up.StatusCode != http.StatusCreated {
|
|
t.Fatalf("upload with only the session: status = %s", up.Status)
|
|
}
|
|
if res := decode[uploadResult](t, up); res.ID != "session-upload" {
|
|
t.Errorf("id = %q, want session-upload", res.ID)
|
|
}
|
|
}
|
|
|
|
// Without "stay logged in" the cookie must die with the browser.
|
|
func TestLoginWithoutPersistIsASessionCookie(t *testing.T) {
|
|
h := newHarness(t, nil)
|
|
resp := h.postForm(t, "/login", url.Values{"token": {h.token}}, nil)
|
|
resp.Body.Close()
|
|
c := findCookie(resp, tokenCookie)
|
|
if c == nil {
|
|
t.Fatal("no cookie was set")
|
|
}
|
|
if c.MaxAge != 0 || !c.Expires.IsZero() {
|
|
t.Errorf("cookie carries a lifetime (MaxAge=%d), want a session cookie", c.MaxAge)
|
|
}
|
|
}
|
|
|
|
func TestLoginRejectsAnUnknownToken(t *testing.T) {
|
|
h := newHarness(t, nil)
|
|
resp := h.postForm(t, "/login", url.Values{"token": {"not-a-token"}}, nil)
|
|
raw, _ := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
|
t.Fatalf("status = %s, want 401", resp.Status)
|
|
}
|
|
if findCookie(resp, tokenCookie) != nil {
|
|
t.Error("a rejected login still set a cookie")
|
|
}
|
|
if !strings.Contains(string(raw), "not recognised") {
|
|
t.Error("the login page does not say what went wrong")
|
|
}
|
|
}
|
|
|
|
// Guessing a token at the login form is throttled; a correct one is not.
|
|
func TestFailedLoginsAreThrottled(t *testing.T) {
|
|
h := newHarness(t, nil)
|
|
h.authLimiter = newLimiter(1, 3)
|
|
|
|
var last *http.Response
|
|
for range 5 {
|
|
if last != nil {
|
|
last.Body.Close()
|
|
}
|
|
last = h.postForm(t, "/login", url.Values{"token": {"guess"}}, nil)
|
|
}
|
|
if last.StatusCode != http.StatusTooManyRequests {
|
|
t.Fatalf("repeated guesses => %s, want 429", last.Status)
|
|
}
|
|
last.Body.Close()
|
|
|
|
resp := h.postForm(t, "/login", url.Values{"token": {h.token}}, nil)
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusSeeOther {
|
|
t.Fatalf("a correct token was throttled: %s", resp.Status)
|
|
}
|
|
}
|
|
|
|
// The post-login destination is an allowlisted page name, never a URL, so it
|
|
// cannot be turned into an open redirect.
|
|
func TestLoginRedirectIsAllowlisted(t *testing.T) {
|
|
h := newHarness(t, nil)
|
|
for _, c := range []struct{ next, want string }{
|
|
{"admin", "/admin"},
|
|
{"", "/"},
|
|
{"https://evil.example.com", "/"},
|
|
{"//evil.example.com", "/"},
|
|
{"../../etc", "/"},
|
|
} {
|
|
resp := h.postForm(t, "/login", url.Values{"token": {h.admin}, "next": {c.next}}, nil)
|
|
resp.Body.Close()
|
|
if loc := resp.Header.Get("Location"); loc != c.want {
|
|
t.Errorf("next=%q => Location %q, want %q", c.next, loc, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLogoutEndsTheSession(t *testing.T) {
|
|
h := newHarness(t, nil)
|
|
resp := h.postForm(t, "/logout", url.Values{}, &http.Cookie{Name: tokenCookie, Value: h.token})
|
|
resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusSeeOther {
|
|
t.Fatalf("status = %s, want 303", resp.Status)
|
|
}
|
|
c := findCookie(resp, tokenCookie)
|
|
if c == nil || c.MaxAge >= 0 || c.Value != "" {
|
|
t.Fatalf("the session cookie was not cleared: %v", c)
|
|
}
|
|
}
|
|
|
|
// Uploading must never change the session, in either direction.
|
|
func TestUploadNeverTouchesTheSession(t *testing.T) {
|
|
h := newHarness(t, nil)
|
|
|
|
resp := h.formUpload(t, map[string]string{"token": h.token}, "a.bin", "one")
|
|
resp.Body.Close()
|
|
if c := findCookie(resp, tokenCookie); c != nil {
|
|
t.Errorf("an upload with a one-off token set a session cookie: %v", c)
|
|
}
|
|
|
|
resp = h.formUploadWith(t, &http.Cookie{Name: tokenCookie, Value: h.token},
|
|
map[string]string{}, "b.bin", "two")
|
|
resp.Body.Close()
|
|
if c := findCookie(resp, tokenCookie); c != nil {
|
|
t.Errorf("an upload cleared the session: %v", c)
|
|
}
|
|
}
|
|
|
|
// A one-off token on the form wins over the logged-in session.
|
|
func TestExplicitTokenBeatsTheCookie(t *testing.T) {
|
|
h := newHarness(t, nil)
|
|
cookie := &http.Cookie{Name: tokenCookie, Value: h.token}
|
|
|
|
resp := h.formUploadWith(t, cookie, map[string]string{"token": h.admin}, "b.bin", "y")
|
|
defer resp.Body.Close()
|
|
res := decode[uploadResult](t, resp)
|
|
|
|
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 session shadowed the one-off token", m.Owner)
|
|
}
|
|
}
|
|
|
|
// A session whose token has since been revoked must not wedge the page.
|
|
func TestStaleCookieIsDropped(t *testing.T) {
|
|
h := newHarness(t, nil)
|
|
req, _ := http.NewRequest("GET", h.ts.URL+"/", nil)
|
|
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), "no longer valid") {
|
|
t.Error("the page does not explain that the session ended")
|
|
}
|
|
if !strings.Contains(string(page), "<em>anonymous</em>") {
|
|
t.Error("the page claims an identity it could not resolve")
|
|
}
|
|
}
|
|
|
|
// The session is resolved server-side, so every page agrees about who you are
|
|
// even though the cookie is unreadable by script.
|
|
func TestIndexShowsWhoIsLoggedIn(t *testing.T) {
|
|
h := newHarness(t, nil)
|
|
req, _ := http.NewRequest("GET", h.ts.URL+"/", nil)
|
|
req.AddCookie(&http.Cookie{Name: tokenCookie, Value: h.token})
|
|
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), ">friend<") {
|
|
t.Error("the page does not show who is logged in")
|
|
}
|
|
if strings.Contains(string(page), h.token) {
|
|
t.Error("the page echoes the token back into the HTML")
|
|
}
|
|
}
|
|
|
|
// 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 logged-in 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)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The result page is the only place a link to the info page is ever offered,
|
|
// so losing it strands that page with no way to discover it.
|
|
func TestResultPageOffersBothLinksAndAWayBack(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")
|
|
resp, err := h.ts.Client().Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
raw, _ := io.ReadAll(resp.Body)
|
|
page := string(raw)
|
|
|
|
id := h.store.List()[0].ID
|
|
for _, want := range []struct{ what, fragment string }{
|
|
{"the info page link", "/i/" + id},
|
|
{"the direct download link", "/d/" + id},
|
|
{"a way to upload another file", `href="/">Upload another file`},
|
|
{"the script that enables the copy buttons", "static/app.js"},
|
|
} {
|
|
if !strings.Contains(page, want.fragment) {
|
|
t.Errorf("the result page is missing %s (%q)", want.what, want.fragment)
|
|
}
|
|
}
|
|
|
|
// Copy buttons ship hidden, so a reader without JavaScript never sees a
|
|
// button that does nothing.
|
|
if strings.Count(page, `class="copy"`) != strings.Count(page, `hidden>Copy<`) {
|
|
t.Error("a copy button is not hidden by default")
|
|
}
|
|
}
|
|
|
|
// The JSON reply has to carry the same two links, since the script builds the
|
|
// result card from it alone.
|
|
func TestUploadJSONCarriesBothLinks(t *testing.T) {
|
|
h := newHarness(t, nil)
|
|
res := decode[uploadResult](t, h.upload(t, []byte("x"), nil))
|
|
|
|
if res.URL == "" || !strings.Contains(res.URL, "/d/"+res.ID) {
|
|
t.Errorf("url = %q, want the direct download", res.URL)
|
|
}
|
|
if res.InfoURL == "" || !strings.Contains(res.InfoURL, "/i/"+res.ID) {
|
|
t.Errorf("info_url = %q, want the info page", res.InfoURL)
|
|
}
|
|
// Both must actually resolve.
|
|
for _, u := range []string{res.URL, res.InfoURL} {
|
|
get, err := h.ts.Client().Get(u)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
get.Body.Close()
|
|
if get.StatusCode != http.StatusOK {
|
|
t.Errorf("GET %s => %s", u, get.Status)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- administration ------------------------------------------------------
|
|
|
|
func (h *harness) get(t *testing.T, path, token string) *http.Response {
|
|
t.Helper()
|
|
req, _ := http.NewRequest("GET", h.ts.URL+path, nil)
|
|
if token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
resp, err := h.ts.Client().Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return resp
|
|
}
|
|
|
|
// The admin page shows every stored file, so who may open it is the whole
|
|
// security story for this feature.
|
|
func TestAdminPageAccessControl(t *testing.T) {
|
|
h := newHarness(t, nil)
|
|
cases := []struct {
|
|
who string
|
|
token string
|
|
want int
|
|
}{
|
|
{"anonymous", "", http.StatusUnauthorized},
|
|
{"an unknown token", "not-a-token", http.StatusUnauthorized},
|
|
{"a non-admin token", h.token, http.StatusForbidden},
|
|
{"an admin token", h.admin, http.StatusOK},
|
|
}
|
|
for _, c := range cases {
|
|
resp := h.get(t, "/admin", c.token)
|
|
resp.Body.Close()
|
|
if resp.StatusCode != c.want {
|
|
t.Errorf("GET /admin as %s => %s, want %d", c.who, resp.Status, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The cookie is the credential a browser actually uses for this page.
|
|
func TestAdminPageAcceptsTheSession(t *testing.T) {
|
|
h := newHarness(t, nil)
|
|
req, _ := http.NewRequest("GET", h.ts.URL+"/admin", nil)
|
|
req.AddCookie(&http.Cookie{Name: tokenCookie, Value: h.admin})
|
|
resp, err := h.ts.Client().Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("GET /admin with an admin cookie => %s", resp.Status)
|
|
}
|
|
}
|
|
|
|
func TestAdminPageListsEveryoneAndHidesExpired(t *testing.T) {
|
|
h := newHarness(t, nil)
|
|
|
|
// One anonymous, one owned, one that will have expired by the time the
|
|
// page is rendered.
|
|
h.upload(t, []byte("anon"), map[string]string{
|
|
"Content-Disposition": `attachment; filename="anonymous.bin"`}).Body.Close()
|
|
h.upload(t, []byte("owned"), map[string]string{
|
|
"Authorization": "Bearer " + h.token,
|
|
"Vanity": "friends-file",
|
|
"Content-Disposition": `attachment; filename="owned.bin"`}).Body.Close()
|
|
h.upload(t, []byte("gone"), map[string]string{
|
|
"Expiry": "1h",
|
|
"Content-Disposition": `attachment; filename="expired.bin"`}).Body.Close()
|
|
|
|
h.now = clock.Add(2 * time.Hour)
|
|
resp := h.get(t, "/admin", h.admin)
|
|
defer resp.Body.Close()
|
|
raw, _ := io.ReadAll(resp.Body)
|
|
page := string(raw)
|
|
|
|
for _, want := range []string{"anonymous.bin", "owned.bin", "friends-file", "friend"} {
|
|
if !strings.Contains(page, want) {
|
|
t.Errorf("the admin page does not list %q", want)
|
|
}
|
|
}
|
|
if strings.Contains(page, "expired.bin") {
|
|
t.Error("the admin page lists an expired file as though it were still stored")
|
|
}
|
|
// Token names and limits are shown; nothing secret is.
|
|
if !strings.Contains(page, "boss") {
|
|
t.Error("the token table does not list the tokens")
|
|
}
|
|
for _, secret := range []string{h.admin, h.token} {
|
|
if strings.Contains(page, secret) {
|
|
t.Error("the admin page echoes a token secret")
|
|
}
|
|
if strings.Contains(page, auth.HashSecret(secret)) {
|
|
t.Error("the admin page exposes a token hash")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAdminSortIsRestrictedToKnownColumns(t *testing.T) {
|
|
h := newHarness(t, nil)
|
|
h.upload(t, []byte("x"), nil).Body.Close()
|
|
|
|
for _, sort := range []string{"size", "created", "expires", "name", "owner", "", "../../etc", "nonsense"} {
|
|
resp := h.get(t, "/admin?sort="+url.QueryEscape(sort), h.admin)
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Errorf("sort=%q => %s", sort, resp.Status)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Deleting from the table returns to the table rather than to a dead end.
|
|
func TestAdminDeleteReturnsToTheTable(t *testing.T) {
|
|
h := newHarness(t, nil)
|
|
res := decode[uploadResult](t, h.upload(t, []byte("someone else's"), nil))
|
|
|
|
client := *h.ts.Client()
|
|
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
|
|
|
|
form := strings.NewReader("from=admin")
|
|
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", "text/html")
|
|
req.AddCookie(&http.Cookie{Name: tokenCookie, Value: h.admin})
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusSeeOther {
|
|
t.Fatalf("status = %s, want 303", resp.Status)
|
|
}
|
|
if loc := resp.Header.Get("Location"); loc != "/admin" {
|
|
t.Errorf("Location = %q, want /admin", loc)
|
|
}
|
|
if _, err := h.store.Get(res.ID, h.now); err == nil {
|
|
t.Error("the file was not deleted")
|
|
}
|
|
}
|
|
|
|
// A non-admin must not be able to delete someone else's file from that form.
|
|
func TestAdminDeleteStillRequiresAdmin(t *testing.T) {
|
|
h := newHarness(t, nil)
|
|
res := decode[uploadResult](t, h.upload(t, []byte("not yours"), nil))
|
|
|
|
form := strings.NewReader("from=admin")
|
|
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)
|
|
}
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
t.Fatalf("status = %s, want 403", resp.Status)
|
|
}
|
|
}
|
|
|
|
// The header link is the only way to discover the page, so it must appear for
|
|
// an admin and never for anyone else.
|
|
func TestAdminLinkIsShownOnlyToAdmins(t *testing.T) {
|
|
h := newHarness(t, nil)
|
|
for _, c := range []struct {
|
|
who string
|
|
token string
|
|
want bool
|
|
}{
|
|
{"anonymous", "", false},
|
|
{"a non-admin token", h.token, false},
|
|
{"an admin token", h.admin, true},
|
|
} {
|
|
req, _ := http.NewRequest("GET", h.ts.URL+"/", nil)
|
|
if c.token != "" {
|
|
req.AddCookie(&http.Cookie{Name: tokenCookie, Value: c.token})
|
|
}
|
|
resp, err := h.ts.Client().Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
raw, _ := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
if got := strings.Contains(string(raw), `href="/admin"`); got != c.want {
|
|
t.Errorf("admin link shown to %s = %v, want %v", c.who, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Mixing the two request shapes — a multipart body with the headers the raw
|
|
// shape uses — must not silently discard the options. Being handed a UUID when
|
|
// you asked for a name is worse than being told no.
|
|
func TestMultipartHonoursTheHeaderForm(t *testing.T) {
|
|
h := newHarness(t, nil)
|
|
|
|
var body bytes.Buffer
|
|
mw := multipart.NewWriter(&body)
|
|
fw, _ := mw.CreateFormFile("file", "build.zip")
|
|
fw.Write([]byte("payload"))
|
|
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")
|
|
req.Header.Set("Authorization", "Bearer "+h.token)
|
|
req.Header.Set("Vanity", "friends-build")
|
|
req.Header.Set("Expiry", "1h")
|
|
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 != "friends-build" {
|
|
t.Errorf("id = %q, want friends-build: the Vanity header was ignored", res.ID)
|
|
}
|
|
if want := clock.Add(time.Hour).UTC().Format(time.RFC3339); res.Expires != want {
|
|
t.Errorf("expires = %q, want %q: the Expiry header was ignored", res.Expires, want)
|
|
}
|
|
}
|
|
|
|
// A form field still wins, so the browser's own controls stay authoritative.
|
|
func TestFormFieldsOverrideTheHeaders(t *testing.T) {
|
|
h := newHarness(t, nil)
|
|
|
|
var body bytes.Buffer
|
|
mw := multipart.NewWriter(&body)
|
|
mw.WriteField("vanity", "from-the-form")
|
|
mw.WriteField("expiry", "")
|
|
fw, _ := mw.CreateFormFile("file", "build.zip")
|
|
fw.Write([]byte("payload"))
|
|
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")
|
|
req.Header.Set("Authorization", "Bearer "+h.token)
|
|
req.Header.Set("Vanity", "from-the-header")
|
|
resp, err := h.ts.Client().Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
res := decode[uploadResult](t, resp)
|
|
if res.ID != "from-the-form" {
|
|
t.Errorf("id = %q, want the form field to win", res.ID)
|
|
}
|
|
}
|
|
|
|
// --- deleting from the info page -----------------------------------------
|
|
|
|
// postForm submits a form the way a browser would, without following the
|
|
// redirect: where these posts send you, and what they set on the way, is
|
|
// usually the thing under test.
|
|
func (h *harness) postForm(t *testing.T, path string, form url.Values, cookie *http.Cookie) *http.Response {
|
|
t.Helper()
|
|
req, _ := http.NewRequest("POST", h.ts.URL+path, strings.NewReader(form.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
req.Header.Set("Accept", "text/html")
|
|
if cookie != nil {
|
|
req.AddCookie(cookie)
|
|
}
|
|
client := *h.ts.Client()
|
|
client.CheckRedirect = func(*http.Request, []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return resp
|
|
}
|
|
|
|
// The delete token is shown once and then has to be usable somewhere. The info
|
|
// page is the link an uploader would have kept, so the form lives there.
|
|
func TestInfoPageAcceptsTheDeleteToken(t *testing.T) {
|
|
h := newHarness(t, nil)
|
|
res := decode[uploadResult](t, h.upload(t, []byte("x"), nil))
|
|
|
|
info := h.get(t, "/i/"+res.ID, "")
|
|
raw, _ := io.ReadAll(info.Body)
|
|
info.Body.Close()
|
|
page := string(raw)
|
|
|
|
if !strings.Contains(page, "Remove this file") {
|
|
t.Error("the info page offers no way to use a delete token")
|
|
}
|
|
if !strings.Contains(page, `name="token"`) {
|
|
t.Error("the info page has no field for the delete token")
|
|
}
|
|
if strings.Contains(page, res.DeleteToken) {
|
|
t.Fatal("the info page leaks the delete token to anyone holding the link")
|
|
}
|
|
|
|
resp := h.postForm(t, "/api/d/"+res.ID+"/delete",
|
|
url.Values{"from": {"info"}, "token": {res.DeleteToken}}, nil)
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("deleting with the right token => %s", resp.Status)
|
|
}
|
|
if _, err := h.store.Get(res.ID, h.now); err == nil {
|
|
t.Error("the file was not deleted")
|
|
}
|
|
}
|
|
|
|
// A mistyped token must land back on the file's page with the reason, not on a
|
|
// generic error page that has thrown the form away.
|
|
func TestWrongDeleteTokenReturnsToTheInfoPage(t *testing.T) {
|
|
h := newHarness(t, nil)
|
|
res := decode[uploadResult](t, h.upload(t, []byte("x"), map[string]string{
|
|
"Content-Disposition": `attachment; filename="keepme.bin"`}))
|
|
|
|
resp := h.postForm(t, "/api/d/"+res.ID+"/delete",
|
|
url.Values{"from": {"info"}, "token": {"wrong"}}, nil)
|
|
raw, _ := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
page := string(raw)
|
|
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
t.Errorf("status = %s, want 403", resp.Status)
|
|
}
|
|
if !strings.Contains(page, "keepme.bin") {
|
|
t.Error("the response is not the file's own page")
|
|
}
|
|
if !strings.Contains(page, "not correct") {
|
|
t.Error("the page does not say what went wrong")
|
|
}
|
|
if !strings.Contains(page, "<details open>") {
|
|
t.Error("the delete section is collapsed, hiding the error")
|
|
}
|
|
if _, err := h.store.Get(res.ID, h.now); err != nil {
|
|
t.Error("the file was deleted despite a wrong token")
|
|
}
|
|
}
|
|
|
|
// Someone whose own token already authorises removal gets a button, not a
|
|
// field asking for a token they do not have.
|
|
func TestInfoPageOffersADirectButtonToAnOwner(t *testing.T) {
|
|
h := newHarness(t, nil)
|
|
res := decode[uploadResult](t, h.upload(t, []byte("x"), map[string]string{
|
|
"Authorization": "Bearer " + h.token}))
|
|
|
|
for _, c := range []struct {
|
|
who string
|
|
token string
|
|
expectBtn bool
|
|
}{
|
|
{"the owner", h.token, true},
|
|
{"an admin", h.admin, true},
|
|
{"a stranger", "", false},
|
|
} {
|
|
req, _ := http.NewRequest("GET", h.ts.URL+"/i/"+res.ID, nil)
|
|
if c.token != "" {
|
|
req.AddCookie(&http.Cookie{Name: tokenCookie, Value: c.token})
|
|
}
|
|
resp, err := h.ts.Client().Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
raw, _ := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
got := strings.Contains(string(raw), "Your token can remove this file")
|
|
if got != c.expectBtn {
|
|
t.Errorf("direct delete button shown to %s = %v, want %v", c.who, got, c.expectBtn)
|
|
}
|
|
}
|
|
|
|
// And that button actually works with no token field at all.
|
|
resp := h.postForm(t, "/api/d/"+res.ID+"/delete",
|
|
url.Values{"from": {"info"}}, &http.Cookie{Name: tokenCookie, Value: h.token})
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("owner delete => %s", resp.Status)
|
|
}
|
|
}
|
|
|
|
// Guessing is throttled, but only the guessing: a correct token is never
|
|
// delayed by someone else's failed attempts.
|
|
func TestFailedDeletesAreThrottledAndSuccessIsNot(t *testing.T) {
|
|
h := newHarness(t, nil)
|
|
h.authLimiter = newLimiter(1, 3)
|
|
|
|
res := decode[uploadResult](t, h.upload(t, []byte("x"), nil))
|
|
|
|
var last *http.Response
|
|
for range 5 {
|
|
if last != nil {
|
|
last.Body.Close()
|
|
}
|
|
last = h.postForm(t, "/api/d/"+res.ID+"/delete",
|
|
url.Values{"from": {"info"}, "token": {"guess"}}, nil)
|
|
}
|
|
if last.StatusCode != http.StatusTooManyRequests {
|
|
t.Fatalf("repeated guesses => %s, want 429", last.Status)
|
|
}
|
|
last.Body.Close()
|
|
|
|
// The real token still works, having consumed nothing from the bucket.
|
|
resp := h.postForm(t, "/api/d/"+res.ID+"/delete",
|
|
url.Values{"from": {"info"}, "token": {res.DeleteToken}}, nil)
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("the correct token was throttled: %s", resp.Status)
|
|
}
|
|
}
|
|
|
|
// --- cross-site posts ----------------------------------------------------
|
|
|
|
// A login form needs no cookie to submit, so SameSite does not cover it: a
|
|
// hostile page could otherwise sign a visitor into an account it controls and
|
|
// collect whatever they upload next. Browsers label their own requests, and
|
|
// those labels are checked on every state-changing route.
|
|
func TestCrossOriginPostsAreRejected(t *testing.T) {
|
|
h := newHarness(t, nil)
|
|
|
|
paths := []string{"/login", "/logout", "/api/upload", "/api/d/anything/delete"}
|
|
hostile := []map[string]string{
|
|
{"Origin": "https://evil.example.com"},
|
|
{"Sec-Fetch-Site": "cross-site"},
|
|
{"Sec-Fetch-Site": "same-site"},
|
|
}
|
|
for _, path := range paths {
|
|
for _, headers := range hostile {
|
|
req, _ := http.NewRequest("POST", h.ts.URL+path, strings.NewReader("token=x"))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
req.Header.Set("Accept", "application/json")
|
|
for k, v := range headers {
|
|
req.Header.Set(k, v)
|
|
}
|
|
resp, err := h.ts.Client().Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
t.Errorf("POST %s with %v => %s, want 403", path, headers, resp.Status)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// The page's own posts, and API clients that label nothing, must still work.
|
|
func TestSameOriginAndUnlabelledPostsAreAccepted(t *testing.T) {
|
|
h := newHarness(t, nil)
|
|
for _, headers := range []map[string]string{
|
|
{}, // curl and friends
|
|
{"Sec-Fetch-Site": "same-origin"}, // the page itself
|
|
{"Sec-Fetch-Site": "none"}, // typed into the bar
|
|
{"Origin": "http://" + strings.TrimPrefix(h.ts.URL, "http://")}, // older browser
|
|
} {
|
|
req, _ := http.NewRequest("POST", h.ts.URL+"/login",
|
|
strings.NewReader(url.Values{"token": {h.token}}.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
req.Header.Set("Accept", "application/json")
|
|
for k, v := range headers {
|
|
req.Header.Set(k, v)
|
|
}
|
|
resp, err := h.ts.Client().Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Errorf("POST /login with %v => %s, want 200", headers, resp.Status)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The header is the only navigation there is, so it has to tell the truth
|
|
// about the session on every page.
|
|
func TestHeaderReflectsTheSession(t *testing.T) {
|
|
h := newHarness(t, nil)
|
|
|
|
for _, c := range []struct {
|
|
who string
|
|
token string
|
|
present []string
|
|
absent []string
|
|
}{
|
|
{"anonymous", "", []string{`href="/login"`}, []string{`action="/logout"`, `href="/admin"`}},
|
|
{"a plain token", h.token, []string{`action="/logout"`, ">friend<"}, []string{`href="/login"`, `href="/admin"`}},
|
|
{"an admin token", h.admin, []string{`action="/logout"`, `href="/admin"`, ">boss<"}, []string{`href="/login"`}},
|
|
} {
|
|
for _, path := range []string{"/", "/login"} {
|
|
req, _ := http.NewRequest("GET", h.ts.URL+path, nil)
|
|
if c.token != "" {
|
|
req.AddCookie(&http.Cookie{Name: tokenCookie, Value: c.token})
|
|
}
|
|
resp, err := h.ts.Client().Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
raw, _ := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
page := string(raw)
|
|
|
|
for _, want := range c.present {
|
|
if !strings.Contains(page, want) {
|
|
t.Errorf("GET %s as %s: missing %q", path, c.who, want)
|
|
}
|
|
}
|
|
for _, unwanted := range c.absent {
|
|
if strings.Contains(page, unwanted) {
|
|
t.Errorf("GET %s as %s: unexpectedly offers %q", path, c.who, unwanted)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// The upload form keeps a one-off token field, so a quick upload under another
|
|
// token does not require logging in and out.
|
|
func TestUploadPageKeepsTheOneOffTokenField(t *testing.T) {
|
|
h := newHarness(t, nil)
|
|
resp := h.get(t, "/", "")
|
|
raw, _ := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
page := string(raw)
|
|
|
|
if !strings.Contains(page, `name="token"`) {
|
|
t.Error("the upload form has no one-off token field")
|
|
}
|
|
if !strings.Contains(page, "does not log you in") {
|
|
t.Error("the form does not explain that the field is one-off")
|
|
}
|
|
// The limits are rendered, not fetched, so no script is needed to show them.
|
|
if !strings.Contains(page, `data-max-size="1048576"`) {
|
|
t.Error("the form does not carry the server-rendered size limit")
|
|
}
|
|
}
|
|
|
|
// A chosen passphrase has to work everywhere a generated token does: at the
|
|
// login form, on an upload, and as a session.
|
|
func TestChosenPassphraseWorksEndToEnd(t *testing.T) {
|
|
h := newHarness(t, nil)
|
|
const passphrase = "godot-friends-2026"
|
|
|
|
tok, err := auth.NewChosen("memorable", passphrase)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tok.AllowVanity = true
|
|
if err := h.tokens.Add(tok); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
resp := h.postForm(t, "/login", url.Values{"token": {passphrase}, "persist": {"1"}}, nil)
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusSeeOther {
|
|
t.Fatalf("login with a passphrase => %s", resp.Status)
|
|
}
|
|
cookie := findCookie(resp, tokenCookie)
|
|
if cookie == nil {
|
|
t.Fatal("no session was started")
|
|
}
|
|
|
|
req, _ := http.NewRequest("POST", h.ts.URL+"/api/upload", strings.NewReader("x"))
|
|
req.Header.Set("Accept", "application/json")
|
|
req.Header.Set("Vanity", "chosen-upload")
|
|
req.AddCookie(cookie)
|
|
up, err := h.ts.Client().Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if up.StatusCode != http.StatusCreated {
|
|
t.Fatalf("upload with a passphrase session => %s", up.Status)
|
|
}
|
|
if res := decode[uploadResult](t, up); res.ID != "chosen-upload" {
|
|
t.Errorf("id = %q, want chosen-upload", res.ID)
|
|
}
|
|
}
|
|
|
|
// Deriving a passphrase is expensive by design, which makes an unverified
|
|
// credential an amplifier unless the work is charged for. Junk must not be
|
|
// able to buy unlimited derivations.
|
|
func TestUnverifiedCredentialsCannotForceUnlimitedDerivations(t *testing.T) {
|
|
h := newHarness(t, nil)
|
|
tok, err := auth.NewChosen("memorable", "a-chosen-passphrase")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := h.tokens.Add(tok); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h.authLimiter = newLimiter(1, 3)
|
|
|
|
// Distinct junk on every request, so the memo never answers.
|
|
for i := range 6 {
|
|
req, _ := http.NewRequest("GET", h.ts.URL+"/", nil)
|
|
req.AddCookie(&http.Cookie{Name: tokenCookie, Value: fmt.Sprintf("junk-%d", i)})
|
|
resp, err := h.ts.Client().Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp.Body.Close()
|
|
// The page still renders; it just renders as anonymous.
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("page %d => %s", i, resp.Status)
|
|
}
|
|
}
|
|
if h.tokens.Resolved("junk-5") {
|
|
t.Error("a derivation ran past the budget")
|
|
}
|
|
}
|
|
|
|
// The memo means a live session pays the derivation once, not per request.
|
|
func TestPassphraseSessionsAreMemoised(t *testing.T) {
|
|
h := newHarness(t, nil)
|
|
const passphrase = "a-chosen-passphrase"
|
|
tok, err := auth.NewChosen("memorable", passphrase)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := h.tokens.Add(tok); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if h.tokens.Resolved(passphrase) {
|
|
t.Fatal("resolved before anything verified it")
|
|
}
|
|
resp := h.get(t, "/", "")
|
|
resp.Body.Close()
|
|
|
|
req, _ := http.NewRequest("GET", h.ts.URL+"/", nil)
|
|
req.AddCookie(&http.Cookie{Name: tokenCookie, Value: passphrase})
|
|
first, err := h.ts.Client().Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
first.Body.Close()
|
|
|
|
if !h.tokens.Resolved(passphrase) {
|
|
t.Error("the session was not memoised, so every request would derive again")
|
|
}
|
|
}
|
|
|
|
// Rotating a secret has to end the sessions that were using it, or rotation
|
|
// would not actually revoke anything.
|
|
func TestRotationEndsLiveSessions(t *testing.T) {
|
|
h := newHarness(t, nil)
|
|
|
|
// A logged-in browser, and a page render proving the session works.
|
|
session := &http.Cookie{Name: tokenCookie, Value: h.token}
|
|
req, _ := http.NewRequest("GET", h.ts.URL+"/", nil)
|
|
req.AddCookie(session)
|
|
resp, err := h.ts.Client().Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
raw, _ := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
if !strings.Contains(string(raw), ">friend<") {
|
|
t.Fatal("setup: the session is not logged in")
|
|
}
|
|
|
|
var replacement string
|
|
if err := h.tokens.Update("friend", func(tok *auth.Token) error {
|
|
s, err := tok.SetGenerated()
|
|
replacement = s
|
|
return err
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// The old cookie is now just a string.
|
|
req, _ = http.NewRequest("GET", h.ts.URL+"/", nil)
|
|
req.AddCookie(session)
|
|
resp, err = h.ts.Client().Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
raw, _ = io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
if strings.Contains(string(raw), ">friend<") {
|
|
t.Error("a rotated-away secret still authenticates a session")
|
|
}
|
|
if !strings.Contains(string(raw), "no longer valid") {
|
|
t.Error("the page does not explain that the session ended")
|
|
}
|
|
|
|
// Uploading with the old secret is refused; the new one works.
|
|
old := h.upload(t, []byte("x"), map[string]string{"Authorization": "Bearer " + h.token})
|
|
old.Body.Close()
|
|
if old.StatusCode != http.StatusUnauthorized {
|
|
t.Errorf("upload with the old secret => %s, want 401", old.Status)
|
|
}
|
|
fresh := h.upload(t, []byte("x"), map[string]string{
|
|
"Authorization": "Bearer " + replacement,
|
|
"Vanity": "after-rotation",
|
|
})
|
|
if fresh.StatusCode != http.StatusCreated {
|
|
t.Fatalf("upload with the rotated secret => %s", fresh.Status)
|
|
}
|
|
if res := decode[uploadResult](t, fresh); res.ID != "after-rotation" {
|
|
t.Errorf("id = %q: the rotated token lost its vanity permission", res.ID)
|
|
}
|
|
}
|
|
|
|
// Sorting has to compare the underlying values, not their rendered form: two
|
|
// uploads in the same minute render identically but are not equal.
|
|
func TestAdminSortOrdersByValue(t *testing.T) {
|
|
h := newHarness(t, nil)
|
|
|
|
// Three files, distinct in every sortable dimension.
|
|
type spec struct {
|
|
name string
|
|
size int
|
|
expiry string
|
|
}
|
|
for i, s := range []spec{
|
|
{"big", 300, "3h"},
|
|
{"small", 10, "1h"},
|
|
{"medium", 100, "2h"},
|
|
} {
|
|
h.now = clock.Add(time.Duration(i) * time.Second) // same minute, distinct instants
|
|
resp := h.upload(t, bytes.Repeat([]byte("x"), s.size), map[string]string{
|
|
"Authorization": "Bearer " + h.token,
|
|
"Vanity": s.name,
|
|
"Expiry": s.expiry,
|
|
"Content-Disposition": `attachment; filename="` + s.name + `.bin"`,
|
|
})
|
|
resp.Body.Close()
|
|
}
|
|
h.now = clock
|
|
|
|
order := func(sortBy string) []string {
|
|
resp := h.get(t, "/admin?sort="+sortBy, h.admin)
|
|
raw, _ := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
var ids []string
|
|
for _, m := range regexp.MustCompile(`class="id mono">([a-z]+)`).FindAllStringSubmatch(string(raw), -1) {
|
|
ids = append(ids, m[1])
|
|
}
|
|
return ids
|
|
}
|
|
|
|
for _, c := range []struct {
|
|
sortBy string
|
|
want []string
|
|
}{
|
|
{"size", []string{"big", "medium", "small"}}, // largest first
|
|
{"expires", []string{"small", "medium", "big"}}, // soonest first
|
|
{"created", []string{"medium", "small", "big"}}, // newest first
|
|
{"name", []string{"big", "medium", "small"}}, // by filename
|
|
} {
|
|
got := order(c.sortBy)
|
|
if !slices.Equal(got, c.want) {
|
|
t.Errorf("sort=%s gave %v, want %v", c.sortBy, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The listing is a table, and a table needs more room than a form. It also has
|
|
// to stop being a table on a narrow screen rather than grow a scrollbar.
|
|
func TestAdminPageIsLaidOutForATable(t *testing.T) {
|
|
h := newHarness(t, nil)
|
|
h.upload(t, []byte("x"), nil).Body.Close()
|
|
|
|
resp := h.get(t, "/admin", h.admin)
|
|
raw, _ := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
page := string(raw)
|
|
|
|
if !strings.Contains(page, `class="wide"`) {
|
|
t.Error("the listing renders at the narrow reading measure meant for forms")
|
|
}
|
|
// Every cell needs its label for the stacked layout, where the header row
|
|
// is hidden.
|
|
for _, label := range []string{"Size", "Owner", "Uploaded", "Expires"} {
|
|
if !strings.Contains(page, `data-label="`+label+`"`) {
|
|
t.Errorf("cells carry no %q label, so the stacked layout loses its headings", label)
|
|
}
|
|
}
|
|
|
|
css := h.get(t, "/static/style.css", "")
|
|
cssRaw, _ := io.ReadAll(css.Body)
|
|
css.Body.Close()
|
|
style := string(cssRaw)
|
|
|
|
if !strings.Contains(style, "body.wide") {
|
|
t.Error("no wide layout is defined")
|
|
}
|
|
if !strings.Contains(style, "@media (max-width: 46rem)") {
|
|
t.Error("no narrow-screen rule, so the table will scroll sideways on a phone")
|
|
}
|
|
if strings.Contains(style, "overflow-x: auto") && strings.Contains(page, "tablewrap") {
|
|
t.Error("the listing still relies on a horizontal scroll container")
|
|
}
|
|
}
|