Commit 6b2968d6 for tesseract

commit 6b2968d65cbe62799c2ae316fc00566a07e712c6
Author: Stefan Weil <sw@weilnetz.de>
Date:   Fri Nov 13 17:30:50 2026 +0100

    classify: reject an inttemp class with too many protos

    ReadIntTemplates() allocates ProtoLengths and ProtoSets[] from
    NumProtoSets, but the adaption code (FindGoodProtos, AddIntProto)
    indexes them by the proto id up to NumProtos. A file with NumProtos >
    NumProtoSets * PROTOS_PER_PROTO_SET therefore caused accesses out of
    bounds.

    Add this condition to the existing check of the counts of a class.

    Also avoid a division by a proto length of 0, which can come from a
    file, too, in FindGoodProtos() and DisplayProtoDebugInfo().

    Add unit tests.

    Based on the fix proposed by the Anthropic OSS Scanner
    (finding ANT-2026-5F5B0BAI).

    Assisted-by: OpenCode / Claude Fable 5.2 (Anthropic)
    Signed-off-by: Stefan Weil <sw@weilnetz.de>

diff --git a/src/classify/intmatcher.cpp b/src/classify/intmatcher.cpp
index d4b81c56..d374f146 100644
--- a/src/classify/intmatcher.cpp
+++ b/src/classify/intmatcher.cpp
@@ -580,7 +580,10 @@ int IntegerMatcher::FindGoodProtos(INT_CLASS_STRUCT *ClassTemplate, BIT_VECTOR P
       Temp += tables->proto_evidence_[proto][i];
     }

