ui/HtmlSanitizer.h
| Line | Branch | Exec | Source |
|---|---|---|---|
| 1 | #pragma once | ||
| 2 | |||
| 3 | #include <QObject> | ||
| 4 | #include <QHash> | ||
| 5 | #include <QQueue> | ||
| 6 | #include <QPointer> | ||
| 7 | #include <QCache> | ||
| 8 | #include <QSet> | ||
| 9 | #include <QTimer> | ||
| 10 | #include <QThreadPool> | ||
| 11 | #include <atomic> | ||
| 12 | #include <memory> | ||
| 13 | #include <optional> | ||
| 14 | #include <QString> | ||
| 15 | #include <functional> | ||
| 16 | |||
| 17 | class QWebEnginePage; | ||
| 18 | class QWebEngineProfile; | ||
| 19 | |||
| 20 | // T-608/SEC-04: DOMPurify-based HTML sanitizer. | ||
| 21 | // | ||
| 22 | // Uses a hidden QWebEnginePage (JS enabled) to run DOMPurify.sanitize() | ||
| 23 | // on untrusted HTML. The sanitized output is then safe to render on the | ||
| 24 | // MailView page (which has JS disabled). | ||
| 25 | // | ||
| 26 | // Architecture: | ||
| 27 | // - On init, loads a minimal HTML skeleton that includes DOMPurify | ||
| 28 | // - sanitize() calls DOMPurify.sanitize(html, config) via runJavaScript() | ||
| 29 | // - Allowlist config: only safe tags/attributes pass through | ||
| 30 | // - Callback delivers clean HTML asynchronously | ||
| 31 | // | ||
| 32 | // Thread safety: must be used on the GUI thread only. | ||
| 33 | class HtmlSanitizer : public QObject { | ||
| 34 | Q_OBJECT | ||
| 35 | friend class TestHtmlSanitizer; | ||
| 36 | friend class TestMailView; | ||
| 37 | |||
| 38 | public: | ||
| 39 | explicit HtmlSanitizer(QObject *parent = nullptr); | ||
| 40 | static HtmlSanitizer *shared(); | ||
| 41 | ~HtmlSanitizer() override; | ||
| 42 | |||
| 43 | // Initialize the sanitizer. Must be called once before sanitize(). | ||
| 44 | // Loads DOMPurify into the hidden page. Emits ready() when done. | ||
| 45 | void init(); | ||
| 46 | // Retire Chromium while its event loop is alive; reject subsequent work. | ||
| 47 | void shutdown(); | ||
| 48 | |||
| 49 | // Returns true if the sanitizer is ready to accept sanitize() calls. | ||
| 50 | 3 | bool isReady() const { return m_state == InitializationState::Ready; } | |
| 51 | |||
| 52 | // A resident full-HTML reader holds the DOMPurify page warm: the next | ||
| 53 | // HTML mail is likely, and re-creating the page after the idle retirement | ||
| 54 | // cost 55-85 ms per open (Sprint 90 reader gate). The page retires | ||
| 55 | // IdleRetentionMs after its last request without holders and | ||
| 56 | // HeldRetentionMs with holders. Holding a dormant sanitizer | ||
| 57 | // re-initializes it ahead of the next request. | ||
| 58 | void holdWarm(const QObject *holder, bool hold); | ||
| 59 | // Measurement (Sprint 90 "prepared display hit: 0 sanitizer runs"): | ||
| 60 | // DOMPurify executions and results served from the output cache. | ||
| 61 | quint64 dispatchedRequests() const { return m_dispatchedRequests; } | ||
| 62 | quint64 cacheHits() const { return m_cacheHits; } | ||
| 63 | |||
| 64 | // Sanitize untrusted HTML. Calls callback with clean HTML asynchronously. | ||
| 65 | // If the sanitizer is not ready, queues the request until ready. | ||
| 66 | void sanitize(const QString &dirtyHtml, | ||
| 67 | std::function<void(const QString &cleanHtml)> callback); | ||
| 68 | void sanitize(const QString &dirtyHtml, | ||
| 69 | const QHash<QString, QString> &inlineImages, | ||
| 70 | std::function<void(const QString &cleanHtml)> callback, | ||
| 71 | QObject *owner = nullptr, const QByteArray &cacheIdentity = {}); | ||
| 72 | void cancel(QObject *owner); | ||
| 73 | |||
| 74 | signals: | ||
| 75 | void ready(); | ||
| 76 | |||
| 77 | private: | ||
| 78 | enum class InitializationState { | ||
| 79 | Uninitialized, | ||
| 80 | Loading, | ||
| 81 | Ready, | ||
| 82 | Dormant, | ||
| 83 | Failed, | ||
| 84 | Destroying, | ||
| 85 | }; | ||
| 86 | |||
| 87 | struct PendingRequest { | ||
| 88 | quint64 id = 0; | ||
| 89 | QString html; | ||
| 90 | QHash<QString, QString> inlineImages; | ||
| 91 | qsizetype byteSize = 0; | ||
| 92 | QPointer<QObject> owner; | ||
| 93 | bool owned = false; | ||
| 94 | QByteArray cacheIdentity; | ||
| 95 | QString cachedOutput; | ||
| 96 | std::shared_ptr<std::atomic_bool> cancelled = std::make_shared<std::atomic_bool>(false); | ||
| 97 | }; | ||
| 98 | |||
| 99 | struct CachedDocument { | ||
| 100 | QString html; | ||
| 101 | QHash<QString, QString> inlineImages; | ||
| 102 | QString clean; | ||
| 103 | }; | ||
| 104 | void dispatchRequest(PendingRequest request); | ||
| 105 | void completeRequest(quint64 id, const QString &cleanHtml); | ||
| 106 | void drainPendingRequests(); | ||
| 107 | void failInitialization(const QString &reason); | ||
| 108 | // S106-041: ends the active request and replaces a page whose renderer | ||
| 109 | // died or whose JavaScript completion never arrived. | ||
| 110 | void recoverFromLostPage(); | ||
| 111 | void failAllRequests(); | ||
| 112 | void releasePage(); | ||
| 113 | void scheduleIdleRelease(); | ||
| 114 | |||
| 115 | QWebEnginePage *m_page = nullptr; | ||
| 116 | QWebEngineProfile *m_profile = nullptr; | ||
| 117 | QTimer m_idleRelease; | ||
| 118 | static constexpr int IdleRetentionMs = 5000; | ||
| 119 | // While a resident full-HTML reader holds the page: the next HTML mail is | ||
| 120 | // likely soon, but an idle Chromium renderer (~100 MiB RSS) is not kept | ||
| 121 | // longer than this after the last request. | ||
| 122 | static constexpr int HeldRetentionMs = 60000; | ||
| 123 | QSet<const QObject *> m_warmHolders; | ||
| 124 | InitializationState m_state = InitializationState::Uninitialized; | ||
| 125 | QQueue<PendingRequest> m_pendingRequests; | ||
| 126 | // Bounded asynchronous cache completions do not occupy Chromium. | ||
| 127 | QHash<quint64, PendingRequest> m_readyRequests; | ||
| 128 | QHash<quint64, std::function<void(const QString &)>> m_callbacks; | ||
| 129 | // Account UTF-8 payload bytes for queued and active requests together. | ||
| 130 | // Retained QString storage is at most twice this encoded-byte ceiling. | ||
| 131 | qsizetype m_pendingBytes = 0; | ||
| 132 | quint64 m_activeRequestId = 0; | ||
| 133 | int m_completionTimeoutMs = 30000; // S106-041: budget of one JavaScript sanitize | ||
| 134 | qsizetype m_activeBytes = 0; | ||
| 135 | QPointer<QObject> m_activeOwner; | ||
| 136 | std::optional<PendingRequest> m_activeInput; | ||
| 137 | QCache<QByteArray, CachedDocument> m_outputCache{2 * 1024 * 1024}; | ||
| 138 | QThreadPool m_serializationPool; | ||
| 139 | quint64 m_dispatchedRequests = 0; | ||
| 140 | quint64 m_cacheHits = 0; | ||
| 141 | quint64 m_nextRequestId = 1; | ||
| 142 | |||
| 143 | static constexpr qsizetype MaxQueuedRequests = 64; | ||
| 144 | // Includes Base64-expanded images; keep the existing 40 MiB payload capacity. | ||
| 145 | static constexpr qsizetype MaxQueuedBytes = 40 * 1024 * 1024; | ||
| 146 | }; | ||
| 147 |