diff --git a/src/crypto/Signer.cpp b/src/crypto/Signer.cpp index ea40f8f7c..80c938bec 100644 --- a/src/crypto/Signer.cpp +++ b/src/crypto/Signer.cpp @@ -24,6 +24,7 @@ #include "Conf.h" #include "crypto/Digest.h" #include "crypto/X509Cert.h" +#include "util/XMLText.h" #include "util/log.h" #include @@ -35,6 +36,17 @@ using namespace digidoc; using namespace std; +static void requireXMLText(string_view value, string_view field) +{ + const util::XMLTextError error = util::validateXMLText(value); + if(!error) + return; + if(error.reason == util::XMLTextError::Reason::InvalidUTF8) + THROW("Invalid UTF-8 at byte offset %zu in %.*s", error.offset, int(field.size()), field.data()); + THROW("XML 1.0 character U+%04X at byte offset %zu is not allowed in %.*s", + unsigned(error.codePoint), error.offset, int(field.size()), field.data()); +} + class Signer::Private { public: @@ -73,10 +85,17 @@ Signer::~Signer() = default; * @param stateOrProvince * @param postalCode * @param countryName + * The strings must be UTF-8 encoded and contain only XML 1.0 characters. + * @throws Exception if a string is not valid UTF-8 or contains a character disallowed by XML 1.0. */ void Signer::setSignatureProductionPlace(const string &city, const string &stateOrProvince, const string &postalCode, const string &countryName) { + requireXMLText(city, "signature production city"); + requireXMLText(stateOrProvince, "signature production state or province"); + requireXMLText(postalCode, "signature production postal code"); + requireXMLText(countryName, "signature production country name"); + d->city = city; d->stateOrProvince = stateOrProvince; d->postalCode = postalCode; @@ -91,10 +110,18 @@ void Signer::setSignatureProductionPlace(const string &city, * @param stateOrProvince * @param postalCode * @param countryName + * The strings must be UTF-8 encoded and contain only XML 1.0 characters. + * @throws Exception if a string is not valid UTF-8 or contains a character disallowed by XML 1.0. */ void Signer::setSignatureProductionPlaceV2(const string &city, const string &streetAddress, const string &stateOrProvince, const string &postalCode, const string &countryName) { + requireXMLText(city, "signature production city"); + requireXMLText(streetAddress, "signature production street address"); + requireXMLText(stateOrProvince, "signature production state or province"); + requireXMLText(postalCode, "signature production postal code"); + requireXMLText(countryName, "signature production country name"); + if(!streetAddress.empty()) setENProfile(true); d->city = city; @@ -197,9 +224,14 @@ void Signer::setProfile(const string &profile) /** * Sets signature roles according XAdES standard. The parameter may contain the signer’s role and optionally the signer’s resolution. Note that only one signer role value (i.e. one <ClaimedRole> XML element) should be used. * If the signer role contains both role and resolution then they must be separated with a slash mark, e.g. “role / resolution”. + * The strings must be UTF-8 encoded and contain only XML 1.0 characters. + * @throws Exception if a string is not valid UTF-8 or contains a character disallowed by XML 1.0. */ void Signer::setSignerRoles(const vector &signerRoles) { + for(const string &role: signerRoles) + requireXMLText(role, "signer role"); + d->signerRoles = signerRoles; } diff --git a/src/util/XMLText.h b/src/util/XMLText.h new file mode 100644 index 000000000..747103dce --- /dev/null +++ b/src/util/XMLText.h @@ -0,0 +1,99 @@ +/* + * libdigidocpp + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +#pragma once + +#include +#include +#include + +namespace digidoc::util +{ +struct XMLTextError +{ + enum class Reason + { + None, + InvalidUTF8, + InvalidXMLCharacter + }; + + constexpr explicit operator bool() const noexcept { return reason != Reason::None; } + + Reason reason {Reason::None}; + size_t offset {}; + uint32_t codePoint {}; +}; + +inline XMLTextError validateXMLText(std::string_view text) noexcept +{ + for(size_t offset = 0; offset < text.size();) + { + const auto lead = static_cast(text[offset]); + uint32_t codePoint {}; + size_t length {}; + uint32_t minimum {}; + if(lead <= 0x7F) + { + codePoint = lead; + length = 1; + } + else if(lead >= 0xC2 && lead <= 0xDF) + { + codePoint = lead & 0x1F; + length = 2; + minimum = 0x80; + } + else if(lead >= 0xE0 && lead <= 0xEF) + { + codePoint = lead & 0x0F; + length = 3; + minimum = 0x800; + } + else if(lead >= 0xF0 && lead <= 0xF4) + { + codePoint = lead & 0x07; + length = 4; + minimum = 0x10000; + } + else + { + return {XMLTextError::Reason::InvalidUTF8, offset}; + } + + if(length > text.size() - offset) + return {XMLTextError::Reason::InvalidUTF8, offset}; + for(size_t i = 1; i < length; ++i) + { + const auto continuation = static_cast(text[offset + i]); + if((continuation & 0xC0) != 0x80) + return {XMLTextError::Reason::InvalidUTF8, offset}; + codePoint = (codePoint << 6) | (continuation & 0x3F); + } + if(codePoint < minimum || codePoint > 0x10FFFF || + (codePoint >= 0xD800 && codePoint <= 0xDFFF)) + return {XMLTextError::Reason::InvalidUTF8, offset}; + if(codePoint != 0x09 && codePoint != 0x0A && codePoint != 0x0D && + (codePoint < 0x20 || codePoint == 0xFFFE || codePoint == 0xFFFF)) + return {XMLTextError::Reason::InvalidXMLCharacter, offset, codePoint}; + offset += length; + } + return {}; +} +} diff --git a/test/libdigidocpp_boost.cpp b/test/libdigidocpp_boost.cpp index 5a1327fce..27ca020d8 100644 --- a/test/libdigidocpp_boost.cpp +++ b/test/libdigidocpp_boost.cpp @@ -30,6 +30,7 @@ #include #include #include +#include namespace digidoc { @@ -115,6 +116,30 @@ BOOST_AUTO_TEST_CASE(signerParameters) }; BOOST_CHECK_EQUAL(signature, sig); } + +BOOST_AUTO_TEST_CASE(signerXMLTextValidation) +{ + PKCS12Signer signer{"signer1.p12", "signer1"}; + signer.setSignerRoles({"Role1"}); + signer.setSignatureProductionPlaceV2("Tartu", "Street", "Tartumaa", "12345", "Estonia"); + + const string invalidControl{"Role\x1B", 5}; + BOOST_CHECK_THROW(signer.setSignerRoles({invalidControl}), Exception); + BOOST_CHECK_EQUAL(signer.signerRoles(), vector{"Role1"}); + BOOST_CHECK_THROW(signer.setSignatureProductionPlace( + invalidControl, "Tartumaa", "12345", "Estonia"), Exception); + BOOST_CHECK_EQUAL(signer.city(), "Tartu"); + + const string invalidUTF8{"Street\xED\xA0\x80", 9}; + BOOST_CHECK_THROW(signer.setSignatureProductionPlaceV2( + "Tartu", invalidUTF8, "Tartumaa", "12345", "Estonia"), Exception); + BOOST_CHECK_EQUAL(signer.streetAddress(), "Street"); + + // Signer metadata is raw UTF-8. XML-looking input is literal text and will + // be escaped by libxml2 when it is eventually inserted into the document. + BOOST_CHECK_NO_THROW(signer.setSignerRoles({""})); + BOOST_CHECK_EQUAL(signer.signerRoles(), vector{""}); +} BOOST_AUTO_TEST_SUITE_END() BOOST_AUTO_TEST_SUITE(X509CertSuite) @@ -706,6 +731,89 @@ BOOST_AUTO_TEST_CASE(WrapContainerWithExpiredSignatures) BOOST_AUTO_TEST_SUITE_END() BOOST_AUTO_TEST_SUITE(XMLTestSuite) +BOOST_AUTO_TEST_CASE(XMLTextValidation) +{ + const auto invalidCharacter = util::validateXMLText(string{"A\x1B" "B", 3}); + BOOST_REQUIRE(invalidCharacter); + BOOST_CHECK(invalidCharacter.reason == util::XMLTextError::Reason::InvalidXMLCharacter); + BOOST_CHECK_EQUAL(invalidCharacter.offset, 1U); + BOOST_CHECK_EQUAL(invalidCharacter.codePoint, 0x1BU); + + for(unsigned char value = 0; value < 0x20; ++value) + { + const auto error = util::validateXMLText(string{char(value)}); + if(value == 0x09 || value == 0x0A || value == 0x0D) + BOOST_CHECK(!error); + else + BOOST_CHECK(error.reason == util::XMLTextError::Reason::InvalidXMLCharacter); + } + + const string invalidUTF8{"A\xED\xA0\x80" "B", 5}; // UTF-8 encoded surrogate U+D800 + const auto invalidEncoding = util::validateXMLText(invalidUTF8); + BOOST_REQUIRE(invalidEncoding); + BOOST_CHECK(invalidEncoding.reason == util::XMLTextError::Reason::InvalidUTF8); + BOOST_CHECK_EQUAL(invalidEncoding.offset, 1U); + + const string legal = "\t\n\r\x7F" + "\xED\x9F\xBF" // U+D7FF + "\xEE\x80\x80" // U+E000 + "\xEF\xBF\xBD" // U+FFFD + "\xF0\x90\x80\x80" // U+10000 + "\xF4\x8F\xBF\xBF"; // U+10FFFF + BOOST_CHECK(!util::validateXMLText(legal)); + + const string invalid[] = { + string{"\0", 1}, + string{"\xC0\x80", 2}, // Overlong U+0000 + string{"\x80", 1}, // Isolated continuation byte + string{"\xE2\x82", 2}, // Truncated sequence + string{"\xF4\x90\x80\x80", 4}, // Above U+10FFFF + string{"\xEF\xBF\xBE", 3}, // U+FFFE + string{"\xEF\xBF\xBF", 3} // U+FFFF + }; + for(const string &value: invalid) + BOOST_CHECK(util::validateXMLText(value)); +} + +BOOST_AUTO_TEST_CASE(XMLTextEscapingAndParsing) +{ + const string raw = "Jüri & Mari \"'"; + auto doc = XMLDocument::create("root"); + XMLNode root = doc; + BOOST_CHECK_NO_THROW(root = raw); + + string serialized; + BOOST_REQUIRE(doc.save([&serialized](const char *data, size_t size) { + serialized.append(data, size); + })); + BOOST_CHECK(serialized.contains("&")); + BOOST_CHECK(serialized.contains("<Manager>")); + + istringstream input{serialized}; + auto parsed = XMLDocument::openStream(input, {"root"}); + BOOST_CHECK_EQUAL(string(string_view(parsed)), raw); + + // The API accepts raw UTF-8, not pre-escaped XML. A character-reference-like + // string is escaped as literal text and cannot smuggle an illegal character. + root = ""; + serialized.clear(); + BOOST_REQUIRE(doc.save([&serialized](const char *data, size_t size) { + serialized.append(data, size); + })); + BOOST_CHECK(serialized.contains("&#x1B;")); + + istringstream encodedInput{serialized}; + parsed = XMLDocument::openStream(encodedInput, {"root"}); + BOOST_CHECK_EQUAL(string(string_view(parsed)), ""); + + istringstream legalReference{"ä"}; + parsed = XMLDocument::openStream(legalReference, {"root"}); + BOOST_CHECK_EQUAL(string(string_view(parsed)), "ä"); + + istringstream illegalReference{""}; + BOOST_CHECK_THROW(XMLDocument::openStream(illegalReference, {"root"}), Exception); +} + BOOST_AUTO_TEST_CASE(XMLBomb) { BOOST_CHECK_THROW(XMLDocument("xml-bomb-attr.xml"), Exception);