From eced3893d28e230d2c5ca5176f818f6d519605e2 Mon Sep 17 00:00:00 2001 From: Karan Yadav Date: Fri, 8 May 2026 20:31:36 +0530 Subject: [PATCH] fix: add element count limit per collection to prevent resource exhaustion GetElementCollection, GetElementCollectionOfSingleType, and GetActionCollection loop all items in a JSON array with no cap. A card with 100,000 elements creates 100,000 parsed objects, leading to memory exhaustion and UI thread freeze when rendered. Adds c_maxElementsPerCollection (200) to ParseContext. Collections exceeding the limit are truncated with a parse warning. The reserve() call is also capped to prevent upfront over-allocation. Covers all 19 call sites: body, actions, columns, choices, facts, images, table rows/cells, carousel pages, inlines, etc. --- .../ObjectModelTest.cpp | 85 +++++++++++++++++++ source/shared/cpp/ObjectModel/ParseContext.h | 3 + source/shared/cpp/ObjectModel/ParseUtil.cpp | 12 ++- source/shared/cpp/ObjectModel/ParseUtil.h | 23 ++++- 4 files changed, 120 insertions(+), 3 deletions(-) diff --git a/source/shared/cpp/AdaptiveCardsSharedModel/AdaptiveCardsSharedModelUnitTest/ObjectModelTest.cpp b/source/shared/cpp/AdaptiveCardsSharedModel/AdaptiveCardsSharedModelUnitTest/ObjectModelTest.cpp index d79ed1379b..79854eda74 100644 --- a/source/shared/cpp/AdaptiveCardsSharedModel/AdaptiveCardsSharedModelUnitTest/ObjectModelTest.cpp +++ b/source/shared/cpp/AdaptiveCardsSharedModel/AdaptiveCardsSharedModelUnitTest/ObjectModelTest.cpp @@ -739,5 +739,90 @@ namespace AdaptiveCardsSharedModelUnitTest const auto serializedCard = card->SerializeToJsonValue(); Assert::IsTrue(serializedCard["body"][0]["isMultiline"].asBool()); } + + // Helper: build a card JSON with N TextBlocks in the body + static std::string MakeCardWithNElements(unsigned int count) + { + std::string json = R"({"type":"AdaptiveCard","version":"1.5","body":[)"; + for (unsigned int i = 0; i < count; i++) + { + if (i > 0) json += ","; + json += R"({"type":"TextBlock","text":"Item )" + std::to_string(i) + R"("})"; + } + json += "]}"; + return json; + } + + // Helper: build a card JSON with N OpenUrl actions + static std::string MakeCardWithNActions(unsigned int count) + { + std::string json = R"({"type":"AdaptiveCard","version":"1.5","body":[{"type":"TextBlock","text":"hi"}],"actions":[)"; + for (unsigned int i = 0; i < count; i++) + { + if (i > 0) json += ","; + json += R"({"type":"Action.OpenUrl","title":"L)" + std::to_string(i) + R"(","url":"https://x.com"})"; + } + json += "]}"; + return json; + } + + TEST_METHOD(ElementCollection_WithinLimit_ParsesFully) + { + const auto json = MakeCardWithNElements(10); + const auto result = AdaptiveCard::DeserializeFromString(json, "1.5"); + Assert::AreEqual(10ui64, result->GetAdaptiveCard()->GetBody().size()); + } + + TEST_METHOD(ElementCollection_AtLimit_ParsesFully) + { + const unsigned int limit = ParseContext::c_maxElementsPerCollection; + const auto json = MakeCardWithNElements(limit); + const auto result = AdaptiveCard::DeserializeFromString(json, "1.5"); + Assert::AreEqual(static_cast(limit), result->GetAdaptiveCard()->GetBody().size()); + } + + TEST_METHOD(ElementCollection_ExceedsLimit_CappedWithWarning) + { + const unsigned int limit = ParseContext::c_maxElementsPerCollection; + const auto json = MakeCardWithNElements(limit + 50); + const auto result = AdaptiveCard::DeserializeFromString(json, "1.5"); + + // Body should be capped at the limit + Assert::AreEqual(static_cast(limit), result->GetAdaptiveCard()->GetBody().size()); + + // Should have a warning about it + bool foundWarning = false; + for (const auto& w : result->GetWarnings()) + { + if (w->GetReason().find("Maximum number of elements") != std::string::npos) + { + foundWarning = true; + break; + } + } + Assert::IsTrue(foundWarning, L"Expected warning about element collection limit"); + } + + TEST_METHOD(ActionCollection_ExceedsLimit_CappedWithWarning) + { + const unsigned int limit = ParseContext::c_maxElementsPerCollection; + const auto json = MakeCardWithNActions(limit + 50); + const auto result = AdaptiveCard::DeserializeFromString(json, "1.5"); + + // Actions should be capped + Assert::IsTrue(result->GetAdaptiveCard()->GetActions().size() <= static_cast(limit)); + + // Should have a warning + bool foundWarning = false; + for (const auto& w : result->GetWarnings()) + { + if (w->GetReason().find("Maximum number of actions") != std::string::npos) + { + foundWarning = true; + break; + } + } + Assert::IsTrue(foundWarning, L"Expected warning about action collection limit"); + } }; } diff --git a/source/shared/cpp/ObjectModel/ParseContext.h b/source/shared/cpp/ObjectModel/ParseContext.h index c734a52103..6b6fe49a9b 100644 --- a/source/shared/cpp/ObjectModel/ParseContext.h +++ b/source/shared/cpp/ObjectModel/ParseContext.h @@ -52,6 +52,9 @@ class ParseContext void RemoveProhibitedElementType(const std::vector& list); void ShouldParse(const std::string& type); + // Max items allowed in a single collection (body, actions, columns, etc.) + static constexpr unsigned int c_maxElementsPerCollection = 200; + private: const AdaptiveCards::InternalId GetNearestFallbackId(const AdaptiveCards::InternalId& skipId) const; // This enum is just a helper to keep track of the position of contents within the std::tuple used in diff --git a/source/shared/cpp/ObjectModel/ParseUtil.cpp b/source/shared/cpp/ObjectModel/ParseUtil.cpp index 1b6c8c190b..23fdd822f8 100644 --- a/source/shared/cpp/ObjectModel/ParseUtil.cpp +++ b/source/shared/cpp/ObjectModel/ParseUtil.cpp @@ -455,10 +455,20 @@ std::vector> ParseUtil::GetActionCollection( return elements; } - elements.reserve(elementArray.size()); + const size_t maxElements = static_cast(ParseContext::c_maxElementsPerCollection); + elements.reserve(std::min(static_cast(elementArray.size()), maxElements)); for (const auto& curJsonValue : elementArray) { + // Cap the number of actions to prevent resource exhaustion + if (elements.size() >= maxElements) + { + context.warnings.emplace_back( + std::make_shared(WarningStatusCode::CustomWarning, + "Maximum number of actions in a collection exceeded; remaining items were dropped")); + break; + } + auto action = ParseUtil::GetActionFromJsonValue(context, curJsonValue); if (action != nullptr) { diff --git a/source/shared/cpp/ObjectModel/ParseUtil.h b/source/shared/cpp/ObjectModel/ParseUtil.h index 5178ee0d7e..08c1743c02 100644 --- a/source/shared/cpp/ObjectModel/ParseUtil.h +++ b/source/shared/cpp/ObjectModel/ParseUtil.h @@ -215,11 +215,21 @@ std::vector> ParseUtil::GetElementCollectionOfSingleType( return elements; } - elements.reserve(elementArray.size()); + const size_t maxElements = static_cast(ParseContext::c_maxElementsPerCollection); + elements.reserve(std::min(static_cast(elementArray.size()), maxElements)); // Deserialize every element in the array for (const Json::Value& curJsonValue : elementArray) { + // Cap the number of elements to prevent resource exhaustion + if (elements.size() >= maxElements) + { + context.warnings.emplace_back( + std::make_shared(WarningStatusCode::CustomWarning, + "Maximum number of elements in a collection exceeded; remaining items were dropped")); + break; + } + // Parse the element auto el = deserializer(context, curJsonValue); if (el != nullptr) @@ -280,13 +290,22 @@ std::vector> ParseUtil::GetElementCollection( } const size_t elemSize = elementArray.size(); - elements.reserve(elemSize); + const size_t maxElements = static_cast(ParseContext::c_maxElementsPerCollection); + elements.reserve(std::min(elemSize, maxElements)); const ContainerBleedDirection previousBleedState = context.GetBleedDirection(); size_t currentIndex = 0; for (auto& curJsonValue : elementArray) { + // Cap the number of elements to prevent resource exhaustion + if (elements.size() >= maxElements) + { + context.warnings.emplace_back( + std::make_shared(WarningStatusCode::CustomWarning, + "Maximum number of elements in a collection exceeded; remaining items were dropped")); + break; + } ContainerBleedDirection currentBleedState = previousBleedState; if (currentIndex != 0)