-    Temp /= ClassTemplate->ProtoLengths[proto];
+    // ConvertProto never stores a length below 1, but a templates file can.
+    if (ClassTemplate->ProtoLengths[proto] > 0) {
+      Temp /= ClassTemplate->ProtoLengths[proto];
+    }

     /* Find Good Protos */
     if (Temp >= AdaptProtoThreshold) {
@@ -1005,7 +1008,9 @@ void IntegerMatcher::DisplayProtoDebugInfo(INT_CLASS_STRUCT *ClassTemplate, BIT_
         temp += tables.proto_evidence_[ActualProtoNum][i];
       }

-      temp /= ClassTemplate->ProtoLengths[ActualProtoNum];
+      if (ClassTemplate->ProtoLengths[ActualProtoNum] > 0) {
+        temp /= ClassTemplate->ProtoLengths[ActualProtoNum];
+      }

       if ((ProtoSet->Protos[ProtoNum]).Configs[0] & (*ConfigMask)) {
         DisplayIntProto(ClassTemplate, ActualProtoNum, temp / 255.0);
diff --git a/src/classify/intproto.cpp b/src/classify/intproto.cpp
index 5672de0e..33a29d96 100644
--- a/src/classify/intproto.cpp
+++ b/src/classify/intproto.cpp
@@ -832,9 +832,11 @@ INT_TEMPLATES_STRUCT *Classify::ReadIntTemplates(TFile *fp) {
     unsigned num_configs = version_id < 4 ? MaxNumConfigs : Class->NumConfigs;
     // Class->NumProtoSets is used as a loop bound that writes into the
     // fixed-size ProtoSets[] array, so reject a corrupt or malicious file
-    // instead of writing out of bounds.
+    // instead of writing out of bounds. ProtoLengths and ProtoSets[] are
+    // sized from NumProtoSets but indexed by proto id up to NumProtos (see
+    // FindGoodProtos and AddIntProto), so the protos must fit in the sets.
     if (Class->NumProtos > MAX_NUM_PROTOS || Class->NumProtoSets > MAX_NUM_PROTO_SETS ||
-        num_configs > MaxNumConfigs) {
+        Class->NumProtos > MaxNumIntProtosIn(Class) || num_configs > MaxNumConfigs) {
       tprintf("Error: invalid counts for class %u in inttemp: NumProtos=%u, NumProtoSets=%u, "
               "NumConfigs=%u\n",
               i, Class->NumProtos, Class->NumProtoSets, Class->NumConfigs);
diff --git a/unittest/intproto_test.cc b/unittest/intproto_test.cc
index e4d57567..ee5b6e20 100644
--- a/unittest/intproto_test.cc
+++ b/unittest/intproto_test.cc
@@ -6,7 +6,8 @@
 //              NumProtoSets) are read from the untrusted file and used
 //              as loop bounds that write into fixed-size arrays in
 //              Classify::ReadIntTemplates, so they must be validated
-//              before use.
+//              before use. The same goes for the NumProtos of each class,
+//              which indexes arrays that are sized from NumProtoSets.
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -19,7 +20,9 @@

 #include <tesseract/baseapi.h>

+#include "classify.h"     // for Classify (ReadIntTemplates)
 #include "intproto.h"     // for MAX_NUM_CLASS_PRUNERS, MAX_NUM_CLASSES
+#include "serialis.h"     // for TFile
 #include "tessdatamanager.h"
 #include "util/tempdir.h" // for CreateTempDir, RemoveTempDir

@@ -44,7 +47,7 @@ const char kMinUnicharset[] =
 //   uint8 NumConfigs.
 std::vector<char> MakeInttemp(int32_t version_id, uint32_t num_class_pruners,
                               uint32_t num_classes, uint32_t unicharset_size,
-                              uint8_t num_proto_sets = 0) {
+                              uint8_t num_proto_sets = 0, uint16_t num_protos = 0) {
   std::vector<char> data;
   auto append = [&data](const void *p, size_t n) {
     const char *b = static_cast<const char *>(p);
@@ -55,7 +58,6 @@ std::vector<char> MakeInttemp(int32_t version_id, uint32_t num_class_pruners,
   append(&num_class_pruners, sizeof(num_class_pruners));
   append(&num_classes, sizeof(num_classes));
   for (uint32_t c = 0; c < num_classes && c < MAX_NUM_CLASSES; ++c) {
-    uint16_t num_protos = 0;
     uint8_t num_configs = 0;
     append(&num_protos, sizeof(num_protos));
     append(&num_proto_sets, sizeof(num_proto_sets));
@@ -64,6 +66,18 @@ std::vector<char> MakeInttemp(int32_t version_id, uint32_t num_class_pruners,
   return data;
 }

+// Builds a complete inttemp component in the version 3 on-disk layout (the
+// last one without font tables): no class pruner and one class with the given
+// NumProtos and NumProtoSets, followed by its MAX_NUM_CONFIGS config lengths,
+// its NumProtoSets * PROTOS_PER_PROTO_SET proto lengths and its proto sets,
+// all zero.
+std::vector<char> MakeInttempWithProtos(uint16_t num_protos, uint8_t num_proto_sets) {
+  std::vector<char> data = MakeInttemp(-3, 0, 1, 1, num_proto_sets, num_protos);
+  data.resize(data.size() + MAX_NUM_CONFIGS * sizeof(uint16_t) +
+              num_proto_sets * (PROTOS_PER_PROTO_SET + sizeof(PROTO_SET_STRUCT)));
+  return data;
+}
+
 // Builds an inttemp component in the old (version 1) on-disk layout. Besides
 // the common header this carries the IndexFor[] table (unicharset_size x
 // int16), the ClassIdFor[] table (num_classes x int32) and one class pruner
@@ -117,6 +131,17 @@ bool WriteCorruptTraineddata(const std::string &dir, const std::vector<char> &in
   return mgr.SaveFile((dir + "/eng.traineddata").c_str(), nullptr);
 }

+// Returns whether Classify::ReadIntTemplates accepts the given component.
+bool ReadsInttemp(const std::vector<char> &inttemp) {
+  Classify classifier;
+  TFile fp;
+  fp.Open(inttemp.data(), inttemp.size());
+  INT_TEMPLATES_STRUCT *templates = classifier.ReadIntTemplates(&fp);
+  const bool accepted = templates != nullptr;
+  delete templates;
+  return accepted;
+}
+
 class IntprotoTest : public testing::Test {
 protected:
   void SetUp() override {
@@ -154,6 +179,24 @@ TEST_F(IntprotoTest, RejectsTooManyProtoSets) {
   ExpectInitFails(MakeInttemp(-5, 0, 1, 1, MAX_NUM_PROTO_SETS + 1));
 }

+// A class with more protos than its proto sets hold: ProtoLengths and
+// ProtoSets[] are sized from NumProtoSets, but the adaption code
+// (IntegerMatcher::FindGoodProtos, AddIntProto) indexes them by proto id up
+// to NumProtos.
+TEST_F(IntprotoTest, RejectsMoreProtosThanProtoSetsHold) {
+  ExpectInitFails(MakeInttemp(-5, 0, 1, 1, 0, 1));
+  ExpectInitFails(MakeInttemp(-5, 0, 1, 1, 1, PROTOS_PER_PROTO_SET + 1));
+  ExpectInitFails(MakeInttemp(-5, 0, 1, 1, 1, MAX_NUM_PROTOS));
+  EXPECT_FALSE(ReadsInttemp(MakeInttempWithProtos(PROTOS_PER_PROTO_SET + 1, 1)));
+}
+
+// A class that uses every place of its proto sets must still be accepted.
+TEST_F(IntprotoTest, AcceptsFullProtoSets) {
+  EXPECT_TRUE(ReadsInttemp(MakeInttempWithProtos(0, 0)));
+  EXPECT_TRUE(ReadsInttemp(MakeInttempWithProtos(PROTOS_PER_PROTO_SET, 1)));
+  EXPECT_TRUE(ReadsInttemp(MakeInttempWithProtos(MAX_NUM_PROTOS, MAX_NUM_PROTO_SETS)));
+}
+
 // unicharset_size exceeds MAX_NUM_CLASSES: the version < 2 class-id-index
 // read would write past the end of IndexFor[].
 TEST_F(IntprotoTest, RejectsTooLargeUnicharsetSize) {