Page MenuHomePhorge

D363.1788949974.diff
No OneTemporary

Size
18 KB
Referenced Files
None
Subscribers
None

D363.1788949974.diff

diff --git a/CMakeLists.txt b/CMakeLists.txt
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -89,6 +89,8 @@
find_package(OpenSSL REQUIRED)
endif()
+find_package(lexbor REQUIRED)
+
if(${kazv_ENABLE_APPIUM_TESTS} STREQUAL "")
find_package(SeleniumWebDriverATSPI)
if(SeleniumWebDriverATSPI_FOUND)
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -36,6 +36,8 @@
configure_file(kazv-platform.hpp.in kazv-platform.hpp)
configure_file(kazv-defs.hpp.in kazv-defs.hpp)
+add_subdirectory(html-sanitizer)
+
set(kazvqmlmodule_SRCS
qt-job-handler.cpp
qt-job.cpp
@@ -144,6 +146,7 @@
OpenSSL::SSL
)
endif()
+target_link_libraries(kazvqmlmodule PRIVATE kazvsanitizer)
target_include_directories(kazvqmlmodule PUBLIC ${CMAKE_CURRENT_BINARY_DIR})
target_include_directories(kazvqmlmodule PUBLIC ${CMAKE_CURRENT_SOURCE_DIR} ${CMAKE_CURRENT_SOURCE_DIR}/device-mgmt)
diff --git a/src/contents/ui/event-types/TextTemplate.qml b/src/contents/ui/event-types/TextTemplate.qml
--- a/src/contents/ui/event-types/TextTemplate.qml
+++ b/src/contents/ui/event-types/TextTemplate.qml
@@ -116,13 +116,8 @@
}
</style>
`;
- const formattedBody = event.content.formatted_body;
- if (event.replyingToEventId && formattedBody.startsWith('<mx-reply>')) {
- const index = formattedBody.indexOf('</mx-reply>');
- return stylesheet + formattedBody.slice(index + '</mx-reply>'.length);
- } else {
- return stylesheet + formattedBody;
- }
+ const formattedBody = MK.KazvUtil.sanitizeHtml(event.content.formatted_body);
+ return stylesheet + formattedBody;
} else {
return event.content.body;
}
diff --git a/src/html-sanitizer/CMakeLists.txt b/src/html-sanitizer/CMakeLists.txt
new file mode 100644
--- /dev/null
+++ b/src/html-sanitizer/CMakeLists.txt
@@ -0,0 +1,6 @@
+set(kazvsanitizer_SRCS
+ sanitize.cpp
+)
+add_library(kazvsanitizer STATIC ${kazvsanitizer_SRCS})
+target_link_libraries(kazvsanitizer PRIVATE lexbor::lexbor)
+target_include_directories(kazvsanitizer INTERFACE ${CMAKE_CURRENT_SOURCE_DIR})
diff --git a/src/html-sanitizer/sanitize.hpp b/src/html-sanitizer/sanitize.hpp
new file mode 100644
--- /dev/null
+++ b/src/html-sanitizer/sanitize.hpp
@@ -0,0 +1,11 @@
+/*
+ * This file is part of kazv.
+ * SPDX-FileCopyrightText: 2026 tusooa <tusooa@kazv.moe>
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+#pragma once
+// #include <kazv-defs.hpp> intentionally omitted
+#include <string>
+
+std::string sanitizeHtml(const std::string &text);
diff --git a/src/html-sanitizer/sanitize.cpp b/src/html-sanitizer/sanitize.cpp
new file mode 100644
--- /dev/null
+++ b/src/html-sanitizer/sanitize.cpp
@@ -0,0 +1,289 @@
+/*
+ * This file is part of kazv.
+ * SPDX-FileCopyrightText: 2026 tusooa <tusooa@kazv.moe>
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+// #include <kazv-defs.hpp> intentionally omitted
+#include <lexbor/html/html.h>
+#include <lexbor/dom/dom.h>
+#include <memory>
+#include <functional>
+#include <variant>
+#include <set>
+#include <iterator>
+#include <cassert>
+#include <string>
+#include <string_view>
+
+using namespace std::string_literals;
+using namespace std::string_view_literals;
+
+namespace
+{
+ struct DocDeleter
+ {
+ void operator()(lxb_html_document_t *doc)
+ {
+ lxb_html_document_destroy(doc);
+ }
+ };
+
+ using DocUP = std::unique_ptr<lxb_html_document_t, DocDeleter>;
+
+ // Remove current node and promote all of its children to the parent
+ struct Promote {};
+ // Delete the subtree of the current node
+ struct DeleteAll {};
+ // Do not do anything with the current node. All changes have already been made
+ // by the visitor. Childrens will still be visited.
+ struct Nothing {};
+
+ using VisitResult = std::variant<
+ Promote, DeleteAll, Nothing
+ >;
+ using Visitor = std::function<VisitResult(lxb_dom_node_t *)>;
+
+ struct LexborAttributeIterator {
+ using difference_type = std::ptrdiff_t;
+ using value_type = lxb_dom_attr_t *;
+
+ value_type m_val;
+
+ value_type operator*() const
+ {
+ return m_val;
+ }
+
+ LexborAttributeIterator& operator++()
+ {
+ m_val = lxb_dom_element_next_attribute(m_val);
+ return *this;
+ }
+
+ void operator++(int)
+ {
+ ++*this;
+ }
+ };
+}
+
+// Matrix spec client-server api
+static const std::set<lxb_tag_id_t> allowedTags{
+ LXB_TAG__TEXT,
+ LXB_TAG_DEL,
+ LXB_TAG_H1,
+ LXB_TAG_H2,
+ LXB_TAG_H3,
+ LXB_TAG_H4,
+ LXB_TAG_H5,
+ LXB_TAG_H6,
+ LXB_TAG_BLOCKQUOTE,
+ LXB_TAG_P,
+ LXB_TAG_A,
+ LXB_TAG_UL,
+ LXB_TAG_OL,
+ LXB_TAG_SUP,
+ LXB_TAG_SUB,
+ LXB_TAG_LI,
+ LXB_TAG_B,
+ LXB_TAG_I,
+ LXB_TAG_U,
+ LXB_TAG_STRONG,
+ LXB_TAG_EM,
+ LXB_TAG_S,
+ LXB_TAG_CODE,
+ LXB_TAG_HR,
+ LXB_TAG_BR,
+ LXB_TAG_DIV,
+ LXB_TAG_TABLE,
+ LXB_TAG_THEAD,
+ LXB_TAG_TBODY,
+ LXB_TAG_TR,
+ LXB_TAG_TH,
+ LXB_TAG_TD,
+ LXB_TAG_CAPTION,
+ LXB_TAG_PRE,
+ LXB_TAG_SPAN,
+ LXB_TAG_IMG,
+ LXB_TAG_DETAILS,
+ LXB_TAG_SUMMARY,
+};
+
+template<class Pred>
+static void filterAttrs(lxb_dom_element_t *element, Pred pred)
+{
+ auto attr = lxb_dom_element_first_attribute(element);
+
+ while (attr) {
+ auto next = lxb_dom_element_next_attribute(attr);
+ std::size_t nameLen;
+ const lxb_char_t *name = lxb_dom_attr_local_name(attr, &nameLen);
+ auto nameView = std::string_view(reinterpret_cast<const char *>(name), nameLen);
+
+ std::size_t valueLen;
+ const lxb_char_t *value = lxb_dom_attr_value(attr, &valueLen);
+ auto valueView = std::string_view(reinterpret_cast<const char *>(value), valueLen);
+
+ if (!pred(nameView, valueView)) {
+ lxb_dom_element_remove_attribute(element, name, nameLen);
+ }
+
+ attr = next;
+ }
+}
+
+static VisitResult sanitizeVisitor(lxb_dom_node_t *node)
+{
+ auto tagId = lxb_dom_node_tag_id(node);
+ if (!(
+ lxb_dom_node_type(node) == LXB_DOM_NODE_TYPE_ELEMENT
+ || lxb_dom_node_type(node) == LXB_DOM_NODE_TYPE_TEXT
+ )) {
+ return DeleteAll{};
+ }
+ // validate tag names
+ if (!allowedTags.contains(tagId) || node->ns != LXB_NS_HTML) {
+ if (tagId >= LXB_TAG__LAST_ENTRY) {
+ std::size_t tagNameSize{0};
+ auto tagName = lxb_tag_name_by_id_noi(tagId, &tagNameSize);
+ auto tagNameStr = std::string_view(reinterpret_cast<const char *>(tagName), tagNameSize);
+ if (tagNameStr == "mx-reply") {
+ return DeleteAll{};
+ }
+ }
+ return Promote{};
+ }
+
+ // validate attributes
+ if (lxb_dom_node_type(node) == LXB_DOM_NODE_TYPE_ELEMENT) {
+ auto elem = lxb_dom_interface_element(node);
+ switch (tagId) {
+ case LXB_TAG_A:
+ filterAttrs(elem, [](const auto &name, const auto &value) {
+ return name == "target"
+ || (name == "href" && (
+ value.starts_with("https:")
+ || value.starts_with("http:")
+ || value.starts_with("ftp:")
+ || value.starts_with("mailto:")
+ || value.starts_with("magnet:")
+ // isn't in spec as of 1.19, but we should support it
+ // because we want to support matrix: uri handling
+ || value.starts_with("matrix:")
+ ));
+ });
+ break;
+
+ case LXB_TAG_SPAN:
+ filterAttrs(elem, [](const auto &name, [[maybe_unused]] const auto &value) {
+ return name == "data-mx-bg-color"
+ || name == "data-mx-color"
+ || name == "data-mx-spoiler"
+ || name == "data-mx-maths";
+ });
+ break;
+
+ case LXB_TAG_IMG:
+ filterAttrs(elem, [](const auto &name, const auto &value) {
+ return name == "width"
+ || name == "height"
+ || name == "alt"
+ || name == "title"
+ || (name == "src" && value.starts_with("mxc://"));
+ });
+ break;
+
+ case LXB_TAG_OL:
+ filterAttrs(elem, [](const auto &name, [[maybe_unused]] const auto &value) {
+ return name == "start";
+ });
+ break;
+
+ case LXB_TAG_CODE:
+ filterAttrs(elem, [](const auto &name, const auto &value) {
+ return name == "class" && value.starts_with("language-");
+ });
+ break;
+
+ case LXB_TAG_DIV:
+ filterAttrs(elem, [](const auto &name, [[maybe_unused]] const auto &value) {
+ return name == "data-mx-maths";
+ });
+ break;
+
+ default:
+ filterAttrs(elem, []([[maybe_unused]] const auto &name, [[maybe_unused]] const auto &value) {
+ return false;
+ });
+ break;
+ }
+ }
+ return Nothing{};
+}
+
+static lxb_dom_node_t *addAllChildren(lxb_dom_node_t *root, lxb_dom_node_t *insertBefore, lxb_dom_node_t *source)
+{
+ auto first = lxb_dom_node_first_child(source);
+ auto cur = first;
+ while (cur) {
+ auto next = lxb_dom_node_next(cur);
+ if (!insertBefore) {
+ auto r = lxb_dom_node_append_child(root, cur);
+ assert(r == LXB_DOM_EXCEPTION_OK);
+ } else {
+ auto r = lxb_dom_node_insert_before_spec(root, cur, insertBefore);
+ assert(r == LXB_DOM_EXCEPTION_OK);
+ }
+ cur = next;
+ }
+ return first;
+}
+
+static void sanitizeTraverse(lxb_dom_node_t *root, Visitor visit)
+{
+ auto cur = lxb_dom_node_first_child(root);
+ while (cur) {
+ auto res = std::invoke(visit, cur);
+ auto origNext = lxb_dom_node_next(cur);
+ auto next = origNext;
+ if (std::holds_alternative<Promote>(res)) {
+ auto r = lxb_dom_node_remove_child(root, cur);
+ assert(r == LXB_DOM_EXCEPTION_OK);
+ auto firstChild = addAllChildren(root, origNext, cur);
+ lxb_dom_node_destroy_deep(cur);
+ if (firstChild) {
+ next = firstChild;
+ }
+ } else if (std::holds_alternative<DeleteAll>(res)) {
+ auto r = lxb_dom_node_remove_child(root, cur);
+ assert(r == LXB_DOM_EXCEPTION_OK);
+ lxb_dom_node_destroy_deep(cur);
+ }
+ sanitizeTraverse(cur, visit);
+ cur = next;
+ }
+}
+
+std::string sanitizeHtml(const std::string &text)
+{
+ auto doc = DocUP(lxb_html_document_create());
+ if (!doc) {
+ return "";
+ }
+ lxb_dom_document_opt_set(lxb_dom_interface_document(doc.get()), LXB_DOM_DOCUMENT_OPT_WO_EVENTS);
+ if (lxb_html_document_parse(doc.get(), reinterpret_cast<const lxb_char_t *>(""), 0) != LXB_STATUS_OK) {
+ return "";
+ }
+ lxb_dom_element_t *context = lxb_dom_interface_element(doc->body);
+ lxb_dom_node_t *fragRoot = lxb_html_document_parse_fragment(
+ doc.get(), context, reinterpret_cast<const lxb_char_t *>(text.data()), text.size());
+
+ sanitizeTraverse(fragRoot, sanitizeVisitor);
+ lexbor_str_t str = {0};
+ auto status = lxb_html_serialize_deep_str(fragRoot, &str);
+ if (status != LXB_STATUS_OK) {
+ return "";
+ }
+ return std::string(reinterpret_cast<const char *>(str.data), str.length);
+}
diff --git a/src/kazv-util.hpp b/src/kazv-util.hpp
--- a/src/kazv-util.hpp
+++ b/src/kazv-util.hpp
@@ -34,6 +34,8 @@
QString matrixLinkUserId(const QString &url) const;
MatrixLink *matrixLink(const QString &url) const;
+ QString sanitizeHtml(const QString &raw) const;
+
private:
int m_kfQtMajorVersion;
};
diff --git a/src/kazv-util.cpp b/src/kazv-util.cpp
--- a/src/kazv-util.cpp
+++ b/src/kazv-util.cpp
@@ -11,6 +11,7 @@
#include "matrix-link.hpp"
#include "kazv-util.hpp"
+#include "html-sanitizer/sanitize.hpp"
KazvUtil::KazvUtil(QObject *parent)
: QObject(parent)
@@ -42,3 +43,8 @@
{
return new MatrixLink(QUrl(url));
}
+
+QString KazvUtil::sanitizeHtml(const QString &raw) const
+{
+ return QString::fromStdString(::sanitizeHtml(raw.toStdString()));
+}
diff --git a/src/tests/CMakeLists.txt b/src/tests/CMakeLists.txt
--- a/src/tests/CMakeLists.txt
+++ b/src/tests/CMakeLists.txt
@@ -51,6 +51,11 @@
LINK_LIBRARIES Qt${QT_MAJOR_VERSION}::Test Qt${QT_MAJOR_VERSION}::HttpServer kazvtestlib
)
+ecm_add_test(
+ kazv-sanitizer-test.cpp
+ LINK_LIBRARIES Qt${QT_MAJOR_VERSION}::Test Qt${QT_MAJOR_VERSION}::HttpServer kazvsanitizer
+)
+
add_executable(quicktest quick-test.cpp)
target_link_libraries(quicktest PRIVATE Qt${QT_MAJOR_VERSION}::QuickTest kazvtestlib)
@@ -60,6 +65,10 @@
matrix-room-timeline-benchmark-test.cpp
LINK_LIBRARIES Qt${QT_MAJOR_VERSION}::Test kazvtestlib
)
+
+ ecm_add_test(kazv-sanitizer-benchmark-test.cpp
+ LINK_LIBRARIES Qt${QT_MAJOR_VERSION}::Test kazvsanitizer
+ )
endif()
set(quicktest_QMLS
diff --git a/src/tests/kazv-sanitizer-benchmark-test.cpp b/src/tests/kazv-sanitizer-benchmark-test.cpp
new file mode 100644
--- /dev/null
+++ b/src/tests/kazv-sanitizer-benchmark-test.cpp
@@ -0,0 +1,44 @@
+/*
+ * This file is part of kazv.
+ * SPDX-FileCopyrightText: 2026 tusooa <nannanko@kazv.moe>
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+// #include <kazv-defs.hpp> intentionally omitted
+#include "sanitize.hpp"
+#include <QtTest>
+
+static const std::string input = R"xx(
+Lorem ipsum <p>dolor</p> sit amet, consectetur adipiscing elit. Curabitur augue risus, commodo quis dolor a, ultricies semper turpis. In aliquet finibus nunc at lobortis. Cras porta rutrum <div>elit ac <img alt="dapibus">. Suspendisse <p xxx="yyy">efficitur</p> sit amet enim eget consectetur. Vivamus luctus nisi volutpat lobortis dignissim. Praesent <script lang="text/javascript">nec velit quis sapien fringilla consectetur fringilla et leo. Donec lorem felis, volutpat ut</script> neque at, consequat elementum eros</div>. Praesent pretium <html>enim</html> urna, suscipit venenatis urna bibendum sit amet. Nam tincidunt nibh at purus <strong><strong><strong>egestas</strong> pretium</strong>. Sed</strong> euismod tellus ac ex interdum, id facilisis purus egestas. Cras ut dui ligula. Nunc dapibus auctor rhoncus.
+)xx";
+
+class KazvSanitizerBenchmarkTest : public QObject
+{
+ Q_OBJECT
+
+private Q_SLOTS:
+ void testSanitize();
+ void testSanitizeQString();
+};
+
+void KazvSanitizerBenchmarkTest::testSanitize()
+{
+ QBENCHMARK {
+ sanitizeHtml(input);
+ }
+}
+
+void KazvSanitizerBenchmarkTest::testSanitizeQString()
+{
+ auto inputQStr = QString::fromStdString(input);
+ // Because lexbor only reads and writes UTF-8 but in QML we only have QString
+ // we want to benchmark the performance of the double conversion.
+ // See also: https://github.com/lexbor/lexbor/issues/271
+ QBENCHMARK {
+ QString::fromStdString(sanitizeHtml(inputQStr.toStdString()));
+ }
+}
+
+QTEST_MAIN(KazvSanitizerBenchmarkTest)
+
+#include "kazv-sanitizer-benchmark-test.moc"
diff --git a/src/tests/kazv-sanitizer-test.cpp b/src/tests/kazv-sanitizer-test.cpp
new file mode 100644
--- /dev/null
+++ b/src/tests/kazv-sanitizer-test.cpp
@@ -0,0 +1,41 @@
+/*
+ * This file is part of kazv.
+ * SPDX-FileCopyrightText: 2026 tusooa <nannanko@kazv.moe>
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+// #include <kazv-defs.hpp> intentionally omitted
+#include "sanitize.hpp"
+#include <QtTest>
+
+class KazvSanitizerTest : public QObject
+{
+ Q_OBJECT
+
+private Q_SLOTS:
+ void testSanitize();
+};
+
+void KazvSanitizerTest::testSanitize()
+{
+ QCOMPARE(sanitizeHtml("abc"), "abc");
+ QCOMPARE(sanitizeHtml("abc <!-- uuu -->"), "abc ");
+ QCOMPARE(sanitizeHtml("<xxx>yyy</xxx>"), "yyy");
+ QCOMPARE(sanitizeHtml("<xxx>yyy<zzz> uuu</zzz><www /></xxx>"), "yyy uuu");
+ QCOMPARE(sanitizeHtml("<xxx>yyy<zzz> uuu</zzz><a href='https://u'>x</a></xxx>"), "yyy uuu<a href=\"https://u\">x</a>");
+ QCOMPARE(sanitizeHtml("<xxx>yyy<zzz> uuu</zzz><a href='u'>x</a></xxx>"), "yyy uuu<a>x</a>");
+ QCOMPARE(sanitizeHtml("<p id='uwu'>yyy<zzz> uuu</zzz><a href='https://u'>x</a></p>"), "<p>yyy uuu<a href=\"https://u\">x</a></p>");
+ QCOMPARE(sanitizeHtml("<p id='uwu'>yyy<zzz> uuu</zzz><a href='https://u'>x</a></p>"), "<p>yyy uuu<a href=\"https://u\">x</a></p>");
+ QCOMPARE(sanitizeHtml("<svg:p>yyy</svg:p>"), "yyy");
+ QCOMPARE(sanitizeHtml("<img alt='uwu' bad='xxx' bad2='yyy' src='mxc://aa/bb' alt='0w0'>yyy</img>"), "<img alt=\"uwu\" src=\"mxc://aa/bb\">yyy");
+ QCOMPARE(sanitizeHtml("<!DOCTYPE html>000"), "000");
+ QCOMPARE(sanitizeHtml("<p>000 <p>111</p></p>"), "<p>000 </p><p>111</p><p></p>");
+ QCOMPARE(sanitizeHtml("u<![CDATA[ 0 0 0 ]]>1"), "u1");
+ QCOMPARE(sanitizeHtml("<a href=\"ftp:\">000</a>"), "<a href=\"ftp:\">000</a>");
+ QCOMPARE(sanitizeHtml("<mx-reply>xxx</mx-reply>yyy"), "yyy");
+ QCOMPARE(sanitizeHtml("<x>心情简单</x>yyy"), "心情简单yyy");
+}
+
+QTEST_MAIN(KazvSanitizerTest)
+
+#include "kazv-sanitizer-test.moc"
diff --git a/src/tests/quick-tests/tst_EventView.qml b/src/tests/quick-tests/tst_EventView.qml
--- a/src/tests/quick-tests/tst_EventView.qml
+++ b/src/tests/quick-tests/tst_EventView.qml
@@ -93,6 +93,21 @@
formattedTime: '4:06 P.M.',
})
+ property var htmlEventDangerous: ({
+ eventId: '',
+ sender: '@foo:tusooa.xyz',
+ type: 'm.room.message',
+ stateKey: '',
+ content: {
+ msgtype: 'm.text',
+ body: '**some body**',
+ format: 'org.matrix.custom.html',
+ formatted_body: '<img src="https://example.com"><strong>some body</strong>',
+ },
+ isAvailable: true,
+ formattedTime: '4:06 P.M.',
+ })
+
property var replyHtmlEvent: ({
eventId: '',
sender: '@foo:tusooa.xyz',
@@ -342,6 +357,13 @@
sender: item.sender
}
+ Kazv.EventView {
+ Layout.fillWidth: true
+ id: eventViewHtmlDangerous
+ event: item.htmlEventDangerous
+ sender: item.sender
+ }
+
Kazv.EventView {
Layout.fillWidth: true
id: eventViewHtmlReply
@@ -523,6 +545,14 @@
verify(text.text.includes('some body'));
}
+ function test_htmlMessageDangerous() {
+ const text = findChild(eventViewHtmlDangerous, 'eventViewMainItem');
+ verify(text.textFormat === TextEdit.RichText);
+ verify(text.text.includes('<img>'));
+ verify(!text.text.includes('<img src="https:'));
+ verify(text.text.includes('some body'));
+ }
+
function test_htmlMessageReply() {
const text = findChild(eventViewHtmlReply, 'eventViewMainItem');
verify(text.textFormat === TextEdit.RichText);

File Metadata

Mime Type
text/plain
Expires
Wed, Sep 9, 3:32 AM (23 h, 7 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1749362
Default Alt Text
D363.1788949974.diff (18 KB)

Event Timeline