From 8ef51c3a2e378ad67a8068e8496838222f5d95c7 Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Mon, 20 Jul 2026 17:42:18 +1000 Subject: [PATCH 1/2] Guard recursive glyph and subroutine calls Add format-limit recursion guards to prevent infinite loops in malformed fonts: cap Type 2/CFF subroutine nesting at 10 and TrueType composite glyph depth at 16. When limits are exceeded, component/subroutine expansion degrades to an empty outline instead of recursing indefinitely. Also add Issue537 regression coverage (including a test font) to verify self-referential composite glyphs still measure safely and cyclic CFF subroutines produce empty bounds. --- .../Tables/Cff/CffEvaluationEngine.cs | 23 +++++++++---- .../TrueType/Glyphs/CompositeGlyphLoader.cs | 21 +++++++++++- .../Tables/TrueType/Glyphs/GlyphTable.cs | 16 ++++++++- tests/Fonts/Issues/Issue537.ttf | 3 ++ .../Issues/Issues_537.cs | 34 +++++++++++++++++++ tests/SixLabors.Fonts.Tests/TestFonts.cs | 2 ++ 6 files changed, 90 insertions(+), 9 deletions(-) create mode 100644 tests/Fonts/Issues/Issue537.ttf create mode 100644 tests/SixLabors.Fonts.Tests/Issues/Issues_537.cs diff --git a/src/SixLabors.Fonts/Tables/Cff/CffEvaluationEngine.cs b/src/SixLabors.Fonts/Tables/Cff/CffEvaluationEngine.cs index cd7c4a753..a4f010b6c 100644 --- a/src/SixLabors.Fonts/Tables/Cff/CffEvaluationEngine.cs +++ b/src/SixLabors.Fonts/Tables/Cff/CffEvaluationEngine.cs @@ -19,6 +19,10 @@ namespace SixLabors.Fonts.Tables.Cff; /// internal ref struct CffEvaluationEngine { + // Appendix B of both Type 2 and CFF2 CharString specifications limits nested local and global + // subroutine calls to 10, which also provides a fixed stack bound for malformed cyclic programs. + private const int MaxSubroutineNesting = 10; + private static readonly Random Random = new(); private float? width; private int nStems; @@ -108,7 +112,7 @@ public Bounds GetBounds() this.transforming = new(finder, Vector2.Zero, new Vector2(1, -1), Vector2.Zero, Matrix3x2.Identity); // Boolean IGlyphRenderer.BeginGlyph(..) is handled by the caller. - this.Parse(this.charStrings); + this.Parse(this.charStrings, 0); // Some CFF end without closing the latest contour. if (this.transforming.IsOpen) @@ -134,7 +138,7 @@ public void RenderTo(IGlyphRenderer renderer, Vector2 origin, Vector2 scale, Vec this.transforming = new(renderer, origin, scale, offset, transform); // Boolean IGlyphRenderer.BeginGlyph(..) is handled by the caller. - this.Parse(this.charStrings); + this.Parse(this.charStrings, 0); // Some CFF end without closing the latest contour. if (this.transforming.IsOpen) @@ -147,7 +151,8 @@ public void RenderTo(IGlyphRenderer renderer, Vector2 origin, Vector2 scale, Vec /// Parses and interprets a Type 2 charstring byte buffer, executing operators and accumulating operands. /// /// The charstring byte data to parse. - private void Parse(ReadOnlySpan buffer) + /// The number of active local and global subroutine calls. + private void Parse(ReadOnlySpan buffer, int subroutineDepth) { SimpleBinaryReader reader = new(buffer); bool endCharEncountered = false; @@ -239,9 +244,11 @@ private void Parse(ReadOnlySpan buffer) index = (int)this.stack.Pop() + this.localBias; subr = this.localSubrBuffers[index]; - if (subr.Length > 0) + // The over-limit call contributes no outline, matching how cyclic TrueType components + // degrade to empty while allowing the enclosing charstring to continue normally. + if (subr.Length > 0 && subroutineDepth < MaxSubroutineNesting) { - this.Parse(subr); + this.Parse(subr, subroutineDepth + 1); } break; @@ -452,9 +459,11 @@ private void Parse(ReadOnlySpan buffer) index = (int)this.stack.Pop() + this.globalBias; subr = this.globalSubrBuffers[index]; - if (subr.Length > 0) + // Local and global subroutines share the same nesting stack and therefore the same + // format limit and empty-outline fallback behavior. + if (subr.Length > 0 && subroutineDepth < MaxSubroutineNesting) { - this.Parse(subr); + this.Parse(subr, subroutineDepth + 1); } break; diff --git a/src/SixLabors.Fonts/Tables/TrueType/Glyphs/CompositeGlyphLoader.cs b/src/SixLabors.Fonts/Tables/TrueType/Glyphs/CompositeGlyphLoader.cs index 2832ff1c8..a6ef26aae 100644 --- a/src/SixLabors.Fonts/Tables/TrueType/Glyphs/CompositeGlyphLoader.cs +++ b/src/SixLabors.Fonts/Tables/TrueType/Glyphs/CompositeGlyphLoader.cs @@ -12,6 +12,10 @@ namespace SixLabors.Fonts.Tables.TrueType.Glyphs; /// internal sealed class CompositeGlyphLoader : GlyphLoader { + // The TrueType reference specification defines 16 as the maximum legal maxComponentDepth. Enforcing the + // format limit bounds malformed cyclic graphs without allocating path-tracking state on composite loads. + private const int MaxCompositeDepth = 16; + private readonly Bounds bounds; private readonly Composite[] composites; private readonly ReadOnlyMemory instructions; @@ -31,14 +35,29 @@ public CompositeGlyphLoader(IEnumerable composites, Bounds bounds, Re /// public override GlyphVector CreateGlyph(GlyphTable table) + => this.CreateGlyph(table, 0); + + /// + /// Creates a glyph vector while enforcing the TrueType composite nesting limit. + /// + /// The glyph table used to resolve component glyphs. + /// The number of composite glyphs above this glyph. + /// The resolved glyph vector, or an empty vector when the component graph exceeds the format limit. + public GlyphVector CreateGlyph(GlyphTable table, int compositeDepth) { + if (compositeDepth >= MaxCompositeDepth) + { + return GlyphVector.Empty(this.bounds); + } + List controlPoints = []; List endPoints = []; CompositeComponent[] components = new CompositeComponent[this.composites.Length]; + for (int i = 0; i < this.composites.Length; i++) { Composite composite = this.composites[i]; - GlyphVector clone = GlyphVector.DeepClone(table.GetGlyph(composite.GlyphIndex)); + GlyphVector clone = GlyphVector.DeepClone(table.GetGlyph(composite.GlyphIndex, compositeDepth + 1)); GlyphVector.TransformInPlace(ref clone, composite.Transformation); ushort endPointOffset = (ushort)controlPoints.Count; diff --git a/src/SixLabors.Fonts/Tables/TrueType/Glyphs/GlyphTable.cs b/src/SixLabors.Fonts/Tables/TrueType/Glyphs/GlyphTable.cs index fd3999882..055e479a1 100644 --- a/src/SixLabors.Fonts/Tables/TrueType/Glyphs/GlyphTable.cs +++ b/src/SixLabors.Fonts/Tables/TrueType/Glyphs/GlyphTable.cs @@ -42,13 +42,27 @@ public GlyphTable(GlyphLoader[] glyphLoaders) /// The , or an empty vector if the index is out of range. // TODO: Make this non-virtual internal virtual GlyphVector GetGlyph(int index) + => this.GetGlyph(index, 0); + + /// + /// Gets the for the glyph at the specified index while tracking composite nesting. + /// + /// The zero-based glyph index. + /// The number of composite glyphs above the requested glyph. + /// The , or an empty vector if the index is out of range. + internal GlyphVector GetGlyph(int index, int compositeDepth) { if (index < 0 || index >= this.loaders.Length) { return GlyphVector.Empty(); } - return this.glyphCache.GetOrAdd(index, i => this.loaders[i].CreateGlyph(this)); + return this.glyphCache.GetOrAdd( + index, + static (i, state) => state.Table.loaders[i] is CompositeGlyphLoader composite + ? composite.CreateGlyph(state.Table, state.CompositeDepth) + : state.Table.loaders[i].CreateGlyph(state.Table), + (Table: this, CompositeDepth: compositeDepth)); } /// diff --git a/tests/Fonts/Issues/Issue537.ttf b/tests/Fonts/Issues/Issue537.ttf new file mode 100644 index 000000000..6e549a068 --- /dev/null +++ b/tests/Fonts/Issues/Issue537.ttf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b9e733bd5644379e8bec5bf7dd90a001e6b8487b2d1f27a1ceb6564c578cb6cb +size 10832 diff --git a/tests/SixLabors.Fonts.Tests/Issues/Issues_537.cs b/tests/SixLabors.Fonts.Tests/Issues/Issues_537.cs new file mode 100644 index 000000000..99f92635d --- /dev/null +++ b/tests/SixLabors.Fonts.Tests/Issues/Issues_537.cs @@ -0,0 +1,34 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using SixLabors.Fonts.Tables.Cff; + +namespace SixLabors.Fonts.Tests.Issues; + +public class Issues_537 +{ + [Fact] + public void ShouldMeasureFontWithSelfReferentialCompositeGlyph() + { + Font font = TestFonts.GetFont(TestFonts.Issues.Issue537, 16); + + FontRectangle bounds = TextMeasurer.MeasureRenderableBounds("ABCabc123!@#", new TextOptions(font)); + + Assert.NotEqual(FontRectangle.Empty, bounds); + } + + [Fact] + public void SelfReferentialSubroutineProducesEmptyBounds() + { + // With one subroutine the Type 2 bias is 107, so -107 (encoded as byte 32) selects subroutine + // zero. That subroutine repeats the same call, reproducing a validly indexed cyclic program. + byte[] selfReferentialSubroutine = [32, (byte)Type2Operator1.Callsubr]; + byte[] charString = [32, (byte)Type2Operator1.Callsubr]; + byte[][] localSubroutines = [selfReferentialSubroutine]; + + using CffEvaluationEngine engine = new(charString, [], localSubroutines, 0, 1); + Bounds bounds = engine.GetBounds(); + + Assert.Equal(Bounds.Empty, bounds); + } +} diff --git a/tests/SixLabors.Fonts.Tests/TestFonts.cs b/tests/SixLabors.Fonts.Tests/TestFonts.cs index 4faee25c8..5cce1d6e3 100644 --- a/tests/SixLabors.Fonts.Tests/TestFonts.cs +++ b/tests/SixLabors.Fonts.Tests/TestFonts.cs @@ -416,6 +416,8 @@ public static class Issues public static string Issue514 => GetFullPath("Issues/Issue514.ttf"); public static string Issue534 => GetFullPath("Issues/Issue534.ttf"); + + public static string Issue537 => GetFullPath("Issues/Issue537.ttf"); } /// From f385640d0376b49337463c90c4fe63500fda2e6b Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Mon, 20 Jul 2026 18:01:38 +1000 Subject: [PATCH 2/2] Add regression test for CFF subroutine loops Add a CFF font fixture for issue 537 and update the test to exercise the public text measurement path. This covers self-referential Type 2 subroutines at the font level and asserts the glyph still produces non-empty bounds. --- tests/Fonts/Issues/Issue537Cff.otf | 3 +++ tests/SixLabors.Fonts.Tests/Issues/Issues_537.cs | 15 ++++----------- tests/SixLabors.Fonts.Tests/TestFonts.cs | 2 ++ 3 files changed, 9 insertions(+), 11 deletions(-) create mode 100644 tests/Fonts/Issues/Issue537Cff.otf diff --git a/tests/Fonts/Issues/Issue537Cff.otf b/tests/Fonts/Issues/Issue537Cff.otf new file mode 100644 index 000000000..f5099cc41 --- /dev/null +++ b/tests/Fonts/Issues/Issue537Cff.otf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:63eaf9c9184ca1cedc97291f288ca633d55c3aba61d1c4913724b9274b47f6f1 +size 1016 diff --git a/tests/SixLabors.Fonts.Tests/Issues/Issues_537.cs b/tests/SixLabors.Fonts.Tests/Issues/Issues_537.cs index 99f92635d..71e8d2942 100644 --- a/tests/SixLabors.Fonts.Tests/Issues/Issues_537.cs +++ b/tests/SixLabors.Fonts.Tests/Issues/Issues_537.cs @@ -1,8 +1,6 @@ // Copyright (c) Six Labors. // Licensed under the Six Labors Split License. -using SixLabors.Fonts.Tables.Cff; - namespace SixLabors.Fonts.Tests.Issues; public class Issues_537 @@ -18,17 +16,12 @@ public void ShouldMeasureFontWithSelfReferentialCompositeGlyph() } [Fact] - public void SelfReferentialSubroutineProducesEmptyBounds() + public void ShouldMeasureCffFontWithSelfReferentialSubroutine() { - // With one subroutine the Type 2 bias is 107, so -107 (encoded as byte 32) selects subroutine - // zero. That subroutine repeats the same call, reproducing a validly indexed cyclic program. - byte[] selfReferentialSubroutine = [32, (byte)Type2Operator1.Callsubr]; - byte[] charString = [32, (byte)Type2Operator1.Callsubr]; - byte[][] localSubroutines = [selfReferentialSubroutine]; + Font font = TestFonts.GetFont(TestFonts.Issues.Issue537Cff, 16); - using CffEvaluationEngine engine = new(charString, [], localSubroutines, 0, 1); - Bounds bounds = engine.GetBounds(); + FontRectangle bounds = TextMeasurer.MeasureRenderableBounds("A", new TextOptions(font)); - Assert.Equal(Bounds.Empty, bounds); + Assert.NotEqual(FontRectangle.Empty, bounds); } } diff --git a/tests/SixLabors.Fonts.Tests/TestFonts.cs b/tests/SixLabors.Fonts.Tests/TestFonts.cs index 5cce1d6e3..9b22323c2 100644 --- a/tests/SixLabors.Fonts.Tests/TestFonts.cs +++ b/tests/SixLabors.Fonts.Tests/TestFonts.cs @@ -418,6 +418,8 @@ public static class Issues public static string Issue534 => GetFullPath("Issues/Issue534.ttf"); public static string Issue537 => GetFullPath("Issues/Issue537.ttf"); + + public static string Issue537Cff => GetFullPath("Issues/Issue537Cff.otf"); } ///