Security¶
Only the latest release on PyPI receives security fixes.
Report a vulnerability¶
Report vulnerabilities privately through GitHub Security Advisories. Do not open a public issue. You should receive an initial response within one week.
Handle untrusted PDFs¶
pylopdf is written in Rust and has no mandatory Python dependencies, but hostile PDF input remains inherently risky.
Use a complete resource policy
Pass limits=pylopdf.DocumentLimits.web() to pylopdf.open(). The
preset is a conservative starting point for user uploads in memory-bounded
web and queue workers.
import pylopdf
try:
with pylopdf.open(
"upload.pdf",
limits=pylopdf.DocumentLimits.web(),
) as doc:
facts = doc.complexity
preview = doc[0].get_pixmap(dpi=144)
except pylopdf.LimitError as error:
reject_upload(error.code)
The web profile currently applies these independent budgets:
| Resource | Limit |
|---|---|
| Input file | 10 MiB |
| Pages | 200 |
| Indirect objects | 100,000 |
| Any decoded stream, including image RGBA estimates | 64 MiB |
| One page-content stream | 10 MiB |
| Cumulative decoded or estimated stream bytes | 128 MiB |
| Direct array/dictionary nesting | 64 |
| Cumulative UTF-8 glyph payload across interpreted pages | 1 MiB |
| Complete PDF snapshot passed to rendering/extraction | 64 MiB |
| Cumulative positioned glyph records across interpreted pages | 65,536 |
Create DocumentLimits(...) directly when the workload needs different
budgets. Every non-None value must be a positive integer.
max_decompressed_size= remains a compatible shorthand for its one
per-stream budget and cannot be combined with limits=.
All public page indexing uses one iterative page-tree walker, including page
count and lookup, complexity, render snapshots, TOC and links, annotations,
Form imports, and page-structure edits. It rejects reused Page/Pages objects
and cycles, indirect /Kids chains over 32 references, internal trees over 256
levels, and edge counts above the indirect-object count. max_pages traversal
stops at the first over-limit page. Opening without a page policy remains
compatible and defers these page-tree checks until the first operation that
indexes pages.
LimitError is a PdfError subclass. Its stable code is one of
file_size, page_count, object_count, object_depth,
decompressed_size, page_content_size, total_decompressed_size,
text_size, text_glyph_count, interpretation_size, embedded_file_size,
embedded_file_input_size, form_field_input_size, form_content_size,
annotation_input_size, metadata_input_size, toc_input_size,
page_label_input_size, xmp_metadata_size, render_output_size,
markdown_output_size, svg_output_size, replacement_input_size,
replacement_output_size, pdf_output_size, image_input_size,
image_pixel_count, font_input_size, text_input_size, text_line_count,
search_input_size, search_hit_count, password_input_size, pixmap_output_size,
ocr_model_size, ocr_dictionary_entries, stream_filter_count, or
decompression_unverifiable.
The same code is also
error.args[0]. A filter chain that cannot be bounded safely is rejected
instead of being decoded optimistically. Pylopdf-owned bounded lopdf decode
paths also reject more than 16 sequential /Filter layers before materializing
the filter list.
doc.complexity reports page, object, and stream counts, encoded stream bytes,
and maximum direct object depth. It neither decodes streams nor invokes the
renderer, so it is suitable for routing work before extraction. Structural and
decompression budgets validate the opened source; reopen generated output with
the same policy when it must cross another trust boundary. Direct-depth
validation and complexity inspection grow their iterative traversal stack
fallibly, so allocation refusal raises PdfError without partial facts.
max_interpretation_size applies when hayro first consumes retained input and
whenever pylopdf must serialize current state after editing, decryption, or
AcroForm state selection. The bounded writer refuses the crossing write and
does not install a partial renderer/extractor cache. Its default is None for
compatibility; DocumentLimits.web() sets 64 MiB.
When max_text_size is configured, plain-text extraction preflights exact
assembled UTF-8 size and caps one private batch at twice that glyph-payload
budget. Each non-empty glyph contributes at least one payload byte, while
inferred gaps plus line endings cannot outnumber glyphs. The batch accepts at
most 4,096 page entries, so repeated page numbers cannot bypass the policy.
Refusals retain text_size; the compatible default remains None.
max_text_glyphs bounds the records retained before line assembly and therefore
also bounds the number of blocks, lines, spans, and words that structured text
can materialize. Text and table interpretations of one page share one
cumulative admission, and a rejected page consumes no budget. Its compatible
default is None; DocumentLimits.web() sets 65,536.
Lenient opening performs one bounded repair: it may replace an incorrect final
startxref only when an intact classic xref table exists in the same final
revision and a complete parse succeeds under the original limits. It does not
scan object headers, repair xref streams, or roll back to an earlier revision.
The event emits PylopdfWarning, sets doc.is_repaired (and the metadata
probe's repaired key), and saving rewrites normalized xref data.
- Rendering is capped at 64 megapixels per page.
Document.render_page(),Page.render(), andPixmap.tobytes()default to a 64 MiB encoded-PNG output boundary. Their writers refuse the crossing write before returning Pythonbytes;max_size=Noneexplicitly opts out. Render refusals userender_output_size, while direct Pixmap encoding usespixmap_output_size.Document.tobytes()defaults to a 512 MiB serialized-output limit across normal, object/xref-stream, and encrypted output. Its Rust writer refuses the write crossing the boundary before Pythonbytesconversion;max_size=Noneexplicitly opts out.save()streams to a securely created sibling in the target directory and atomically replaces the requested path only after a complete write, so serialization or replacement failures preserve an existing file. It is not governed by the in-memory output budget. Save options such asgarbage,deflate, andobject_streamsretain their documented mutation semantics even when later I/O fails.Pixmap.save()streams PNG encoding directly to an unpredictable, exclusively created sibling in the target directory and atomically replaces the requested path only after the complete write succeeds. It does not retain a second completed PNG in memory. Replacement failures preserve existing output and remove the temporary file.Page.insert_image()defaults encoded JPEG/PNG input to 64 MiB and decoded PNG input to 64,000,000 pixels. Filename input is read under the released GIL through the same bounded Rust boundary used by direct core calls; PNG dimensions are checked before decoded storage is allocated.max_size=Noneandmax_pixels=Noneexplicitly opt trusted workloads out.- Explicit and automatically selected OpenType input for
insert_text,insert_textbox,set_form_field, andset_fallback_fontdefaults to 64 MiB. Buffer input is rejected before its PyO3 copy and filename input is read through the bounded, GIL-released Rust path.max_font_size=Noneexplicitly opts trusted workloads out. insert_text()andinsert_textbox()default generated text input to 1 MiB of UTF-8 and 4,096 physical or final wrapped lines. Python checks physical input before PyO3 copying, the Rust boundary repeats it and stops wrapped layout before mutation, and textbox tab expansion is preflighted before allocating the expanded string.max_text_size=Noneexplicitly opts trusted insertion input out; refusals usetext_input_sizeortext_line_count. AcroForm text and choice appearances retain the fixed 4,096-line layout cap. UAX #14 and grapheme indexes, line collections, and retained line text grow fallibly, so allocation refusal exposes neither a partial layout nor a document edit. Pylopdf-owned normalization, grapheme references, shaped-line/glyph collections, WinAnsi output, and input-derived text-operator growth are fallible; Standard 14 width checks do not allocate an encoded text copy. Allocation refusal returnsPdfError; insertion remains nonmutating and form filling rolls back atomically.search_for()accepts at most 4,096 UTF-8 bytes of search text and defaults returned geometry to 4,096 hits. Python rejects oversized terms before PyO3 copying and the Rust boundary repeats both limits.max_hits=Noneexplicitly opts trusted result sets out; refusals usesearch_input_sizeorsearch_hit_countand never return a partial list.- Passwords used by open, authenticate, fast metadata probes, and AES-256
output stop at 127 UTF-8 bytes before PyO3 copying or password-KDF work.
Direct Rust calls repeat the boundary. Refusals use
password_input_size; save rejection occurs before document mutation or output creation. render_page_svg()andPage.render_svg()default to a 64 MiB UTF-8 output limit and reject over-limit output before PyO3 creates the Python string;max_size=Noneexplicitly opts out. hayro-svg 0.7 materializes one internal Rust string before pylopdf can enforce this boundary.- Drawing insertions preflight raw page
/Contentsarrays and reference chains before cache invalidation, input decoding, or dependent object creation. Raw arrays stop at 4,096 entries, chains at 32 levels, and the final array at 4,096 stream references including the one-timeq/Qisolation pair. Failures do not mutate the document. Page.replace_text()caps aggregate search/replacement/fallback input at 4,096 UTF-8 bytes and defaults decoded page content, font encoding data, replacement growth, and final stream output to 64 MiB. It prepares a page-owned stream before committing, so copied pages cannot edit shared content and no-match/error calls preserve the document and caches. Caller text is counted incrementally before PyO3 copying rather than encoded in full for preflight.max_size=Noneexplicitly opts out for trusted input.delete_pages(),select(), andinsert_pdf()accept at most 4,096 page entries per call in both Python and Rust. Iterable reads stop at item 4,097 before graph mutation. Empty deletion preserves caches, generation, and existingPageviews.Page.get_images()rejects partial results above 4,096 placements, 64,000,000 cumulative source pixels, or 64 MiB of returned payloads per page. Flate-wrapped JPEG passthrough stops decompression at the remaining budget.Document.embfile_add()rejects input above 64 MiB before its PyO3 copy, andembfile_get()applies the same default to every decoded filter layer. Raisemax_size=for a known large attachment;max_size=Noneexplicitly accepts unbounded input or materialization. Attachment name trees are rejected above 4,096 entries/nodes, 32 levels, or 1 MiB of encoded or decoded names. Caller lookup/deletion names and aggregate add-time key/filename/description text stop at 1 MiB before traversal or data copying withembedded_file_input_size. Edits validate inline FileSpecs before cloning above 4,096 direct objects, 32 levels, or 1 MiB of direct string/name/stream data, and preflight the Catalog write target instead of cloning the complete document for rollback.Document.get_pdfa_claim()defaults to a 1 MiB XMP decoded-size limit applied to every filter layer. Raisemax_size=for a known large packet;max_size=Noneexplicitly accepts unbounded materialization.Page.insert_ocr_text_layer()stops iterable materialization above 4,096 non-empty words or 1 MiB of aggregate UTF-8 text. Direct core calls enforce the same limits, CID assignment stops before 65,535 distinct characters, and all input-derived buffers are prepared before PDF mutation.- Page-label number-tree reads reject partial output above 4,096 entries/nodes,
32 levels, or 1 MiB of encoded or decoded style/prefix text. Reference cycles
are visited once; writes enforce the same entry/text boundary before PyO3
copying with
page_label_input_size. - AcroForm field-tree reads reject partial output above 4,096 entries/nodes,
8,192 edges, 64 levels, 1 MiB of encoded, decoded, or returned names/values,
or 4,096 choice-value items. Reference cycles are visited once, inherited
values are charged per returned leaf, and fills enforce the same tree plus
1 MiB caller name/value input atomically. Caller input stops before font
discovery, button lookup, or file reads with
form_field_input_size. - AcroForm button fields reject more than 4,096 widgets, 8,192 normal-appearance
state entries, 4,096 unique returned state names, or 1 MiB of encoded/returned
state-name text. Fills budget missing
Offand on-state keys before mutation. - Annotation and link reads reject partial output above 4,096
/Annotsentries or 1 MiB of aggregate encoded/returned metadata text per call. Adds enforce the same page count, 1 MiB generated subtype plus Contents/URI input, and 4,096 highlight rectangles before creating dependent objects or invalidating caches. Caller text is rejected before PyO3 copying or rectangle iteration withannotation_input_size; highlight iteration stops at item 4,097. - Named-destination lookup visits reference cycles once and rejects traversal
above 4,096 entries/nodes, 8,192 edges, 32 levels, or 1 MiB of key bytes
instead of silently reporting a truncated tree as unresolved.
Page.get_links()builds one borrowed index per call rather than traversing the tree again for every named link. - TOC reads use an iterative outline walk, visit reference cycles once, release
the GIL, and reject partial output above 4,096 nodes/entries, 8,192 edges,
64 levels, 32 destination indirections, or 1 MiB of source/returned text.
Writes enforce compatible entry, depth, and title-text limits before PyO3
copying and mutation with
toc_input_size. Document.metadatadecodes only the eight standard Info fields and rejects aggregate source or returned text above 1 MiB; custom entries do not become Python output.peek_metadata(max_file_size=)can reject path or byte input before parsing and also caps returned standard text;Nonepreserves the unbounded input default. Writes preflight 1 MiB aggregate source/encoded text before PyO3 copying withmetadata_input_sizeand apply atomically.- Embedded JavaScript is never executed; it is unsupported by design.
render_pages()accepts at most 4,096 page entries and defaults to a 512 MiB cumulative encoded-PNG limit. Completed parallel results share one atomic budget and failures return no partial list;max_size=Noneexplicitly opts out. Worker admission separately caps estimated live raster/conversion buffers, so do not add unbounded application-level parallelism around it.Document.to_markdown()accepts at most 4,096 page entries and defaults to a 64 MiB cumulative UTF-8 output limit. It keeps only one page's interpreted layout, tables, and words at a time across a heading-count pass and a render pass. Each table receives the remaining aggregate budget before page output is assembled. Headings, paragraphs, lists, and tables are charged as retained entries, and the final page uses linear assembly after the complete size has been admitted. DirectTable.to_markdown()calls default to the same limit and preflight exact escaped UTF-8 size, including merged-cell expansion. Over-limit conversions return no partial string;max_size=Noneexplicitly opts out.- Enforce CPU deadlines in the Worker, process, or container host. Resource budgets prevent documented allocations and output growth; they do not interrupt an in-progress parser or interpreter by wall-clock time.
- Run batch processing of untrusted files in a sandbox or container when possible. Native and Pyodide CI share the same hostile-input regression contract, and scheduled Atheris fuzzing seeds malformed xrefs, cycles, deep objects, broken streams, and compression bombs.
Dependency auditing¶
CI runs cargo audit against the Rust dependency tree and the RustSec advisory
database on every push.
The repository copy of this policy is
SECURITY.md.