From 24593cdb9ddc071e1696ff56c0c464f71be1deeb Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Mon, 5 Oct 2026 03:09:46 +0200 Subject: [PATCH] pdf: forms read again are bounded per document A Form XObject that drew the next one many times, level under level, was read (draws ^ depth) times: a few kilobytes asked for an unbounded reading, and the page text grew with every leaf (D2 of the document layer security review). The forms' text cannot be read faithfully; it is exponential in the file. The first reading of each form in a document stays whole. Reading one again (the same content drawn elsewhere: a logo, a header on every page) is counted in decoded bytes; beyond 256 MiB of such re-readings the document reads no form again, and each skip is counted (form_rereads_cut), as a decoded stream above 512 MiB already is. Real documents re-read small forms: the 749-PDF dev corpus extracts byte-identically as before (720 of 720 documents, 1,492 of 1,492 pages; flattened copies too). Reading each form only once per page was tried first and changed one real page, so it was not taken. Test: pdf_extract_form_ladder_bounded: seven levels of forms drawing the next four times each, 64 KiB per form: re-readings are cut and the page keeps its glyphs bounded (with the bound reverted nothing is cut). Signed-off-by: Martin Vogel --- internal/cbm/pdf/pdf.h | 1 + internal/cbm/pdf/pdf_internal.h | 7 ++++ internal/cbm/pdf/pdf_text.c | 14 +++++++ tests/test_doc_links_pdf.c | 67 +++++++++++++++++++++++++++++++++ 4 files changed, 89 insertions(+) diff --git a/internal/cbm/pdf/pdf.h b/internal/cbm/pdf/pdf.h index 4cfe1b4da..599a7b704 100644 --- a/internal/cbm/pdf/pdf.h +++ b/internal/cbm/pdf/pdf.h @@ -40,6 +40,7 @@ typedef struct { int stream_length_recovered; /* a stream's /Length was wrong */ int objstm_nested; /* opening an object stream needed an object of one */ int xref_streams; /* cross-reference streams decoded */ + int form_rereads_cut; /* a form not read again: re-readings past 256 MiB */ int flate_recovered; /* a damaged Flate stream decoded in part */ int stream_guard_hits; /* a decoded stream cut at 512 MiB */ int filters_unsupported; /* a stream with a filter this reader does not decode */ diff --git a/internal/cbm/pdf/pdf_internal.h b/internal/cbm/pdf/pdf_internal.h index 0b50b929b..95b94c4f6 100644 --- a/internal/cbm/pdf/pdf_internal.h +++ b/internal/cbm/pdf/pdf_internal.h @@ -71,6 +71,13 @@ enum { * no text layer, and real documents stay far below it. */ #define PDF_MAX_STREAM_OUT ((size_t)512 << 20) +/* A Form XObject's first reading in a document is always whole. Reading one + * again (the same content drawn elsewhere: a logo, a header) is counted, and + * beyond this many decoded bytes of such re-readings the document reads no + * form again (counted): forms drawing the next one many times, level under + * level, asked for (draws ^ depth) readings from a few kilobytes. */ +#define PDF_MAX_FORM_REREAD ((size_t)256 << 20) + /* ── Integers from the file: saturated, never undefined ──────────── */ static inline int64_t pdf_sat_add(int64_t a, int64_t b) { diff --git a/internal/cbm/pdf/pdf_text.c b/internal/cbm/pdf/pdf_text.c index 7ccebddce..dcd361b09 100644 --- a/internal/cbm/pdf/pdf_text.c +++ b/internal/cbm/pdf/pdf_text.c @@ -359,6 +359,8 @@ typedef struct { pdf_out_t out; /* the page being read */ pdf_formkey_t forms[PDF_MAX_FORM_DEPTH + 2]; int nforms; + pdf_map_t forms_read; /* the forms the document has read once */ + size_t reread; /* decoded bytes of forms read again */ pdf_glyph_out_t *gbuf; size_t gcap; } pdf_interp_t; @@ -484,6 +486,17 @@ static bool do_xobject(pdf_interp_t *it, pdf_val_t *res, const pdf_val_t *name, if (!data || !dlen) { return true; } + uint64_t once = key.key << 1 | (key.is_ref ? 1U : 0U); + if (pdf_map_get(&it->forms_read, once) >= 0) { + if (dlen > PDF_MAX_FORM_REREAD - it->reread) { + d->counts->form_rereads_cut++; + return true; + } + it->reread += dlen; + } else if (!pdf_map_put(&it->forms_read, once, 1)) { + d->nomem = true; + return false; + } pdf_val_t *mat = pdf_dget(d, xo->stream->dict, "Matrix"); double m[6]; memcpy(m, IDENT, sizeof(m)); @@ -1025,6 +1038,7 @@ bool cbm_pdf_extract(const unsigned char *data, size_t len, CBMArena *out, cbm_p res->npages = i + 1; } cbm_free(CBM_MEM_CLASS_EXTRACT, it.gbuf); + pdf_map_free(&it.forms_read); cbm_free(CBM_MEM_CLASS_EXTRACT, pl.v); bool nomem = d.nomem; pdf_doc_close(&d); diff --git a/tests/test_doc_links_pdf.c b/tests/test_doc_links_pdf.c index bef150442..66a6c47c1 100644 --- a/tests/test_doc_links_pdf.c +++ b/tests/test_doc_links_pdf.c @@ -707,6 +707,72 @@ static unsigned char *tp_xrefstm_sections(int sections, size_t *out_len) { return b.p; } +/* A page drawing form 1, form k drawing form k+1 `fan` times, `depth` forms, + * each padded with a 64 KiB comment; the last one shows "x". */ +static unsigned char *tp_form_ladder(int depth, int fan, size_t *out_len) { + int n = 5 + depth; + tp_obj_t *o = (tp_obj_t *)calloc((size_t)n, sizeof(tp_obj_t)); + char **dicts = (char **)calloc((size_t)n, sizeof(char *)); + tp_buf_t *bodies = (tp_buf_t *)calloc((size_t)n, sizeof(tp_buf_t)); + o[0] = (tp_obj_t){TP_CATALOG, NULL, 0}; + o[1] = (tp_obj_t){"<< /Type /Pages /Kids [3 0 R] /Count 1 >>", NULL, 0}; + o[2] = (tp_obj_t){"<< /Type /Page /Parent 2 0 R /Resources << /XObject << /F 6 0 R >> >> " + "/Contents 5 0 R >>", + NULL, 0}; + o[3] = (tp_obj_t){TP_HELV, NULL, 0}; + o[4] = (tp_obj_t){"", "q /F Do Q", 9}; + char *pad = (char *)malloc(64 * 1024 + 3); + pad[0] = '%'; + memset(pad + 1, 'x', 64 * 1024); + pad[64 * 1024 + 1] = '\n'; + pad[64 * 1024 + 2] = 0; + for (int k = 0; k < depth; k++) { + int i = 5 + k; /* object i + 1 */ + dicts[i] = (char *)malloc(256); + snprintf(dicts[i], 256, + "/Type /XObject /Subtype /Form /BBox [0 0 612 792] /Resources << /XObject << /F " + "%d 0 R >> /Font << /F1 4 0 R >> >>", + i + 2); + tp_puts(&bodies[i], pad); + if (k + 1 < depth) { + for (int f = 0; f < fan; f++) { + tp_puts(&bodies[i], "q /F Do Q\n"); + } + } else { + tp_puts(&bodies[i], "BT /F1 12 Tf 72 700 Td (x) Tj ET"); + } + o[i] = (tp_obj_t){dicts[i], bodies[i].p, bodies[i].n}; + } + unsigned char *pdf = tp_pdf(o, n, NULL, out_len); + for (int i = 0; i < n; i++) { + free(dicts[i]); + free(bodies[i].p); + } + free(pad); + free(bodies); + free(dicts); + free(o); + return pdf; +} + +/* Forms drawing the next one four times, seven levels: the first reading of + * each form is whole, readings again stop at PDF_MAX_FORM_REREAD (about 1.3 + * GB of form content read again before, and draws ^ depth in general). */ +TEST(pdf_extract_form_ladder_bounded) { + size_t len; + tp_text_t t; + unsigned char *pdf = tp_form_ladder(7, 4, &len); + ASSERT_TRUE(tp_extract(pdf, len, &t)); + ASSERT_EQ(t.r.status, CBM_PDF_OK); + ASSERT_EQ(t.r.npages, 1); + ASSERT_TRUE(t.r.counts.form_rereads_cut > 0); + ASSERT_TRUE(t.r.pages[0].glyphs >= 1); + ASSERT_TRUE((size_t)t.r.pages[0].glyphs <= (256U << 20) / (64U * 1024U) + 1); + cbm_arena_destroy(&t.arena); + free(pdf); + PASS(); +} + /* tp_simple's page, then `n` objects that each open a string and never close * it, and no cross-reference: the reader rebuilds it from the object headers. */ static unsigned char *tp_open_strings(int n, size_t *out_len) { @@ -1117,6 +1183,7 @@ SUITE(doc_links_pdf) { RUN_TEST(pdf_extract_object_chains); RUN_TEST(pdf_extract_xrefstm_once); RUN_TEST(pdf_extract_open_strings_linear); + RUN_TEST(pdf_extract_form_ladder_bounded); RUN_TEST(pdf_scan_mentions); RUN_TEST(pdf_links_pipeline); RUN_TEST(pdf_snippet_is_page_text);