Add --port
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user