diff options
| author | Danilo M. <danix@danix.xyz> | 2026-08-02 17:37:48 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-08-02 17:37:48 +0200 |
| commit | 3e540e8b2c79006524ad11d3629361bde663db27 (patch) | |
| tree | 24592b2059a81d806486a9d295c900776e9cc503 /src | |
| parent | d774f0e94e7c5a7864ca585c5b3493ee9e33fcf6 (diff) | |
| download | qtmaildir-3e540e8b2c79006524ad11d3629361bde663db27.tar.gz qtmaildir-3e540e8b2c79006524ad11d3629361bde663db27.zip | |
feat: add deny-by-default web request interceptor
Diffstat (limited to 'src')
| -rw-r--r-- | src/CMakeLists.txt | 1 | ||||
| -rw-r--r-- | src/requestinterceptor.cpp | 67 | ||||
| -rw-r--r-- | src/requestinterceptor.h | 42 |
3 files changed, 110 insertions, 0 deletions
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 8dfd212..e9da44e 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -2,6 +2,7 @@ add_library(qtmaildir_lib STATIC keymap.cpp config.cpp mimeparser.cpp + requestinterceptor.cpp ) target_include_directories(qtmaildir_lib diff --git a/src/requestinterceptor.cpp b/src/requestinterceptor.cpp new file mode 100644 index 0000000..0ce95ca --- /dev/null +++ b/src/requestinterceptor.cpp @@ -0,0 +1,67 @@ +#include "requestinterceptor.h" + +#include <QWebEngineUrlRequestInfo> + +RequestInterceptor::RequestInterceptor(QObject *parent) + : QWebEngineUrlRequestInterceptor(parent) +{ +} + +bool RequestInterceptor::shouldAllow(const QUrl &url) +{ + // QUrl::scheme() always normalizes to lowercase (verified: QUrl("HTTP://x/y") + // .scheme() == "http"), so a lowercase-literal compare cannot be bypassed + // by unusual casing, in either the allow or the deny direction. + const QString scheme = url.scheme(); + + // The document itself is loaded via setHtml() with a qtmaildir: base URL, + // so that scheme must pass or nothing renders at all. This is unconditional + // on any path/host because Task 11's scheme handler is the only thing that + // can ever originate a qtmaildir: navigation in the first place; the message + // body cannot cause a request with this scheme, only reference cid:/http(s):. + if (scheme == QLatin1String("qtmaildir")) + return true; + + // Inline parts of the current message only. + if (scheme == QLatin1String("cid")) { + // QUrl keeps a cid: body in path(), not host() or userName(), even + // when it contains '@' (verified empirically: QUrl("cid:logo@example.org") + // .path() == "logo@example.org", host() and userName() are empty). + // path() also returns the percent-decoded form, so a percent-encoded + // id (e.g. "%6Cogo@example.org") compares equal to its decoded form, + // not to some other allowed id: it cannot be used to smuggle a + // foreign id past the allowlist, only to spell an already-legitimate + // id differently. + const QString id = url.path(); + if (m_allowedCids.contains(id)) + return true; + m_blockedAnything = true; + return false; + } + + if (scheme == QLatin1String("http") || scheme == QLatin1String("https")) { + if (m_allowRemote) + return true; + m_blockedAnything = true; + return false; + } + + // Everything else, including file:, javascript:, data:, blob:, about:, + // chrome:, qrc:, filesystem:, protocol-relative URLs (empty scheme with a + // host), and empty/malformed URLs (empty scheme), is denied + // unconditionally. There is no flag that enables it. + m_blockedAnything = true; + return false; +} + +void RequestInterceptor::interceptRequest(QWebEngineUrlRequestInfo &info) +{ + if (!shouldAllow(info.requestUrl())) + info.block(true); +} + +void RequestInterceptor::resetForNewMessage() +{ + m_allowRemote = false; + m_blockedAnything = false; +} diff --git a/src/requestinterceptor.h b/src/requestinterceptor.h new file mode 100644 index 0000000..817a4d1 --- /dev/null +++ b/src/requestinterceptor.h @@ -0,0 +1,42 @@ +#pragma once + +#include <QSet> +#include <QUrl> +#include <QWebEngineUrlRequestInterceptor> + +/// Deny-by-default request policy for the message view. +/// +/// A message body is untrusted input from a stranger. Everything is blocked +/// unless explicitly permitted: remote loads leak the fact that a message was +/// read (tracking pixels) and file: loads would expose the local filesystem. +class RequestInterceptor : public QWebEngineUrlRequestInterceptor +{ + Q_OBJECT +public: + explicit RequestInterceptor(QObject *parent = nullptr); + + /// The whole policy, as a pure function so it can be tested directly. + bool shouldAllow(const QUrl &url); + + void interceptRequest(QWebEngineUrlRequestInfo &info) override; + + /// Content-IDs belonging to the currently displayed message. + void setAllowedCids(const QSet<QString> &cids) { m_allowedCids = cids; } + + /// Per-message opt-in, triggered by the user clicking "Load remote content". + /// Never persisted, never carried to the next message. + void setAllowRemote(bool allow) { m_allowRemote = allow; } + bool allowRemote() const { return m_allowRemote; } + + /// True once any request has been denied, so the UI can offer the button. + bool blockedAnything() const { return m_blockedAnything; } + + /// Called before rendering a new message: clears both the remote grant and + /// the blocked flag. + void resetForNewMessage(); + +private: + QSet<QString> m_allowedCids; + bool m_allowRemote = false; + bool m_blockedAnything = false; +}; |
