glyf::outline_impl's MAX_COMPONENTS = 32 bounds the depth of a composite-glyph chain, but
nothing bounds how many times a shared component-glyph subtree gets re-visited via sibling
components at the same level. A composite glyph whose components all reference one shared
child glyph — rather than MAX_COMPONENTS distinct ones — forces branching^depth calls to
outline_impl while the depth cap is never reached, and needs only depth + 1 distinct glyph
records (not branching^depth of them) to construct.
The identical pattern exists in gvar::outline_var_impl (used for variable-font glyph
outlining), which has the same depth >= glyf::MAX_COMPONENTS guard and no visit-count bound
either.
How bad is this, measured against real data
Rather than assert a severity, I measured it against a real 6253-glyph font (DejaVu Sans, 759 KB,
genuine composite glyphs for accented Latin/Cyrillic/Greek) outlining every glyph: 8.66ms
total, worst single real glyph 1.23ms. Against that baseline, the 920-byte crafted glyph below
(16.8s) is ~13,600× slower than the single slowest glyph in a large, real, widely-deployed
font — for less than 2× the byte count of that glyph. This is a genuine, large disproportion
between crafted-input cost and legitimate-input cost, not just "technically unbounded in theory."
Reproduction (ttf-parser 0.25.1, and current main @ 6e75b3c)
A chain of depth composite glyphs, each with branching components that all reference the
SAME single next-level glyph, terminating in one real leaf glyph:
| depth |
branching |
font size |
outline_glyph time (before fix) |
| 10 |
2 |
536 B |
55 µs |
| 15 |
2 |
686 B |
2.2 ms |
| 18 |
3 |
920 B |
16.8 s |
| 20 |
3 |
996 B |
> 20 s (did not complete) |
Each timing was measured as an isolated OS process (to safely bound the run) on current main
@ 6e75b3c. Self-contained reproducer available (builds the sfnt/head/hhea/maxp/loca/glyf
tables byte-by-byte; ~150 lines).
Fix
PR incoming: adds a total component-visit budget (100,000 — real composite glyphs have at most a
few hundred components, so this leaves generous headroom while bounding worst-case cost to a
small constant regardless of the crafted branching factor), threaded through both
glyf::outline_impl and gvar::outline_var_impl. Happy to adjust the exact threshold if you'd
rather pick a different number — this part is a policy call, the mechanism (a total-visit
counter, independent of depth) is the part I'd consider load-bearing.
Discovered by the rust-in-peace security pipeline.
glyf::outline_impl'sMAX_COMPONENTS = 32bounds the depth of a composite-glyph chain, butnothing bounds how many times a shared component-glyph subtree gets re-visited via sibling
components at the same level. A composite glyph whose components all reference one shared
child glyph — rather than
MAX_COMPONENTSdistinct ones — forcesbranching^depthcalls tooutline_implwhile the depth cap is never reached, and needs onlydepth + 1distinct glyphrecords (not
branching^depthof them) to construct.The identical pattern exists in
gvar::outline_var_impl(used for variable-font glyphoutlining), which has the same
depth >= glyf::MAX_COMPONENTSguard and no visit-count boundeither.
How bad is this, measured against real data
Rather than assert a severity, I measured it against a real 6253-glyph font (DejaVu Sans, 759 KB,
genuine composite glyphs for accented Latin/Cyrillic/Greek) outlining every glyph: 8.66ms
total, worst single real glyph 1.23ms. Against that baseline, the 920-byte crafted glyph below
(16.8s) is ~13,600× slower than the single slowest glyph in a large, real, widely-deployed
font — for less than 2× the byte count of that glyph. This is a genuine, large disproportion
between crafted-input cost and legitimate-input cost, not just "technically unbounded in theory."
Reproduction (ttf-parser 0.25.1, and current main @ 6e75b3c)
A chain of
depthcomposite glyphs, each withbranchingcomponents that all reference theSAME single next-level glyph, terminating in one real leaf glyph:
outline_glyphtime (before fix)Each timing was measured as an isolated OS process (to safely bound the run) on current
main@
6e75b3c. Self-contained reproducer available (builds the sfnt/head/hhea/maxp/loca/glyftables byte-by-byte; ~150 lines).
Fix
PR incoming: adds a total component-visit budget (100,000 — real composite glyphs have at most a
few hundred components, so this leaves generous headroom while bounding worst-case cost to a
small constant regardless of the crafted branching factor), threaded through both
glyf::outline_implandgvar::outline_var_impl. Happy to adjust the exact threshold if you'drather pick a different number — this part is a policy call, the mechanism (a total-visit
counter, independent of depth) is the part I'd consider load-bearing.
Discovered by the rust-in-peace security pipeline.