Skip to content

Security: CrossApplication/glyph_path

.github/SECURITY.md

Security Policy

Supported Versions

Version Supported
1.x ✅ Yes

Reporting a Vulnerability

Please report security vulnerabilities by opening a private GitHub Security Advisory at: https://github.com/CrossApplication/glyph_path/security/advisories/new

Do not open a public issue for security vulnerabilities.

Dependency Audit

Audited 2026-06-24 (post-v1.0.0)

Package Version Status
cacherine 2.4.0 ✅ No known advisories
synchronized 3.4.0+1 (transitive via cacherine) ✅ No known advisories

Dev dependencies (test, analyzer, etc.) are not included in published builds and are therefore not in scope for end-user security.

Advisories were verified manually against pub.dev. To re-verify at each dependency update, search for each package at osv.dev, check the package changelog on pub.dev, and run dart pub outdated to identify stale versions. Enable GitHub Dependabot on the repository for automatic advisory notifications.

Input Handling

This package parses binary font data (.ttf / .otf). Treat all font bytes as untrusted.

Resource limits (ResourceLimits)

ResourceLimits defines hard static const caps enforced during font parsing and during text-shaping API calls (generateGlyphPaths, measureText, resolveGlyphIdsForLayout, TextLayout.layout). Exceeding a limit tied to the font's own binary structure raises FontParseException (fatal tables) or silently disables the associated feature (non-fatal tables: kern, GPOS, GSUB) — but this Fatal/Non-Fatal split describes parse-time behavior only, i.e. what happens while Font.parse() reads the table itself. A small number of limits are instead evaluated lazily, once per text-shaping call, well after Font.parse() has already returned successfully; these always raise FontParseException to the caller of that call regardless of whether the table they belong to is otherwise classified Non-Fatal — see maxLigatureEvalOps below and Exception model for the one currently affected case. Exceeding a limit on the caller-supplied input itself (maxInputTextLength, maxFontSize) raises ArgumentError instead, since these validate the public API contract rather than the font data:

Limit Value Scope
maxTables 64 Font table directory
maxGlyphs 65535 cmap, hmtx, glyf/CFF
maxCompositeDepth 8 Composite glyph nesting
maxCompositeComponents 64 Components per composite glyph
maxContoursPerGlyph 256 TrueType glyph outlines
maxPointsPerGlyph 32768 TrueType glyph outlines
maxCmapIterations 65536 cmap format 4 segment expansion
maxCmapGroups 10000 cmap format 12 group count
maxCmapFormat12Span 1114112 cmap format 12 cumulative code-point span
maxKernPairs 65535 Cumulative across all kern subtables
maxGposPairs 65535 Cumulative across all GPOS PairPos subtables
maxGposMatrixCells 65535 Per GPOS PairPos Format 2 subtable
maxGposFeatureLookupRefs 65535 Cumulative kern lookup-index references across the GPOS FeatureList
maxCoverageGlyphs 65535 GPOS Coverage Format 2 expansion
maxClassDefEntries 65535 GPOS ClassDef Format 2 expansion
maxLigaRules 10000 Cumulative GSUB ligature rules
maxLigatureComponentCount 16 Components per GSUB ligature record
maxLigatureEvalOps 1000000 GSUB ligature rule visits and component comparisons per text call
maxCffSubrDepth 10 CFF Type 2 subroutine call nesting
maxCffTotalSubrCalls 10000 Cumulative callsubr/callgsubr invocations per CFF glyph
maxCffStackDepth 513 CFF Type 2 charstring operand stack, and CFF DICT operand stacks
maxCffStemCount 96 CFF Type 2 cumulative hstem/vstem/hintmask pairs per glyph
maxCffOutputCommands 32768 PathCommands emitted per CFF glyph interpretation
maxCompositeOutputCommands 65535 PathCommands per composite glyph expansion level
maxPathCommandsPerGlyph 131072 Cumulative PathCommands across all composite levels
maxCoverageRangeRecords 20000 Per-call range-record scan in GSUB Coverage Format 2
maxGsubCoverageScanSteps 1000000 Cumulative GSUB ligature coverage scan cost per parse
maxGsubLigSetVisits 1000000 Cumulative LigatureSet visits across all GSUB liga lookups/subtables per parse
maxGsubSubtablesPerLookup 64 Subtables per GSUB Lookup table
maxGsubFeatureLookupRefs 65535 Cumulative liga lookup-index references across the FeatureList
maxInputTextLength 4096 UTF-16 code units per API call
maxFontSize 1e6 fontSize value accepted by generateGlyphPaths, measureText, TextLayout.layout

Table isolation

