Add --port
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// tokenCookie remembers a caller's token so it does not have to be pasted for
|
||||
// every upload.
|
||||
//
|
||||
// It is HttpOnly, so a script on this origin cannot read it back — which is the
|
||||
// reason to prefer it over localStorage, where any injected script could
|
||||
// exfiltrate the credential. The page never needs to see the value: the server
|
||||
// resolves it and renders who the caller is.
|
||||
const tokenCookie = "send_token"
|
||||
|
||||
// rememberFor is how long a remembered token survives. Tokens are revoked by
|
||||
// deleting them from the token file, so a long window costs nothing.
|
||||
const rememberFor = 365 * 24 * time.Hour
|
||||
|
||||
// cookieCredential returns the remembered token, if any.
|
||||
func cookieCredential(r *http.Request) string {
|
||||
c, err := r.Cookie(tokenCookie)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(c.Value)
|
||||
}
|
||||
|
||||
// credential resolves the caller's token from an explicit header first, then
|
||||
// from the remembered cookie. Upload additionally accepts a form field, which
|
||||
// takes precedence over both.
|
||||
func credential(r *http.Request) string {
|
||||
if t := bearer(r); t != "" {
|
||||
return t
|
||||
}
|
||||
return cookieCredential(r)
|
||||
}
|
||||
|
||||
// remember stores the token in a cookie.
|
||||
//
|
||||
// SameSite=Strict is what makes accepting a cookie as a credential safe here:
|
||||
// without it, any site could make the browser post an upload or a deletion with
|
||||
// the cookie attached. Scoping the path to the mount point keeps the credential
|
||||
// out of requests to the rest of the host when running under a subdirectory.
|
||||
func (s *Server) remember(w http.ResponseWriter, r *http.Request, token string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: tokenCookie,
|
||||
Value: token,
|
||||
Path: s.cfg.BasePath,
|
||||
MaxAge: int(rememberFor.Seconds()),
|
||||
HttpOnly: true,
|
||||
Secure: s.isHTTPS(r),
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
}
|
||||
|
||||
// forget clears a remembered token.
|
||||
func (s *Server) forget(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: tokenCookie,
|
||||
Value: "",
|
||||
Path: s.cfg.BasePath,
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: s.isHTTPS(r),
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
}
|
||||
|
||||
// isHTTPS decides whether the cookie may carry the Secure attribute. Setting it
|
||||
// on a plain-HTTP development server would stop the browser storing the cookie
|
||||
// at all, so it is only set when the connection is genuinely secure.
|
||||
func (s *Server) isHTTPS(r *http.Request) bool {
|
||||
if strings.HasPrefix(s.cfg.PublicURL, "https://") {
|
||||
return true // the operator said so, and they are behind the proxy
|
||||
}
|
||||
if r.TLS != nil {
|
||||
return true
|
||||
}
|
||||
// Believed only from a proxy that is trusted for forwarded headers at all.
|
||||
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
|
||||
if ip := net.ParseIP(host); ip != nil && s.cfg.TrustsProxy(ip) {
|
||||
return r.Header.Get("X-Forwarded-Proto") == "https"
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// handleForget drops the remembered token and returns to the upload page.
|
||||
func (s *Server) handleForget(w http.ResponseWriter, r *http.Request) {
|
||||
s.forget(w, r)
|
||||
if wantsJSON(r) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "forgotten"})
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, s.cfg.BasePath, http.StatusSeeOther)
|
||||
}
|
||||
+42
-14
@@ -26,24 +26,16 @@ func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
secret := bearer(r)
|
||||
if secret == "" {
|
||||
// A small form post; the 4 KiB cap keeps this from being a way to
|
||||
// stream a body into memory.
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxFieldBytes)
|
||||
if err := r.ParseForm(); err == nil {
|
||||
secret = strings.TrimSpace(r.PostFormValue("token"))
|
||||
}
|
||||
}
|
||||
if secret == "" {
|
||||
presented := s.deleteCredentials(w, r)
|
||||
if len(presented) == 0 {
|
||||
s.fail(w, r, http.StatusUnauthorized, "A delete token or an owning token is required.")
|
||||
return
|
||||
}
|
||||
|
||||
if !s.mayDelete(m, secret) {
|
||||
if !s.authorised(m, presented) {
|
||||
s.fail(w, r, http.StatusForbidden, "That token cannot delete this file.")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.store.Delete(id); err != nil {
|
||||
s.log.Error("deleting object", "id", id, "err", err)
|
||||
s.fail(w, r, http.StatusInternalServerError, "Could not delete the file.")
|
||||
@@ -62,8 +54,44 @@ func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// mayDelete checks the presented secret against the object's delete token
|
||||
// first, then against the token file.
|
||||
// deleteCredentials collects every secret the request carries.
|
||||
//
|
||||
// Three can legitimately arrive at once — the object's delete token in the
|
||||
// form, a token in the header, and a remembered token in the cookie — and any
|
||||
// one of them may be the sufficient one. They are all collected so that the
|
||||
// first one present cannot shadow the others.
|
||||
func (s *Server) deleteCredentials(w http.ResponseWriter, r *http.Request) []string {
|
||||
var out []string
|
||||
add := func(secret string) {
|
||||
if secret = strings.TrimSpace(secret); secret != "" {
|
||||
out = append(out, secret)
|
||||
}
|
||||
}
|
||||
|
||||
add(bearer(r))
|
||||
add(cookieCredential(r))
|
||||
|
||||
// A small form post; the cap keeps this from being a way to stream a body
|
||||
// into memory. A non-form body simply fails to parse and is ignored.
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxFieldBytes)
|
||||
if err := r.ParseForm(); err == nil {
|
||||
add(r.PostFormValue("token"))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// authorised reports whether any of the presented secrets may delete m.
|
||||
func (s *Server) authorised(m *store.Meta, presented []string) bool {
|
||||
for _, secret := range presented {
|
||||
if s.mayDelete(m, secret) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// mayDelete checks one secret against the object's delete token first, then
|
||||
// against the token file.
|
||||
func (s *Server) mayDelete(m *store.Meta, secret string) bool {
|
||||
if auth.EqualHash(m.DeleteHash, auth.HashSecret(secret)) {
|
||||
return true
|
||||
|
||||
@@ -49,23 +49,39 @@ type indexPage struct {
|
||||
MaxExpiry string
|
||||
DefaultExpiry string
|
||||
AbsBase string
|
||||
TokenName string // the remembered token's name, if there is one
|
||||
AllowVanity bool
|
||||
Stale bool // a remembered token that no longer exists
|
||||
}
|
||||
|
||||
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
// The page always renders the anonymous tier; the script refreshes it from
|
||||
// /api/limits once a token is entered.
|
||||
lim := auth.Anonymous(s.cfg)
|
||||
// A remembered token is resolved server-side, so the page can show the real
|
||||
// limits without the cookie ever being readable by a script.
|
||||
remembered := cookieCredential(r)
|
||||
lim, err := s.limitsFor(remembered)
|
||||
stale := false
|
||||
if err != nil {
|
||||
// The token was revoked or the file was edited; drop the cookie rather
|
||||
// than leave the caller wondering why uploads fail.
|
||||
s.forget(w, r)
|
||||
lim, stale = auth.Anonymous(s.cfg), true
|
||||
}
|
||||
|
||||
s.render(w, http.StatusOK, "index.html", indexPage{
|
||||
page: s.page("Upload", true),
|
||||
MaxSize: config.FormatSize(lim.MaxSize),
|
||||
MaxExpiry: config.FormatDuration(lim.MaxExpiry),
|
||||
DefaultExpiry: config.FormatDuration(lim.DefaultExpiry),
|
||||
AbsBase: s.absBase(r),
|
||||
TokenName: lim.Name,
|
||||
AllowVanity: lim.AllowVanity,
|
||||
Stale: stale,
|
||||
})
|
||||
}
|
||||
|
||||
type limitsJSON struct {
|
||||
Name string `json:"name"`
|
||||
Remembered bool `json:"remembered"`
|
||||
MaxSize *int64 `json:"max_size"` // null means unlimited
|
||||
MaxExpiry string `json:"max_expiry"`
|
||||
DefaultExpiry string `json:"default_expiry"`
|
||||
@@ -81,13 +97,14 @@ func (s *Server) handleLimits(w http.ResponseWriter, r *http.Request) {
|
||||
s.fail(w, r, http.StatusTooManyRequests, "Too many requests; try again shortly.")
|
||||
return
|
||||
}
|
||||
lim, err := s.limitsFor(bearer(r))
|
||||
lim, err := s.limitsFor(credential(r))
|
||||
if err != nil {
|
||||
s.fail(w, r, http.StatusUnauthorized, "Unrecognised token.")
|
||||
return
|
||||
}
|
||||
out := limitsJSON{
|
||||
Name: lim.Name,
|
||||
Remembered: lim.Name != "" && bearer(r) == "",
|
||||
MaxExpiry: config.FormatDuration(lim.MaxExpiry),
|
||||
DefaultExpiry: config.FormatDuration(lim.DefaultExpiry),
|
||||
AllowVanity: lim.AllowVanity,
|
||||
|
||||
@@ -62,6 +62,7 @@ func (s *Server) routes() http.Handler {
|
||||
mux.HandleFunc("GET /d/{id}", s.handleDownload)
|
||||
mux.HandleFunc("GET /i/{id}", s.handleInfo)
|
||||
mux.HandleFunc("POST /api/d/{id}/delete", s.handleDelete)
|
||||
mux.HandleFunc("POST /api/forget", s.handleForget)
|
||||
mux.Handle("GET /static/", http.StripPrefix("/static/", s.staticHandler()))
|
||||
mux.HandleFunc("/", s.handleNotFound)
|
||||
|
||||
@@ -94,8 +95,14 @@ func (s *Server) staticHandler() http.Handler {
|
||||
|
||||
// appCSP locks the application pages down to their own origin. The frontend has
|
||||
// no inline script and no third-party anything, so this can be strict.
|
||||
//
|
||||
// connect-src is not optional here: the upload page talks to /api/upload and
|
||||
// /api/limits over XMLHttpRequest, and every fetch-directive left unlisted
|
||||
// falls back to default-src, so omitting it makes the browser block every
|
||||
// upload before it reaches the network. See TestAppCSPAllowsWhatThePageDoes.
|
||||
const appCSP = "default-src 'none'; script-src 'self'; style-src 'self'; " +
|
||||
"img-src 'self' data:; form-action 'self'; base-uri 'none'; frame-ancestors 'none'"
|
||||
"img-src 'self' data:; connect-src 'self'; form-action 'self'; " +
|
||||
"base-uri 'none'; frame-ancestors 'none'"
|
||||
|
||||
func (s *Server) securityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -647,3 +647,313 @@ func assertNoDebris(t *testing.T, dir string) {
|
||||
t.Errorf("leftover object directory: %s", e.Name())
|
||||
}
|
||||
}
|
||||
|
||||
// --- remembered tokens ---------------------------------------------------
|
||||
|
||||
// A browser form post that carries a token and the remember box gets a cookie
|
||||
// back, and that cookie then authenticates later uploads on its own.
|
||||
func TestTokenIsRememberedInACookie(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
|
||||
resp := h.formUpload(t, map[string]string{"token": h.token, "remember": "1"}, "a.bin", "one")
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("status = %s", resp.Status)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
cookie := findCookie(resp, tokenCookie)
|
||||
if cookie == nil {
|
||||
t.Fatal("no token cookie was set")
|
||||
}
|
||||
if cookie.Value != h.token {
|
||||
t.Error("the cookie does not hold the token")
|
||||
}
|
||||
if !cookie.HttpOnly {
|
||||
t.Error("the token cookie is readable by scripts")
|
||||
}
|
||||
if cookie.SameSite != http.SameSiteStrictMode {
|
||||
t.Error("the token cookie is not SameSite=Strict, so it is CSRF-exposed")
|
||||
}
|
||||
|
||||
// The cookie alone is now enough to claim a vanity name, which anonymous
|
||||
// callers cannot do.
|
||||
req, _ := http.NewRequest("POST", h.ts.URL+"/api/upload", strings.NewReader("two"))
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Vanity", "remembered")
|
||||
req.AddCookie(cookie)
|
||||
resp, err := h.ts.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("upload with only the cookie: status = %s", resp.Status)
|
||||
}
|
||||
if res := decode[uploadResult](t, resp); res.ID != "remembered" {
|
||||
t.Errorf("id = %q, want remembered", res.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// A typed token wins over whatever the browser remembered.
|
||||
func TestExplicitTokenBeatsTheCookie(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
resp := h.formUpload(t, map[string]string{"token": h.token, "remember": "1"}, "a.bin", "x")
|
||||
cookie := findCookie(resp, tokenCookie)
|
||||
resp.Body.Close()
|
||||
|
||||
resp = h.formUploadWith(t, cookie, map[string]string{"token": h.admin, "remember": "1"}, "b.bin", "y")
|
||||
defer resp.Body.Close()
|
||||
res := decode[uploadResult](t, resp)
|
||||
|
||||
m, err := h.store.Get(res.ID, h.now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m.Owner != "boss" {
|
||||
t.Errorf("owner = %q, want boss: the cookie shadowed the typed token", m.Owner)
|
||||
}
|
||||
}
|
||||
|
||||
// Leaving the box unchecked clears a token the browser had remembered.
|
||||
func TestUncheckingRememberForgetsTheCookie(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
resp := h.formUpload(t, map[string]string{"token": h.token, "remember": "1"}, "a.bin", "x")
|
||||
cookie := findCookie(resp, tokenCookie)
|
||||
resp.Body.Close()
|
||||
|
||||
resp = h.formUploadWith(t, cookie, map[string]string{}, "b.bin", "y")
|
||||
defer resp.Body.Close()
|
||||
cleared := findCookie(resp, tokenCookie)
|
||||
if cleared == nil || cleared.MaxAge >= 0 {
|
||||
t.Fatalf("the cookie was not cleared: %v", cleared)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForgetEndpointClearsTheCookie(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
req, _ := http.NewRequest("POST", h.ts.URL+"/api/forget", nil)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.AddCookie(&http.Cookie{Name: tokenCookie, Value: h.token})
|
||||
resp, err := h.ts.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
c := findCookie(resp, tokenCookie)
|
||||
if c == nil || c.MaxAge >= 0 || c.Value != "" {
|
||||
t.Fatalf("the cookie was not cleared: %v", c)
|
||||
}
|
||||
}
|
||||
|
||||
// A revoked token left in a cookie must not wedge the page.
|
||||
func TestStaleCookieIsDropped(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
req, _ := http.NewRequest("GET", h.ts.URL+"/", nil)
|
||||
req.AddCookie(&http.Cookie{Name: tokenCookie, Value: "a-token-that-was-revoked"})
|
||||
resp, err := h.ts.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %s, want the page to still render", resp.Status)
|
||||
}
|
||||
if c := findCookie(resp, tokenCookie); c == nil || c.MaxAge >= 0 {
|
||||
t.Error("a stale cookie was not dropped")
|
||||
}
|
||||
page, _ := io.ReadAll(resp.Body)
|
||||
if strings.Contains(string(page), "Uploading as") {
|
||||
t.Error("the page claims an identity it could not resolve")
|
||||
}
|
||||
}
|
||||
|
||||
// The index page resolves a remembered token server-side, so the limits shown
|
||||
// are the caller's real ones even though the cookie is unreadable by script.
|
||||
func TestIndexShowsTheRememberedIdentity(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
req, _ := http.NewRequest("GET", h.ts.URL+"/", nil)
|
||||
req.AddCookie(&http.Cookie{Name: tokenCookie, Value: h.token})
|
||||
resp, err := h.ts.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
page, _ := io.ReadAll(resp.Body)
|
||||
if !strings.Contains(string(page), "Uploading as <strong>friend</strong>") {
|
||||
t.Error("the page does not show the remembered identity")
|
||||
}
|
||||
if strings.Contains(string(page), h.token) {
|
||||
t.Error("the page echoes the token back into the HTML")
|
||||
}
|
||||
}
|
||||
|
||||
// The per-object delete token must still work when a cookie is also present.
|
||||
func TestCookieDoesNotShadowTheDeleteToken(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
// Uploaded anonymously, so the remembered token owns nothing here.
|
||||
res := decode[uploadResult](t, h.upload(t, []byte("x"), nil))
|
||||
|
||||
form := strings.NewReader("token=" + res.DeleteToken)
|
||||
req, _ := http.NewRequest("POST", h.ts.URL+"/api/d/"+res.ID+"/delete", form)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.AddCookie(&http.Cookie{Name: tokenCookie, Value: h.token})
|
||||
resp, err := h.ts.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %s, want 200: the cookie shadowed the delete token", resp.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// An API caller sending a bearer token manages its own credentials and should
|
||||
// not be handed a cookie it never asked for.
|
||||
func TestBearerCallersAreNotGivenACookie(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
resp := h.upload(t, []byte("x"), map[string]string{"Authorization": "Bearer " + h.token})
|
||||
defer resp.Body.Close()
|
||||
if c := findCookie(resp, tokenCookie); c != nil {
|
||||
t.Errorf("a cookie was set for a bearer-token upload: %v", c)
|
||||
}
|
||||
}
|
||||
|
||||
func findCookie(resp *http.Response, name string) *http.Cookie {
|
||||
for _, c := range resp.Cookies() {
|
||||
if c.Name == name {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// formUpload posts the multipart form the browser would, with fields ordered
|
||||
// ahead of the file part.
|
||||
func (h *harness) formUpload(t *testing.T, fields map[string]string, filename, content string) *http.Response {
|
||||
t.Helper()
|
||||
return h.formUploadWith(t, nil, fields, filename, content)
|
||||
}
|
||||
|
||||
func (h *harness) formUploadWith(t *testing.T, cookie *http.Cookie, fields map[string]string, filename, content string) *http.Response {
|
||||
t.Helper()
|
||||
var body bytes.Buffer
|
||||
mw := multipart.NewWriter(&body)
|
||||
for k, v := range fields {
|
||||
mw.WriteField(k, v)
|
||||
}
|
||||
fw, err := mw.CreateFormFile("file", filename)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fw.Write([]byte(content))
|
||||
mw.Close()
|
||||
|
||||
req, _ := http.NewRequest("POST", h.ts.URL+"/api/upload", &body)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if cookie != nil {
|
||||
req.AddCookie(cookie)
|
||||
}
|
||||
resp, err := h.ts.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// --- content security policy ---------------------------------------------
|
||||
|
||||
// The page's own behaviour and its CSP have to agree, and nothing in a Go test
|
||||
// or a curl invocation enforces CSP — only a browser does. This reads the
|
||||
// script that is actually shipped, works out which fetch directives the page
|
||||
// needs, and checks the policy grants them.
|
||||
//
|
||||
// It exists because omitting connect-src once made the browser block every
|
||||
// upload while every server-side test still passed.
|
||||
func TestAppCSPAllowsWhatThePageDoes(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
|
||||
resp, err := h.ts.Client().Get(h.ts.URL + "/static/app.js")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
script, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Which directive each capability the script might use depends on. Every
|
||||
// fetch directive falls back to default-src when unlisted, and default-src
|
||||
// here is 'none', so anything the script does must be granted explicitly.
|
||||
needs := []struct {
|
||||
directive string
|
||||
used bool
|
||||
because string
|
||||
}{
|
||||
{"connect-src", bytes.Contains(script, []byte("XMLHttpRequest")) ||
|
||||
bytes.Contains(script, []byte("fetch(")), "the page makes XHR or fetch calls"},
|
||||
{"script-src", true, "the page loads an external script"},
|
||||
{"style-src", true, "the page loads an external stylesheet"},
|
||||
{"form-action", true, "the page posts a form"},
|
||||
}
|
||||
|
||||
page, err := h.ts.Client().Get(h.ts.URL + "/")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
page.Body.Close()
|
||||
csp := page.Header.Get("Content-Security-Policy")
|
||||
if csp == "" {
|
||||
t.Fatal("the upload page carries no Content-Security-Policy")
|
||||
}
|
||||
|
||||
directives := map[string]string{}
|
||||
for _, d := range strings.Split(csp, ";") {
|
||||
name, value, _ := strings.Cut(strings.TrimSpace(d), " ")
|
||||
directives[strings.ToLower(name)] = strings.TrimSpace(value)
|
||||
}
|
||||
if directives["default-src"] != "'none'" {
|
||||
t.Errorf("default-src = %q, want 'none': the checks below assume it denies by default",
|
||||
directives["default-src"])
|
||||
}
|
||||
|
||||
for _, n := range needs {
|
||||
if !n.used {
|
||||
continue
|
||||
}
|
||||
value, ok := directives[n.directive]
|
||||
if !ok {
|
||||
t.Errorf("CSP has no %s, but %s; the browser will fall back to default-src and block it",
|
||||
n.directive, n.because)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(value, "'self'") {
|
||||
t.Errorf("CSP %s = %q, which does not allow this origin, but %s",
|
||||
n.directive, value, n.because)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The download policy is the opposite case: it must stay maximally restrictive,
|
||||
// since it governs bytes a stranger uploaded.
|
||||
func TestDownloadCSPStaysInert(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
res := decode[uploadResult](t, h.upload(t, []byte("<script>alert(1)</script>"), nil))
|
||||
|
||||
get, err := h.ts.Client().Get(h.ts.URL + "/d/" + res.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
get.Body.Close()
|
||||
|
||||
csp := get.Header.Get("Content-Security-Policy")
|
||||
if !strings.Contains(csp, "default-src 'none'") || !strings.Contains(csp, "sandbox") {
|
||||
t.Errorf("download CSP = %q, want default-src 'none' and sandbox", csp)
|
||||
}
|
||||
for _, forbidden := range []string{"connect-src", "script-src 'self'", "'unsafe-inline'"} {
|
||||
if strings.Contains(csp, forbidden) {
|
||||
t.Errorf("download CSP contains %q; uploaded bytes must be granted nothing", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,12 @@ type uploadRequest struct {
|
||||
vanity string
|
||||
expiry string
|
||||
filename string
|
||||
|
||||
// remember is set by the form's checkbox. It decides whether a token used
|
||||
// here is stored in a cookie for next time, and unchecking it is how a
|
||||
// remembered token is cleared from the upload page itself.
|
||||
remember bool
|
||||
explicit bool // the token was typed or sent, not read back from the cookie
|
||||
}
|
||||
|
||||
func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -75,6 +81,10 @@ func (s *Server) uploadRaw(w http.ResponseWriter, r *http.Request, ip string) {
|
||||
expiry: strings.TrimSpace(r.Header.Get("Expiry")),
|
||||
filename: filenameFromDisposition(r.Header.Get("Content-Disposition")),
|
||||
}
|
||||
req.explicit = req.token != ""
|
||||
if req.token == "" {
|
||||
req.token = cookieCredential(r)
|
||||
}
|
||||
s.storeUpload(w, r, req, r.Body, ip)
|
||||
}
|
||||
|
||||
@@ -92,6 +102,7 @@ func (s *Server) uploadMultipart(w http.ResponseWriter, r *http.Request, boundar
|
||||
}
|
||||
mr := multipart.NewReader(r.Body, boundary)
|
||||
req := uploadRequest{token: bearer(r)}
|
||||
req.explicit = req.token != ""
|
||||
|
||||
for n := 0; ; n++ {
|
||||
if n > maxFieldCount {
|
||||
@@ -112,6 +123,12 @@ func (s *Server) uploadMultipart(w http.ResponseWriter, r *http.Request, boundar
|
||||
if req.filename == "" {
|
||||
req.filename = part.FileName()
|
||||
}
|
||||
// Nothing explicit was supplied, so fall back to what the browser
|
||||
// remembered. This happens after the fields precisely so a typed
|
||||
// token still wins.
|
||||
if req.token == "" {
|
||||
req.token = cookieCredential(r)
|
||||
}
|
||||
s.storeUpload(w, r, req, part, ip)
|
||||
return
|
||||
}
|
||||
@@ -124,9 +141,11 @@ func (s *Server) uploadMultipart(w http.ResponseWriter, r *http.Request, boundar
|
||||
}
|
||||
switch part.FormName() {
|
||||
case "token":
|
||||
if value != "" {
|
||||
req.token = value
|
||||
if v := strings.TrimSpace(value); v != "" {
|
||||
req.token, req.explicit = v, true
|
||||
}
|
||||
case "remember":
|
||||
req.remember = true
|
||||
case "vanity":
|
||||
req.vanity = strings.TrimSpace(value)
|
||||
case "expiry":
|
||||
@@ -250,11 +269,28 @@ func (s *Server) storeUpload(w http.ResponseWriter, r *http.Request, req uploadR
|
||||
}
|
||||
committed = true
|
||||
|
||||
s.updateRemembered(w, r, req, lim)
|
||||
|
||||
s.log.Info("stored", "id", m.ID, "bytes", m.Size, "owner", orAnonymous(lim.Name),
|
||||
"ip", ip, "expires", m.Expires)
|
||||
s.respondUploaded(w, r, m, secret)
|
||||
}
|
||||
|
||||
// updateRemembered applies the form's "remember" checkbox to the cookie. It
|
||||
// only ever acts on a browser form post: an API caller sending a bearer token
|
||||
// has its own way of keeping credentials and should not be handed a cookie.
|
||||
func (s *Server) updateRemembered(w http.ResponseWriter, r *http.Request, req uploadRequest, lim auth.Limits) {
|
||||
if bearer(r) != "" {
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case req.remember && req.explicit && lim.Name != "":
|
||||
s.remember(w, r, req.token)
|
||||
case !req.remember && cookieCredential(r) != "":
|
||||
s.forget(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func orAnonymous(name string) string {
|
||||
if name == "" {
|
||||
return "(anonymous)"
|
||||
|
||||
Reference in New Issue
Block a user