diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a97581dd..6e680b690 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ The release run heads these entries with the version and opens a fresh ## Unreleased +- SFNT fonts check character-map ranges before they expand them. A broken or + unsupported character map no longer hides the next supported one. + - Legacy Office reports unencrypted documents as decodable and derives metadata from the shared file-type table. diff --git a/src/odr/internal/font/sfnt_font.cpp b/src/odr/internal/font/sfnt_font.cpp index 84ab1618f..3a612be83 100644 --- a/src/odr/internal/font/sfnt_font.cpp +++ b/src/odr/internal/font/sfnt_font.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -133,8 +134,8 @@ struct NameEntry { /// Preference among `cmap` subtables: Unicode full > Unicode BMP > symbol > /// Mac. -[[nodiscard]] int cmap_score(const PlatformId platform, - const std::uint16_t encoding) { +[[nodiscard]] std::uint32_t cmap_score(const PlatformId platform, + const std::uint16_t encoding) { switch (platform) { case PlatformId::windows: switch (static_cast(encoding)) { @@ -197,6 +198,10 @@ void SfntFont::parse() { std::uint32_t sfnt_offset = 0; if (d.substr(0, 4) == "ttcf") { + const std::uint32_t count = bs::read_u32_be(d.substr(8)); + if (count == 0 || count > (d.size() - 12) / 4) { + throw std::runtime_error("sfnt: invalid collection directory"); + } // TrueType Collection: read the first member's offset table. sfnt_offset = bs::read_u32_be(d.substr(12)); } @@ -210,13 +215,23 @@ void SfntFont::parse() { } std::string_view SfntFont::table_data(const Table table) const { + if (table.offset > m_data.size()) { + throw std::runtime_error("sfnt: table offset exceeds the font"); + } + // Like FreeType, clip a table that runs past the end of the font. return std::string_view{m_data}.substr(table.offset, table.length); } void SfntFont::read_directory(const std::string_view sfnt) { + if (sfnt.size() < 12 || !is_sfnt(sfnt) || sfnt.substr(0, 4) == "ttcf") { + throw std::runtime_error("sfnt: invalid offset table"); + } m_format = sfnt.substr(0, 4) == "OTTO" ? FontFormat::opentype_cff : FontFormat::truetype; const std::uint16_t num_tables = bs::read_u16_be(sfnt.substr(4)); + if (num_tables > (sfnt.size() - 12) / 16) { + throw std::runtime_error("sfnt: truncated table directory"); + } // Past the 12 byte offset table, each directory entry is 16 bytes: tag(4), // checkSum(4), offset(4), length(4). @@ -236,6 +251,9 @@ void SfntFont::read_head() { } const std::string_view t = table_data(*head); m_units_per_em = bs::read_u16_be(t.substr(18)); + if (m_units_per_em == 0) { + throw std::runtime_error("sfnt: zero units per em"); + } m_bbox = read_bbox(t.substr(36)); } @@ -291,16 +309,36 @@ void SfntFont::read_cmap() { WindowsEncoding::symbol; }); - const auto best_entry = - std::ranges::max_element(entries, {}, [](const CmapEntry &entry) { - return cmap_score(entry.platform, entry.encoding); - }); + const auto score = [t](const CmapEntry &entry) -> std::uint32_t { + if (t.size() < 2 || entry.offset > t.size() - 2) { + return 0; + } + switch (static_cast(bs::read_u16_be(t.substr(entry.offset)))) { + case CmapFormat::byte_encoding: + case CmapFormat::segment_mapping: + case CmapFormat::trimmed_mapping: + case CmapFormat::segmented_coverage: + return cmap_score(entry.platform, entry.encoding); + default: + return 0; + } + }; + std::ranges::stable_sort(entries, std::ranges::greater{}, score); - if (best_entry != entries.end() && - cmap_score(best_entry->platform, best_entry->encoding) > 0) { - // The encoding record's offset is relative to the start of the `cmap` - // table. - read_cmap_subtable(t.substr(best_entry->offset)); + for (const CmapEntry &entry : entries) { + if (score(entry) == 0) { + break; + } + // Like FreeType, a broken subtable drops only itself, because a PDF + // addresses the glyphs of an embedded font by ID. + try { + // The encoding record's offset is relative to the start of the `cmap` + // table. + read_cmap_subtable(t.substr(entry.offset)); + break; + } catch (const std::exception &) { + m_cmap.clear(); + } } update_reverse(); @@ -315,7 +353,12 @@ void SfntFont::update_reverse() { } } -void SfntFont::read_cmap_subtable(const std::string_view s) { +void SfntFont::read_cmap_subtable(std::string_view s) { + const auto format = static_cast(bs::read_u16_be(s)); + const std::size_t length = format == CmapFormat::segmented_coverage + ? bs::read_u32_be(s.substr(4)) + : bs::read_u16_be(s.substr(2)); + s = s.substr(0, length); const auto map = [this](const char32_t code, const std::uint16_t glyph) { if (glyph == 0) { return; @@ -333,7 +376,7 @@ void SfntFont::read_cmap_subtable(const std::string_view s) { return out; }; - switch (static_cast(bs::read_u16_be(s))) { + switch (format) { case CmapFormat::byte_encoding: { // format(0), length(2), language(4), then a 256-byte glyphIdArray(6). for (std::uint32_t code = 0; code < 256; ++code) { @@ -345,8 +388,11 @@ void SfntFont::read_cmap_subtable(const std::string_view s) { // format(0), length(2), language(4), segCountX2(6), 3 search hints(8..12), // then the parallel segs-sized arrays endCode(14), reservedPad, startCode, // idDelta, idRangeOffset. - const std::uint16_t length = bs::read_u16_be(s.substr(2)); - const std::size_t segs = bs::read_u16_be(s.substr(6)) / 2U; + const std::uint16_t seg_count_x2 = bs::read_u16_be(s.substr(6)); + const std::size_t segs = seg_count_x2 / 2U; + if (segs == 0 || seg_count_x2 % 2 != 0) { + throw std::runtime_error("sfnt: invalid cmap segment count"); + } const std::vector end_codes = read_u16_vector(s.substr(14), segs); const std::vector start_codes = @@ -363,11 +409,19 @@ void SfntFont::read_cmap_subtable(const std::string_view s) { } const std::vector glyph_ids = read_u16_vector(s.substr(header), (length - header) / 2); + std::int32_t previous_end = -1; for (std::size_t seg = 0; seg < segs; ++seg) { const std::uint16_t start = start_codes.at(seg); const std::uint16_t end = end_codes.at(seg); const std::uint16_t delta = id_deltas.at(seg); const std::uint16_t range = id_range_offsets.at(seg); + // Skip a bad segment and keep the rest. In ascending order, the + // segments cover each code once. + if (start > end || start <= previous_end || + (start != 0xFFFF && range % 2 != 0)) { + continue; + } + previous_end = end; for (std::uint32_t code = start; code <= end && code != 0xffff; ++code) { std::uint16_t glyph = 0; if (range == 0) { @@ -392,6 +446,9 @@ void SfntFont::read_cmap_subtable(const std::string_view s) { // glyphIdArray(10). const std::uint16_t first = bs::read_u16_be(s.substr(6)); const std::uint16_t entries = bs::read_u16_be(s.substr(8)); + if (std::uint32_t{first} + entries > 0x10000) { + throw std::runtime_error("sfnt: cmap format 6 exceeds the BMP"); + } for (std::uint16_t i = 0; i < entries; ++i) { map(first + i, bs::read_u16_be(s.substr(10 + static_cast(i) * 2))); @@ -402,11 +459,20 @@ void SfntFont::read_cmap_subtable(const std::string_view s) { // format(0), reserved(2), length(4), language(8), numGroups(12), then the // groups(16), each a 12-byte startCharCode/endCharCode/startGlyphID. const std::uint32_t groups = bs::read_u32_be(s.substr(12)); + if (groups > (s.size() - 16) / 12) { + throw std::runtime_error("sfnt: truncated cmap groups"); + } + std::uint32_t previous_end = 0; for (std::uint32_t g = 0; g < groups; ++g) { const std::size_t base = 16 + static_cast(g) * 12; const std::uint32_t start = bs::read_u32_be(s.substr(base)); const std::uint32_t end = bs::read_u32_be(s.substr(base + 4)); const std::uint32_t start_glyph = bs::read_u32_be(s.substr(base + 8)); + if (start > end || end > 0x10FFFF || (g > 0 && start <= previous_end) || + start_glyph > 0xFFFF || end - start > 0xFFFF - start_glyph) { + throw std::runtime_error("sfnt: invalid cmap format 12 group"); + } + previous_end = end; for (std::uint32_t code = start; code <= end; ++code) { map(code, static_cast(start_glyph + (code - start))); } @@ -435,14 +501,14 @@ void SfntFont::read_name() { read_name_entry(t.substr(6 + static_cast(i) * 12))); } - const auto score_entry = [](const NameEntry &entry) { + const auto score_entry = [](const NameEntry &entry) -> std::uint32_t { // Only the full (4) and PostScript (6) names are usable; everything else // scores below the empty default so it is never selected. if (entry.name_id != NameId::full && entry.name_id != NameId::postscript) { return 0; } - const int score = (entry.name_id == NameId::postscript ? 10 : 0) + - (entry.utf16() ? 2 : 1); + const std::uint32_t score = (entry.name_id == NameId::postscript ? 10 : 0) + + (entry.utf16() ? 2 : 1); return score; }; @@ -450,10 +516,12 @@ void SfntFont::read_name() { if (best_entry != entries.end() && score_entry(*best_entry) > 0) { // name_local_offset is relative to the string storage (string_offset). - const std::string_view value = t.substr( - static_cast(string_offset) + best_entry->name_local_offset, - best_entry->name_length); - m_name = best_entry->utf16() ? read_utf16be(value) : read_latin1(value); + const std::size_t offset = + static_cast(string_offset) + best_entry->name_local_offset; + if (offset <= t.size()) { + const std::string_view value = t.substr(offset, best_entry->name_length); + m_name = best_entry->utf16() ? read_utf16be(value) : read_latin1(value); + } } } @@ -511,7 +579,7 @@ std::string SfntFont::write() const { if (tag == "cmap" || !is_kept_table(tag)) { continue; } - std::string data = m_data.substr(location.offset, location.length); + std::string data = table_data(location); if (tag == "hmtx") { // OTS rejects an `hmtx` shorter than `hhea` and `maxp` say. const std::size_t side_bearings = diff --git a/test/src/internal/font/sfnt_font.cpp b/test/src/internal/font/sfnt_font.cpp index a0bc2d417..d167ea362 100644 --- a/test/src/internal/font/sfnt_font.cpp +++ b/test/src/internal/font/sfnt_font.cpp @@ -5,7 +5,9 @@ #include +#include #include +#include #include #include #include @@ -312,3 +314,103 @@ TEST(SfntFont, write_pads_a_short_hmtx) { EXPECT_EQ(hmtx_length, 2 * 4 + 3 * 2); EXPECT_EQ(SfntFont(written).advance_width(4), 600); } + +TEST(SfntFont, rejects_invalid_headers_and_clips_quirky_ranges) { + EXPECT_THROW(SfntFont(std::string(12, '\0')), std::runtime_error); + std::string collection("ttcf"); + collection.resize(16, '\0'); + EXPECT_THROW(SfntFont{collection}, std::runtime_error); + + std::string bytes = sample_font(cmap_table(3, 1, cmap_format4('A', 3))); + std::string clipped = bytes; + bs::write_u32_be(clipped, 24, static_cast(bytes.size())); + EXPECT_EQ(SfntFont(clipped).glyph_for_code_point('B'), 2); + bs::write_u32_be(bytes, 20, static_cast(bytes.size() + 1)); + EXPECT_THROW(SfntFont{bytes}, std::runtime_error); + + std::string head = head_table(); + bs::write_u16_be(head, 18, 0); + EXPECT_THROW(SfntFont(build_sfnt({{"head", head}})), std::runtime_error); + + std::string name = name_table("A"); + bs::write_u16_be(name, 14, 4); + EXPECT_EQ(SfntFont(build_sfnt({{"name", name}})).name(), "A"); + bs::write_u16_be(name, 14, 1); + EXPECT_EQ(SfntFont(build_sfnt({{"name", name}})).name(), ""); +} + +TEST(SfntFont, drops_an_invalid_cmap_and_keeps_the_font) { + const std::string valid = cmap_format12(0x1f600, 2); + const std::array, 5> patches{ + {{20, std::numeric_limits::max()}, + {20, 0x1f5ff}, + {24, 0xFFFF}, + {12, 2}, + {4, 16}}}; + for (const auto &[offset, value] : patches) { + std::string subtable = valid; + bs::write_u32_be(subtable, offset, value); + const SfntFont font(sample_font(cmap_table(3, 10, subtable))); + EXPECT_TRUE(font.cmap().empty()); + EXPECT_EQ(font.advance_width(1), 600); + } + std::string overlapping = valid; + overlapping += valid.substr(16); + bs::write_u32_be(overlapping, 4, + static_cast(overlapping.size())); + bs::write_u32_be(overlapping, 12, 2); + EXPECT_TRUE( + SfntFont(sample_font(cmap_table(3, 10, overlapping))).cmap().empty()); + + std::string short_subtable = cmap_format4('A', 3); + bs::write_u16_be(short_subtable, 2, 16); + EXPECT_TRUE( + SfntFont(sample_font(cmap_table(3, 1, short_subtable))).cmap().empty()); + short_subtable = cmap_format4('A', 3); + bs::write_u16_be(short_subtable, 6, 3); + EXPECT_TRUE( + SfntFont(sample_font(cmap_table(3, 1, short_subtable))).cmap().empty()); +} + +TEST(SfntFont, skips_a_format4_segment_out_of_order) { + std::string subtable = cmap_format4('A', 3); + bs::write_u16_be(subtable, 14, 'A' - 1); // endCode[0] below startCode[0] + const SfntFont font(sample_font(cmap_table(3, 1, subtable))); + EXPECT_EQ(font.glyph_for_code_point('A'), 0); +} + +namespace { + +/// One cmap with a (0, 6) record for @p first and a (3, 1) record for a valid +/// format-4 subtable after it. +std::string cmap_with_fallback(const std::string &first) { + std::string cmap; + bs::put_u16_be(cmap, 0); + bs::put_u16_be(cmap, 2); + bs::put_u16_be(cmap, 0); + bs::put_u16_be(cmap, 6); // Unicode full repertoire. + bs::put_u32_be(cmap, 20); + bs::put_u16_be(cmap, 3); + bs::put_u16_be(cmap, 1); + bs::put_u32_be(cmap, static_cast(20 + first.size())); + cmap += first; + std::string supported = cmap_format4('A', 3); + bs::write_u16_be(supported, 30, 0xFFFF); // The terminator offset is unused. + cmap += supported; + return cmap; +} + +} // namespace + +TEST(SfntFont, unsupported_cmap_does_not_hide_a_supported_one) { + std::string unsupported; + bs::put_u16_be(unsupported, 13); + EXPECT_EQ(SfntFont(sample_font(cmap_with_fallback(unsupported))) + .glyph_for_code_point('B'), + 2); + std::string broken = cmap_format12(0x1f600, 2); + bs::write_u32_be(broken, 20, 0x1f5ff); + EXPECT_EQ(SfntFont(sample_font(cmap_with_fallback(broken))) + .glyph_for_code_point('B'), + 2); +}