Every table parser receives a BinaryReader.slice() scoped to the table's declared byte range. This makes cross-table reads structurally impossible — a truncated or malformed table cannot bleed into adjacent table bytes. BinaryReader.slice() rejects negative offset or length arguments with RangeError, preventing callers from accidentally referencing memory before the start of a table slice.

Per-table bounds checks

head — minimum 54-byte length validated before any field read. The magicNumber field (offset 8+4 = 12) is validated against 0x5F0F3CF5. The checkSumAdjustment field (offset 8) is intentionally not validated: many freely distributed fonts and subsetting-tool outputs contain incorrect checksum values, so rejecting on checksum mismatch would break compatibility with fonts that render correctly in every major browser and OS.

hhea — minimum 36-byte length validated; numberOfHMetrics checked against the hmtx table size.

cmap — subtable offsets are validated against the cmap table's own declared size (not the full font buffer), catching subtables that point into adjacent tables. Within format 4, segCountX2 is validated to be even and length is checked against 16 + 8 × segCount before any array read. Format 12 rejects tables whose group count exceeds maxCmapGroups before allocating any group data.

kern — at least 4 bytes are required before reading the table header; each subtable requires at least 6 bytes; any subtable with length < 6 is rejected (prevents backward seeks). The cumulative pair count is capped at maxKernPairs.

loca — every offset is validated to fall within the glyf table's byte range; descending offsets (which would imply a negative glyph size) are rejected.

glyf — composite glyph nesting is checked against maxCompositeDepth and maxCompositeComponents before recursion; cycle detection prevents infinite loops. The cumulative PathCommand output per expansion level is capped at maxCompositeOutputCommands, and the total across all recursive levels at maxPathCommandsPerGlyph, preventing memory exhaustion from deeply nested composites. Simple glyph flag repeat expansion is validated before allocation: if the declared repeatCount would push the total flag count past numPoints, a FontParseException is raised immediately rather than after the over-expanded list is built. endPtsOfContours is validated to be strictly increasing immediately after it is read, before numPoints (derived from its last entry) is used to size the coordinate and flag arrays — a non-monotonic array would otherwise let the contour-building loop index past those arrays.

GPOS — Coverage Format 2 range expansion is capped at maxCoverageGlyphs; ClassDef Format 1 and Format 2 expansion are capped at maxClassDefEntries; PairPos Format 2 matrix size (class1Count × class2Count) is checked against maxGposMatrixCells before the nested loop executes. PairPos Format 1 counts every visited pair record (not only those with a non-zero XAdvance) against maxGposPairs, so a crafted font whose pairs all encode a zero XAdvance still trips the cumulative cap instead of iterating unbounded. The FeatureList's kern-feature collection loop counts every lookup-index reference read (featureCount × lookupIndexCount, both independent uint16 fields) against maxGposFeatureLookupRefs, preventing a crafted font from sharing one small FeatureTable offset across thousands of kern features to force hundreds of millions of reads from a buffer of a few hundred kilobytes.

GSUB — ligature compCount values of 0 (spec violation) or greater than maxLigatureComponentCount are rejected. Cumulative rule count is capped at maxLigaRules. All limits described so far in this section are checked inside parseGsub() itself, so a violation is caught and swallowed by the Non-Fatal boundary described under Exception model below — Font.parse() succeeds and ligatures are simply disabled.

maxLigatureEvalOps is different: it is not checked during parseGsub(). It is checked inside Font._resolveGlyphIdsWithSource, which runs on every call to generateGlyphPaths, generateRawGlyphPaths, measureText, resolveGlyphIdsForLayout, and TextLayout.layout — i.e. after Font.parse() has already returned successfully, once per text-shaping call. The total number of ligature rule visits and component comparisons per call is capped at maxLigatureEvalOps — counted once per candidate rule considered in addition to once per component compared, so a rule that never reaches the component loop (e.g. a degenerate 0-component rule) cannot evade the budget — preventing CPU exhaustion from fonts with large rule sets combined with long input strings. Because the check fires mid-evaluation rather than while parseGsub() builds the table's data structures, there is no Non-Fatal boundary to swallow it at: exceeding this limit raises FontParseException directly to the caller of that text-shaping call, even though GSUB itself is otherwise a Non-Fatal table. Applications that call these APIs with long, caller-supplied strings against untrusted fonts should be prepared to catch FontParseException from them, not only from Font.parse(). Lookup subtable counts are capped at maxGsubSubtablesPerLookup, and the cumulative liga lookup-index references scanned across the FeatureList are capped at maxGsubFeatureLookupRefs — both independent uint16 fields whose product would otherwise be unbounded. Coverage Format 2 range-record scans are capped per call at maxCoverageRangeRecords, and the cumulative scan cost across all LigatureSet first-glyph lookups in a single parse is capped at maxGsubCoverageScanSteps, preventing the ligSetCount × rangeCount combination from driving billions of comparisons even though neither factor alone exceeds its uint16 range. Independently of that per-visit coverage-scan cost, the cumulative number of LigatureSet visits itself — across every subtable of every liga lookup in a single parse — is capped at maxGsubLigSetVisits. Without this cap, a Coverage Format 1 table (or a sparse Format 2 table) makes each visit O(1), so maxGsubCoverageScanSteps never fires; a crafted font could still reuse the same physical subtable and LigatureSet bytes across every offset entry to declare hundreds of billions of visits (the product of the number of distinct liga lookup indices, subtableCount, and ligSetCount — three independently uint16-bounded fields) from a binary of only a few tens of kilobytes.

