This commit is contained in:
@@ -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
|
||||
|
||||
+57
-1
@@ -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":
|
||||
|
||||
+21
-1
@@ -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("<svg><script/></svg>"), "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))...)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user