diff --git a/CHANGELOG.md b/CHANGELOG.md index 81048b8..05b2135 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ # Changelog +## v0.1.0-preview.14 — 2026-09-03 + +- Reject header-only, truncated, and structurally invalid PDF uploads in the + bounded media preparer. Accepted attachments now require a supported PDF + version, terminal EOF marker, numeric in-range `startxref`, and either a + traditional xref/trailer or xref-stream object at the declared offset. +- Keep PDF handling storage-neutral and non-rendering: applications still own + authorization, reference tracking, attachment disposition, and lifecycle. + ## v0.1.0-preview.13 — 2026-09-03 - Complete the password-plus-recovery-code flow with a short-lived restricted diff --git a/media/media.go b/media/media.go index 97ddfad..6b02881 100644 --- a/media/media.go +++ b/media/media.go @@ -20,6 +20,7 @@ import ( "mime" "net/http" "path/filepath" + "strconv" "strings" "time" "unicode/utf8" @@ -121,7 +122,7 @@ func Prepare(reader io.Reader, originalName string, limits Limits) (Prepared, er } detected := http.DetectContentType(data) - if detected == "application/pdf" && bytes.HasPrefix(data, []byte("%PDF-")) { + if detected == "application/pdf" && validPDF(data) { result := Prepared{Data: append([]byte(nil), data...), MediaType: "application/pdf", Kind: KindAttachment, OriginalName: name} result.Digest = sha256.Sum256(result.Data) return result, nil @@ -156,6 +157,61 @@ func Prepare(reader io.Reader, originalName string, limits Limits) (Prepared, er return result, nil } +// validPDF performs a deliberately bounded structural check without trying to +// render or interpret document content. It rejects header-only spoofing and +// truncated uploads by requiring a supported header, terminal EOF marker, a +// numeric startxref offset, and either a traditional xref table with trailer +// or an xref-stream object at that offset. +func validPDF(data []byte) bool { + if len(data) < 32 || !bytes.HasPrefix(data, []byte("%PDF-")) { + return false + } + headerEnd := bytes.IndexAny(data, "\r\n") + if headerEnd < 8 || headerEnd > 32 { + return false + } + header := string(bytes.TrimSpace(data[:headerEnd])) + if header != "%PDF-1.0" && header != "%PDF-1.1" && header != "%PDF-1.2" && header != "%PDF-1.3" && header != "%PDF-1.4" && header != "%PDF-1.5" && header != "%PDF-1.6" && header != "%PDF-1.7" && header != "%PDF-2.0" { + return false + } + trimmed := bytes.TrimRight(data, "\x00\t\n\f\r ") + if !bytes.HasSuffix(trimmed, []byte("%%EOF")) { + return false + } + eof := len(trimmed) - len("%%EOF") + start := bytes.LastIndex(trimmed[:eof], []byte("startxref")) + if start < headerEnd { + return false + } + cursor := start + len("startxref") + for cursor < eof && (trimmed[cursor] == ' ' || trimmed[cursor] == '\t' || trimmed[cursor] == '\r' || trimmed[cursor] == '\n' || trimmed[cursor] == '\f') { + cursor++ + } + digits := cursor + for cursor < eof && trimmed[cursor] >= '0' && trimmed[cursor] <= '9' && cursor-digits < 20 { + cursor++ + } + if cursor == digits { + return false + } + if len(bytes.TrimSpace(trimmed[cursor:eof])) != 0 { + return false + } + offset, err := strconv.ParseInt(string(trimmed[digits:cursor]), 10, 64) + if err != nil || offset < int64(headerEnd+1) || offset >= int64(start) { + return false + } + target := trimmed[int(offset):start] + if bytes.HasPrefix(target, []byte("xref")) { + return bytes.Contains(target, []byte("trailer")) + } + lineEnd := bytes.IndexByte(target, '\n') + if lineEnd < 5 || lineEnd > 80 || !bytes.Contains(target[:lineEnd], []byte(" obj")) { + return false + } + return bytes.Contains(target, []byte("/Type /XRef")) || bytes.Contains(target, []byte("/Type/XRef")) +} + func Extension(mediaType string) string { switch mediaType { case "image/jpeg": diff --git a/media/media_test.go b/media/media_test.go index e8dd9b5..1235d97 100644 --- a/media/media_test.go +++ b/media/media_test.go @@ -5,6 +5,7 @@ package media import ( "bytes" "errors" + "fmt" "image" "image/color" "image/jpeg" @@ -33,7 +34,8 @@ func TestPrepareReencodesRasterAndStripsTrailingData(t *testing.T) { } func TestPreparePDFIsAttachment(t *testing.T) { - prepared, err := Prepare(strings.NewReader("%PDF-1.7\nsmall fixture"), "guide.pdf", Limits{}) + pdf := minimalPDF() + prepared, err := Prepare(bytes.NewReader(pdf), "guide.pdf", Limits{}) if err != nil { t.Fatal(err) } @@ -42,6 +44,18 @@ func TestPreparePDFIsAttachment(t *testing.T) { } } +func TestPrepareRejectsMalformedPDF(t *testing.T) { + for _, source := range []string{ + "%PDF-1.7\nsmall fixture", + "%PDF-9.9\nxref\ntrailer\nstartxref\n9\n%%EOF", + "%PDF-1.7\nxref\ntrailer\nstartxref\n999999\n%%EOF", + } { + if _, err := Prepare(strings.NewReader(source), "broken.pdf", Limits{}); !errors.Is(err, ErrInvalidMedia) { + t.Fatalf("malformed PDF error=%v source=%q", err, source) + } + } +} + func TestPrepareRejectsActiveAndOversizedInput(t *testing.T) { if _, err := Prepare(strings.NewReader(""), "bad.svg", Limits{}); !errors.Is(err, ErrInvalidMedia) { t.Fatalf("svg err=%v", err) @@ -50,3 +64,9 @@ func TestPrepareRejectsActiveAndOversizedInput(t *testing.T) { t.Fatalf("large err=%v", err) } } + +func minimalPDF() []byte { + prefix := []byte("%PDF-1.7\n1 0 obj\n<< /Type /Catalog >>\nendobj\n") + offset := len(prefix) + return append(prefix, []byte(fmt.Sprintf("xref\n0 2\n0000000000 65535 f \n0000000009 00000 n \ntrailer\n<< /Size 2 /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n", offset))...) +} diff --git a/medialocal/store_test.go b/medialocal/store_test.go index 5866333..123789f 100644 --- a/medialocal/store_test.go +++ b/medialocal/store_test.go @@ -5,6 +5,7 @@ package medialocal import ( "bytes" "errors" + "fmt" "io" "os" "path/filepath" @@ -20,7 +21,9 @@ func TestStoreRoundTripAndIdempotentPut(t *testing.T) { if err != nil { t.Fatal(err) } - prepared, err := media.Prepare(bytes.NewReader([]byte("%PDF-1.7\nfixture")), "fixture.pdf", media.Limits{}) + prefix := []byte("%PDF-1.7\n1 0 obj\n<< /Type /Catalog >>\nendobj\n") + pdf := append(prefix, []byte(fmt.Sprintf("xref\n0 2\n0000000000 65535 f \n0000000009 00000 n \ntrailer\n<< /Size 2 /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n", len(prefix)))...) + prepared, err := media.Prepare(bytes.NewReader(pdf), "fixture.pdf", media.Limits{}) if err != nil { t.Fatal(err) }