CFF_skipIndex validates that offSize is in [1, 4] before reading any INDEX offsets. The charstring interpreter verifies that the byte position advances each iteration (stalled iteration → FontParseException). Multi-byte number encodings (byte 28 shortint, byte 255 fixed-point, bytes 247–254) are checked for sufficient remaining bytes before reading. Subroutine call depth is capped at maxCffSubrDepth; total invocations per glyph at maxCffTotalSubrCalls; charstring operand stack depth at maxCffStackDepth; cumulative hint stem pairs at maxCffStemCount (bounding the hintmask/cntrmask mask-byte skip distance); PathCommand output per glyph at maxCffOutputCommands. FDSelect Format 3 sentinel handling rejects malformed range boundaries. The Top DICT, Private DICT, and Font DICT operand stacks (built while parsing DICT structures, independent of the charstring interpreter) are also capped at maxCffStackDepth, and the Top DICT's escape opcode (byte 12) is bounds-checked before reading its second byte. _readIndexEntries rejects INDEX offset arrays that are not non-decreasing, rather than relying on Uint8List.sublist to reject the resulting negative-length entry with a raw RangeError. _readIndexEntries and _parsePrivateDict both validate that the byte range they are about to read (derived from attacker-controlled DICT operands) fits within the table buffer before calling Uint8List.sublist, raising FontParseException directly at the point of failure rather than letting Uint8List.sublist throw a raw RangeError that the caller would otherwise see (there is no global handler downstream that would convert it). The Font DICT parser (used for CID-keyed FDArray entries) handles the real-number operand (byte 30) the same way the Top DICT and Private DICT parsers do — without it, a real number's continuation bytes could be misread as a premature Private operator or a truncated multi-byte integer, corrupting the Private DICT size/offset resolved for that Font DICT.

Exception model

Two exception types cross the public API boundary, and they are deliberately not interchangeable:

  • FontParseException — the font's own binary data is structurally invalid: a missing or truncated table, an out-of-range offset, a cyclic composite glyph, or a resource limit tied to the font's structure being exceeded. Most such limits (e.g. maxCffSubrDepth) are checked by a Fatal-table parser (head, hhea, maxp, cmap, hmtx, glyf/loca, CFF ) during Font.parse(), which validates bounds before reading and raises FontParseException directly at the point of failure rather than depending on a global handler to catch a RangeError after the fact — there is no such global handler for fatal tables. One limit, maxLigatureEvalOps, is checked later instead: during a text-shaping call (generateGlyphPaths, generateRawGlyphPaths, measureText, resolveGlyphIdsForLayout, or TextLayout.layout), not during Font.parse() — see the GSUB entry under Per-table bounds checks above. It still raises FontParseException, but to the caller of that later call rather than to the caller of Font.parse(), even though GSUB itself is a Non-Fatal table for every other kind of failure. Every FontParseException carries a descriptive message and an optional offset pointing to the problematic byte position (offset is null for the maxLigatureEvalOps case, since it is not tied to a specific byte).
  • ArgumentError — the caller violated the public API's own contract, independent of the font's contents: maxGlyphCacheSize/maxShapeCacheSize ≤ 0 passed to Font.parse, text longer than maxInputTextLength, or fontSize non-finite, non-positive, or greater than maxFontSize. These are argument-validation failures on the API surface, not font-parsing failures, and are never converted to FontParseException.

Non-fatal table failures (kern, GPOS, GSUB, OS/2) are swallowed silently at the point each table is parsed: both FontParseException and RangeError are caught locally (the GPOS and GSUB parsers additionally catch internal ArgumentErrors raised while parsing the table's own bytes, as defensive best-effort — unrelated to the public-API-boundary ArgumentError described above, which is never caught or converted), the associated feature is disabled, and parsing continues. Callers have no direct notification when these tables are rejected; a malformed font will simply produce output without kerning or ligature substitution.

The one exception is maxLigatureEvalOps (see above): because it fires during a text-shaping call rather than while GSUB is parsed, it is not covered by this swallow — FontParseException propagates out of that call normally.

There aren't any published security advisories