handleRaw only streamed the blob behind an isImageMime gate (#221), so non-image attachment pastes fell through to empty row.Content and /raw served 0 bytes. Serve the blob for every attachment mime, passing the sniffed mime through serveContentType so active-content types (html, svg, xml) still serve as text/plain per the #34 rule. Regression tests cover text and html attachments (size, Content-Type, byte equality).
370 lines
12 KiB
Go
370 lines
12 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"palette/internal/store"
|
|
)
|
|
|
|
// multipartCreate posts a multipart create to the routes handler; extra
|
|
// fields are appended as text parts. Returns recorder and parsed response.
|
|
func multipartCreate(t *testing.T, h http.Handler, filename string, content []byte, fields map[string]string) (*httptest.ResponseRecorder, map[string]any) {
|
|
t.Helper()
|
|
var buf bytes.Buffer
|
|
mw := multipart.NewWriter(&buf)
|
|
if filename != "" {
|
|
fw, _ := mw.CreateFormFile("file", filename)
|
|
fw.Write(content)
|
|
}
|
|
for k, v := range fields {
|
|
mw.WriteField(k, v)
|
|
}
|
|
mw.Close()
|
|
req := httptest.NewRequest("POST", "/api/pastes", &buf)
|
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
var resp map[string]any
|
|
json.Unmarshal(rec.Body.Bytes(), &resp)
|
|
return rec, resp
|
|
}
|
|
|
|
func TestMultipartAttachmentCreateAndServe(t *testing.T) {
|
|
s := testServer(t)
|
|
h := s.routes()
|
|
|
|
png := append([]byte("\x89PNG\r\n\x1a\n"), bytes.Repeat([]byte{0, 1, 2, 3}, 32)...)
|
|
rec, resp := multipartCreate(t, h, "shot.png", png, map[string]string{"title": "with file"})
|
|
if rec.Code != 201 {
|
|
t.Fatalf("create: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
att, _ := resp["attachment"].(map[string]any)
|
|
if att == nil {
|
|
t.Fatalf("no attachment in response: %v", resp)
|
|
}
|
|
id, _ := att["id"].(string)
|
|
url, _ := att["url"].(string)
|
|
if url != "/f/"+id+"/shot.png" {
|
|
t.Fatalf("attachment url = %q", url)
|
|
}
|
|
if att["mime"] != "image/png" {
|
|
t.Fatalf("sniffed mime = %v want image/png", att["mime"])
|
|
}
|
|
|
|
// serve: image mime -> inline, nosniff, stored bytes
|
|
req := httptest.NewRequest("GET", url, nil)
|
|
rec2 := httptest.NewRecorder()
|
|
h.ServeHTTP(rec2, req)
|
|
if rec2.Code != 200 {
|
|
t.Fatalf("serve: %d %s", rec2.Code, rec2.Body.String())
|
|
}
|
|
if got := rec2.Header().Get("Content-Type"); got != "image/png" {
|
|
t.Fatalf("Content-Type = %q", got)
|
|
}
|
|
if got := rec2.Header().Get("X-Content-Type-Options"); got != "nosniff" {
|
|
t.Fatalf("nosniff = %q", got)
|
|
}
|
|
if got := rec2.Header().Get("Content-Disposition"); !strings.HasPrefix(got, "inline") {
|
|
t.Fatalf("Content-Disposition = %q", got)
|
|
}
|
|
if !bytes.Equal(rec2.Body.Bytes(), png) {
|
|
t.Fatal("served bytes differ from upload")
|
|
}
|
|
}
|
|
|
|
func TestMultipartFileReplacesText(t *testing.T) {
|
|
s := testServer(t)
|
|
h := s.routes()
|
|
rec, resp := multipartCreate(t, h, "notes.txt", []byte("file body"), map[string]string{"content": "some text"})
|
|
if rec.Code != 201 {
|
|
t.Fatalf("create: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
id, _ := resp["id"].(string)
|
|
req := httptest.NewRequest("GET", "/api/pastes/"+id, nil)
|
|
rec2 := httptest.NewRecorder()
|
|
h.ServeHTTP(rec2, req)
|
|
var got map[string]any
|
|
json.Unmarshal(rec2.Body.Bytes(), &got)
|
|
if got["content"] != "" {
|
|
t.Fatalf("content should be empty when file provided, got %v", got["content"])
|
|
}
|
|
}
|
|
|
|
func TestMultipartSecondFileRejected(t *testing.T) {
|
|
s := testServer(t)
|
|
h := s.routes()
|
|
var buf bytes.Buffer
|
|
mw := multipart.NewWriter(&buf)
|
|
for _, name := range []string{"a.txt", "b.txt"} {
|
|
fw, _ := mw.CreateFormFile("file", name)
|
|
fw.Write([]byte("x"))
|
|
}
|
|
mw.Close()
|
|
req := httptest.NewRequest("POST", "/api/pastes", &buf)
|
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
if rec.Code != 400 {
|
|
t.Fatalf("two files: got %d want 400", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "one_file_only") {
|
|
t.Fatalf("error code missing: %s", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestMultipartHtmlUploadServesAsPlainText(t *testing.T) {
|
|
s := testServer(t)
|
|
h := s.routes()
|
|
html := []byte("<html><script>alert(1)</script></html>")
|
|
rec, resp := multipartCreate(t, h, "page.html", html, nil)
|
|
if rec.Code != 201 {
|
|
t.Fatalf("create: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
url, _ := resp["attachment"].(map[string]any)["url"].(string)
|
|
req := httptest.NewRequest("GET", url, nil)
|
|
rec2 := httptest.NewRecorder()
|
|
h.ServeHTTP(rec2, req)
|
|
if got := rec2.Header().Get("Content-Type"); got != "text/plain; charset=utf-8" {
|
|
t.Fatalf("html served as %q, want text/plain", got)
|
|
}
|
|
if got := rec2.Header().Get("Content-Disposition"); !strings.HasPrefix(got, "attachment") {
|
|
t.Fatalf("html Content-Disposition = %q, want attachment", got)
|
|
}
|
|
}
|
|
|
|
func TestMultipartSvgUploadServesAsPlainText(t *testing.T) {
|
|
s := testServer(t)
|
|
h := s.routes()
|
|
svg := []byte(`<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>`)
|
|
rec, resp := multipartCreate(t, h, "evil.svg", svg, nil)
|
|
if rec.Code != 201 {
|
|
t.Fatalf("create: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
url, _ := resp["attachment"].(map[string]any)["url"].(string)
|
|
req := httptest.NewRequest("GET", url, nil)
|
|
rec2 := httptest.NewRecorder()
|
|
h.ServeHTTP(rec2, req)
|
|
ct := rec2.Header().Get("Content-Type")
|
|
if strings.Contains(ct, "svg") || strings.Contains(ct, "html") {
|
|
t.Fatalf("svg served as %q", ct)
|
|
}
|
|
if ct != "text/plain; charset=utf-8" {
|
|
t.Fatalf("svg Content-Type = %q", ct)
|
|
}
|
|
}
|
|
|
|
func TestMultipartClientMimeIgnored(t *testing.T) {
|
|
// client claims image/png; server must sniff the real type (text)
|
|
s := testServer(t)
|
|
h := s.routes()
|
|
var buf bytes.Buffer
|
|
mw := multipart.NewWriter(&buf)
|
|
fw, _ := mw.CreateFormFile("file", "fake.png")
|
|
fw.Write([]byte("just plain text, definitely not a png"))
|
|
// note: CreateFormFile sets Content-Type: application/octet-stream; the
|
|
// sniffed type for text content is text/plain either way.
|
|
mw.Close()
|
|
req := httptest.NewRequest("POST", "/api/pastes", &buf)
|
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
if rec.Code != 201 {
|
|
t.Fatalf("create: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
var resp map[string]any
|
|
json.Unmarshal(rec.Body.Bytes(), &resp)
|
|
att := resp["attachment"].(map[string]any)
|
|
if att["mime"] != "text/plain; charset=utf-8" && att["mime"] != "text/plain" {
|
|
t.Fatalf("mime = %v, want sniffed text/plain", att["mime"])
|
|
}
|
|
}
|
|
|
|
func TestMultipartOversizeRejected(t *testing.T) {
|
|
s := testServer(t)
|
|
h := s.routes()
|
|
big := bytes.Repeat([]byte("A"), MaxAttachmentBytes+1024)
|
|
rec, _ := multipartCreate(t, h, "big.bin", big, nil)
|
|
if rec.Code != http.StatusRequestEntityTooLarge {
|
|
t.Fatalf("oversize: got %d want 413", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestMultipartExactlyAtLimitAccepted(t *testing.T) {
|
|
s := testServer(t)
|
|
h := s.routes()
|
|
exact := bytes.Repeat([]byte("A"), MaxAttachmentBytes)
|
|
rec, resp := multipartCreate(t, h, "exact.bin", exact, nil)
|
|
if rec.Code != 201 {
|
|
t.Fatalf("at-limit: got %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
att := resp["attachment"].(map[string]any)
|
|
if att["size"].(float64) != float64(MaxAttachmentBytes) {
|
|
t.Fatalf("size = %v", att["size"])
|
|
}
|
|
}
|
|
|
|
func TestMultipartEmptyFileRejected(t *testing.T) {
|
|
s := testServer(t)
|
|
h := s.routes()
|
|
rec, _ := multipartCreate(t, h, "empty.txt", nil, nil)
|
|
if rec.Code != 400 {
|
|
t.Fatalf("empty file: got %d want 400", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestServeAttachment404Missing(t *testing.T) {
|
|
s := testServer(t)
|
|
h := s.routes()
|
|
req := httptest.NewRequest("GET", "/f/zzzzzzzz/nonexistent.txt", nil)
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
if rec.Code != 404 {
|
|
t.Fatalf("missing attachment: got %d want 404", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestServeAttachmentUnknownPaste404(t *testing.T) {
|
|
// attachment row referencing a paste that doesn't exist must 404, not leak
|
|
s := testServer(t)
|
|
h := s.routes()
|
|
s.store.CreatePaste(&store.Paste{Content: "x"})
|
|
att := store.Attachment{PasteID: "ghost00", Filename: "f.txt", Mime: "text/plain"}
|
|
blobs := s.store.Blobs()
|
|
if err := s.store.CreateAttachment(&att, strings.NewReader("hello"), blobs); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
req := httptest.NewRequest("GET", "/f/"+att.ID+"/f.txt", nil)
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
if rec.Code != 404 {
|
|
t.Fatalf("orphan attachment: got %d want 404", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestServeAttachmentPdfInline(t *testing.T) {
|
|
s := testServer(t)
|
|
h := s.routes()
|
|
pdf := []byte("%PDF-1.4\n%fake pdf body\n")
|
|
rec, resp := multipartCreate(t, h, "doc.pdf", pdf, nil)
|
|
if rec.Code != 201 {
|
|
t.Fatalf("create: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
url, _ := resp["attachment"].(map[string]any)["url"].(string)
|
|
req := httptest.NewRequest("GET", url, nil)
|
|
rec2 := httptest.NewRecorder()
|
|
h.ServeHTTP(rec2, req)
|
|
if got := rec2.Header().Get("Content-Type"); !strings.HasPrefix(got, "application/pdf") {
|
|
t.Fatalf("pdf Content-Type = %q", got)
|
|
}
|
|
if got := rec2.Header().Get("Content-Disposition"); !strings.HasPrefix(got, "inline") {
|
|
t.Fatalf("pdf Content-Disposition = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestServeAttachmentBurnedPaste404(t *testing.T) {
|
|
s := testServer(t)
|
|
h := s.routes()
|
|
rec, resp := multipartCreate(t, h, "burn.txt", []byte("burn me"), nil)
|
|
if rec.Code != 201 {
|
|
t.Fatalf("create: %d", rec.Code)
|
|
}
|
|
att := resp["attachment"].(map[string]any)
|
|
url, _ := att["url"].(string)
|
|
pid, _ := resp["id"].(string)
|
|
// burn the paste via API read (burn_after_read default off here, so force)
|
|
s.store.SoftDelete(pid)
|
|
req := httptest.NewRequest("GET", url, nil)
|
|
rec2 := httptest.NewRecorder()
|
|
h.ServeHTTP(rec2, req)
|
|
if rec2.Code != 404 {
|
|
t.Fatalf("deleted paste attachment: got %d want 404", rec2.Code)
|
|
}
|
|
}
|
|
|
|
func TestMultipartPasswordFieldAccepted(t *testing.T) {
|
|
s := testServer(t)
|
|
h := s.routes()
|
|
rec, resp := multipartCreate(t, h, "secret.txt", []byte("top secret"),
|
|
map[string]string{"password": "hunter2", "expires_in": "1h"})
|
|
if rec.Code != 201 {
|
|
t.Fatalf("create: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
if resp["attachment"] == nil {
|
|
t.Fatal("attachment missing")
|
|
}
|
|
id, _ := resp["id"].(string)
|
|
req := httptest.NewRequest("GET", "/api/pastes/"+id, nil)
|
|
rec2 := httptest.NewRecorder()
|
|
h.ServeHTTP(rec2, req)
|
|
if rec2.Code != 401 {
|
|
t.Fatalf("paste should require password, got %d", rec2.Code)
|
|
}
|
|
}
|
|
|
|
// #281: /raw/{id} must stream the attachment blob for ALL attachment mimes,
|
|
// not just raster images (the old isImageMime gate left non-image
|
|
// attachments serving an empty body from row.Content).
|
|
func TestRawStreamsNonImageAttachment(t *testing.T) {
|
|
s := testServer(t)
|
|
h := s.routes()
|
|
|
|
body := []byte("hello, this is a plain text attachment body")
|
|
rec, resp := multipartCreate(t, h, "notes.txt", body, nil)
|
|
if rec.Code != 201 {
|
|
t.Fatalf("create: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
if resp["attachment"] == nil {
|
|
t.Fatalf("no attachment in response: %v", resp)
|
|
}
|
|
id, _ := resp["id"].(string)
|
|
|
|
req := httptest.NewRequest("GET", "/raw/"+id, nil)
|
|
rec2 := httptest.NewRecorder()
|
|
h.ServeHTTP(rec2, req)
|
|
if rec2.Code != 200 {
|
|
t.Fatalf("raw: %d %s", rec2.Code, rec2.Body.String())
|
|
}
|
|
if got := rec2.Header().Get("Content-Type"); got != "text/plain; charset=utf-8" {
|
|
t.Fatalf("Content-Type = %q", got)
|
|
}
|
|
if got := rec2.Header().Get("X-Content-Type-Options"); got != "nosniff" {
|
|
t.Fatalf("nosniff = %q", got)
|
|
}
|
|
if !bytes.Equal(rec2.Body.Bytes(), body) {
|
|
t.Fatalf("raw bytes differ: got %d bytes want %d", rec2.Body.Len(), len(body))
|
|
}
|
|
}
|
|
|
|
// #281: active-content attachment types still get forced to text/plain on
|
|
// /raw, same rule as the /f/ serving path (#34).
|
|
func TestRawHtmlAttachmentServesAsPlainText(t *testing.T) {
|
|
s := testServer(t)
|
|
h := s.routes()
|
|
|
|
html := []byte("<html><body><script>alert(1)</script></body></html>")
|
|
rec, resp := multipartCreate(t, h, "page.html", html, nil)
|
|
if rec.Code != 201 {
|
|
t.Fatalf("create: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
id, _ := resp["id"].(string)
|
|
|
|
req := httptest.NewRequest("GET", "/raw/"+id, nil)
|
|
rec2 := httptest.NewRecorder()
|
|
h.ServeHTTP(rec2, req)
|
|
if rec2.Code != 200 {
|
|
t.Fatalf("raw: %d %s", rec2.Code, rec2.Body.String())
|
|
}
|
|
if got := rec2.Header().Get("Content-Type"); got != "text/plain; charset=utf-8" {
|
|
t.Fatalf("Content-Type = %q", got)
|
|
}
|
|
if !bytes.Equal(rec2.Body.Bytes(), html) {
|
|
t.Fatal("raw bytes differ from upload")
|
|
}
|
|
}
|