package web import ( "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "net/url" "strings" "testing" "github.com/gramanas/blogspace/internal/auth" "github.com/gramanas/blogspace/internal/config" "github.com/gramanas/blogspace/internal/store" ) func TestFileType(t *testing.T) { png := []byte("\x89PNG\r\n\x1a\n" + strings.Repeat("\x00", 16)) cases := []struct { head string name string ct, kind string }{ {string(png), "photo.png", "image/png", "image"}, {string(png), "evil.html", "image/png", "image"}, // bytes win over the name {"\x00\x00\x01\x00\x01\x00", "icon.ico", "image/x-icon", "image"}, {"%PDF-1.4 ...", "paper.pdf", "application/pdf", "document"}, {"PK\x03\x04junk", "site.zip", "application/zip", "archive"}, {"\x1f\x8b\x08junk", "site.tar.gz", "application/x-gzip", "archive"}, {"ID3\x03\x00\x00\x00", "song.mp3", "audio/mpeg", "audio"}, {"OggS\x00\x02", "song.ogg", "audio/ogg", "audio"}, {"OggS\x00\x02", "clip.ogv", "video/ogg", "video"}, {"\x1a\x45\xdf\xa3junk", "clip.webm", "video/webm", "video"}, {"hello world", "notes.txt", "text/plain", "document"}, {"hello world", "server.log", "text/plain", "document"}, // text/* names only confirm the sniff {"hello world", "notes.md", "text/plain", "document"}, {"hello world", "README", "text/plain", "document"}, // no extension: the bytes are all we have {"\x00\x01\x02\x03\xff\xfe", "font.ttf", "application/octet-stream", "other"}, // a font is known by its bytes {"wOF2\x00\x01\x00\x00", "body.woff2", "font/woff2", "font"}, {"wOFF\x00\x01\x00\x00", "body.woff", "font/woff", "font"}, {"\x00\x01\x00\x00\x00\x10", "body.ttf", "font/ttf", "font"}, {"OTTO\x00\x10\x00\x80", "body.otf", "font/otf", "font"}, {"OTTO\x00\x10\x00\x80", "evil.html", "font/otf", "font"}, {"ttcf\x00\x01\x00\x00", "family.ttc", "application/octet-stream", "other"}, {"hello world", "data.csv", "text/csv", "document"}, {"hello world", "evil.html", "application/octet-stream", "other"}, {"", "evil.svg", "application/octet-stream", "other"}, {"", "pic.svg", "application/octet-stream", "other"}, {"", "page.html", "application/octet-stream", "other"}, {"alert(1)", "x.js", "application/octet-stream", "other"}, {"\x00\x01\x02\x03\xff\xfe", "song.mp3", "audio/mpeg", "audio"}, {"\x00\x01\x02\x03\xff\xfe", "tool.exe", "application/octet-stream", "other"}, {"\x00\x01\x02\x03\xff\xfe", "book.epub", "application/epub+zip", "document"}, {"\x00\x01\x02\x03\xff\xfe", "noext", "application/octet-stream", "other"}, } for _, c := range cases { ct, kind := fileType([]byte(c.head), c.name) if ct != c.ct || kind != c.kind { t.Errorf("fileType(%q, %q) = %s, %s; want %s, %s", c.head[:min(8, len(c.head))], c.name, ct, kind, c.ct, c.kind) } } } func TestServedAs(t *testing.T) { cases := []struct { ct string download bool ctype string disp string }{ {"image/png", false, "image/png", "inline"}, {"image/png", true, "image/png", "attachment"}, {"application/pdf", false, "application/pdf", "inline"}, {"text/plain", false, "text/plain; charset=utf-8", "inline"}, {"audio/mpeg", false, "audio/mpeg", "inline"}, {"video/mp4", false, "video/mp4", "inline"}, {"font/woff2", false, "font/woff2", "inline"}, {"application/zip", false, "application/octet-stream", "attachment"}, {"text/html", false, "application/octet-stream", "attachment"}, {"image/svg+xml", false, "application/octet-stream", "attachment"}, {"application/javascript", true, "application/octet-stream", "attachment"}, } for _, c := range cases { ctype, disp := servedAs(c.ct, c.download) if ctype != c.ctype || disp != c.disp { t.Errorf("servedAs(%s, %v) = %s, %s; want %s, %s", c.ct, c.download, ctype, disp, c.ctype, c.disp) } } } func TestContentDisposition(t *testing.T) { if got := contentDisposition("inline", "a.pdf"); got != `inline; filename="a.pdf"; filename*=utf-8''a.pdf` { t.Errorf("ascii: %s", got) } got := contentDisposition("attachment", `έγγρα"φο.pdf`) if !strings.HasPrefix(got, `attachment; filename="________.pdf"; filename*=utf-8''%CE%AD`) { t.Errorf("greek: %s", got) } } func TestCleanFilename(t *testing.T) { cases := map[string]string{ `C:\Users\me\photo.png`: "photo.png", "../../etc/passwd": "passwd", " spaced .txt ": "spaced .txt", "": "file", ".": "file", "/": "file", "a\"b'c\x00d.txt": "abcd.txt", "φωτογραφία.jpg": "φωτογραφία.jpg", } for in, want := range cases { if got := cleanFilename(in); got != want { t.Errorf("cleanFilename(%q) = %q, want %q", in, got, want) } } if got := cleanFilename(strings.Repeat("α", 200)); len([]rune(got)) != 120 { t.Errorf("long name not capped: %d runes", len([]rune(got))) } } func TestHumanSize(t *testing.T) { cases := map[int64]string{0: "1 KB", 100: "1 KB", 512 << 10: "512 KB", 1 << 20: "1 MB", 1536 << 10: "1.5 MB", 10 << 20: "10 MB"} for in, want := range cases { if got := humanSize(in); got != want { t.Errorf("humanSize(%d) = %q, want %q", in, got, want) } } } func TestPageBounds(t *testing.T) { cases := []struct{ total, per, n, offset, page, last int }{ {0, 50, 1, 0, 1, 1}, {0, 50, 7, 0, 1, 1}, {50, 50, 2, 0, 1, 1}, {120, 50, 3, 100, 3, 3}, {120, 50, 9, 100, 3, 3}, {120, 50, 0, 0, 1, 3}, {120, 50, 2, 50, 2, 3}, } for _, c := range cases { o, p, l := pageBounds(c.total, c.per, c.n) if o != c.offset || p != c.page || l != c.last { t.Errorf("pageBounds(%d,%d,%d) = %d,%d,%d; want %d,%d,%d", c.total, c.per, c.n, o, p, l, c.offset, c.page, c.last) } } } func TestFileBadge(t *testing.T) { for name, want := range map[string]string{"a.pdf": "PDF", "site.tar.gz": "GZ", "README": "FILE", "x.verylongext": "FILE", "α.ΈΓΓΡΑΦΟ": "FILE", "n.txt": "TXT"} { if got := (store.File{Filename: name}).Badge(); got != want { t.Errorf("Badge(%q) = %q, want %q", name, got, want) } } } // guardPOST runs before any store access, so it can be exercised with a nil store. func TestGuardPOST(t *testing.T) { secret := []byte("test-secret") s := NewServer(&config.Config{BaseDomain: "example.com", JWTSecret: secret, MaxUploadBytes: 1 << 20}, nil) u := &store.User{ID: 1} post := func(body string, accept string) (*httptest.ResponseRecorder, *http.Request) { r := httptest.NewRequest("POST", "/b/alice/files/upload", strings.NewReader(body)) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") if accept != "" { r.Header.Set("Accept", accept) } r = withLang(r.WithContext(context.WithValue(r.Context(), ctxUser, u)), "en") return httptest.NewRecorder(), r } token := auth.CSRFToken(secret, u.ID, u.TokenVersion) // over the cap (limit + 1 MB overhead) w, r := post("x="+strings.Repeat("a", 2<<20+10), "") if s.guardPOST(w, r, 1<<20) || w.Code != http.StatusRequestEntityTooLarge || !strings.Contains(w.Body.String(), "1 MB") { t.Errorf("too big: code %d body %q", w.Code, w.Body.String()) } w, r = post("x="+strings.Repeat("a", 2<<20+10), "application/json") var j map[string]string if s.guardPOST(w, r, 1<<20) || w.Code != http.StatusRequestEntityTooLarge || json.NewDecoder(w.Body).Decode(&j) != nil || j["error"] == "" { t.Errorf("too big (json): code %d", w.Code) } // a bigger limit lets the same body through (CSRF aside) w, r = post("x="+strings.Repeat("a", 2<<20+10)+"&_csrf="+url.QueryEscape(token), "") if !s.guardPOST(w, r, 4<<20) { t.Errorf("under a 4 MB limit: code %d", w.Code) } // missing / valid token w, r = post("x=1", "") if s.guardPOST(w, r, 1<<20) || w.Code != http.StatusForbidden { t.Errorf("missing csrf: code %d", w.Code) } w, r = post("x=1&_csrf="+url.QueryEscape(token), "") if !s.guardPOST(w, r, 1<<20) || r.FormValue("x") != "1" { t.Errorf("valid token: code %d", w.Code) } // GET is never touched r = httptest.NewRequest("GET", "/b/alice/files", nil) if !s.guardPOST(httptest.NewRecorder(), r, 0) { t.Error("GET should pass") } _ = bytes.MinRead }