Reject malformed PDF media uploads
verify / verify (push) Successful in 3m38s

This commit is contained in:
2026-09-03 13:47:56 -04:00
parent 95d50f0888
commit d8b09c8ae5
4 changed files with 91 additions and 3 deletions
+9
View File
@@ -2,6 +2,15 @@
# Changelog # 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 ## v0.1.0-preview.13 — 2026-09-03
- Complete the password-plus-recovery-code flow with a short-lived restricted - Complete the password-plus-recovery-code flow with a short-lived restricted
+57 -1
View File
@@ -20,6 +20,7 @@ import (
"mime" "mime"
"net/http" "net/http"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"time" "time"
"unicode/utf8" "unicode/utf8"
@@ -121,7 +122,7 @@ func Prepare(reader io.Reader, originalName string, limits Limits) (Prepared, er
} }
detected := http.DetectContentType(data) 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 := Prepared{Data: append([]byte(nil), data...), MediaType: "application/pdf", Kind: KindAttachment, OriginalName: name}
result.Digest = sha256.Sum256(result.Data) result.Digest = sha256.Sum256(result.Data)
return result, nil return result, nil
@@ -156,6 +157,61 @@ func Prepare(reader io.Reader, originalName string, limits Limits) (Prepared, er
return result, nil 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 { func Extension(mediaType string) string {
switch mediaType { switch mediaType {
case "image/jpeg": case "image/jpeg":
+21 -1
View File
@@ -5,6 +5,7 @@ package media
import ( import (
"bytes" "bytes"
"errors" "errors"
"fmt"
"image" "image"
"image/color" "image/color"
"image/jpeg" "image/jpeg"
@@ -33,7 +34,8 @@ func TestPrepareReencodesRasterAndStripsTrailingData(t *testing.T) {
} }
func TestPreparePDFIsAttachment(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 { if err != nil {
t.Fatal(err) 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) { func TestPrepareRejectsActiveAndOversizedInput(t *testing.T) {
if _, err := Prepare(strings.NewReader("<svg><script/></svg>"), "bad.svg", Limits{}); !errors.Is(err, ErrInvalidMedia) { if _, err := Prepare(strings.NewReader("<svg><script/></svg>"), "bad.svg", Limits{}); !errors.Is(err, ErrInvalidMedia) {
t.Fatalf("svg err=%v", err) t.Fatalf("svg err=%v", err)
@@ -50,3 +64,9 @@ func TestPrepareRejectsActiveAndOversizedInput(t *testing.T) {
t.Fatalf("large err=%v", err) 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))...)
}
+4 -1
View File
@@ -5,6 +5,7 @@ package medialocal
import ( import (
"bytes" "bytes"
"errors" "errors"
"fmt"
"io" "io"
"os" "os"
"path/filepath" "path/filepath"
@@ -20,7 +21,9 @@ func TestStoreRoundTripAndIdempotentPut(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }