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
+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)
}
}
}