Branch data Line data Source code
1 : : #include "NextcloudAuth.h"
2 : :
3 : : #include <QDesktopServices>
4 : : #include <QJsonDocument>
5 : : #include <QJsonObject>
6 : : #include <QLoggingCategory>
7 : : #include <QNetworkAccessManager>
8 : : #include <QNetworkReply>
9 : : #include <QNetworkRequest>
10 : : #include <QTimer>
11 : : #include <QUrl>
12 : : #include <QUrlQuery>
13 : :
14 : : #include <utility>
15 : :
16 [ + + + - : 81 : Q_LOGGING_CATEGORY(lcNextcloudAuth, "mailjd.nextcloudauth")
+ - - - ]
17 : :
18 : : namespace {
19 : 44 : int effectivePort(const QUrl &url) {
20 [ + + ]: 44 : if (url.port() >= 0)
21 : 4 : return url.port();
22 [ + - ]: 120 : return url.scheme().compare(QStringLiteral("https"), Qt::CaseInsensitive) == 0
23 [ + + ]: 40 : ? 443
24 : 40 : : 80;
25 : : }
26 : :
27 : 25 : bool isSameOrigin(const QUrl &candidate, const QUrl &expected) {
28 [ + - + - : 75 : return candidate.isValid() && !candidate.host().isEmpty() &&
+ + - - ]
29 [ + - + - : 50 : candidate.scheme().compare(expected.scheme(), Qt::CaseInsensitive) ==
+ - + - -
- - - ]
30 [ + + ]: 48 : 0 &&
31 [ + - + - : 73 : candidate.host().compare(expected.host(), Qt::CaseInsensitive) == 0 &&
+ + + - -
- - - ]
32 [ + - + - : 44 : effectivePort(candidate) == effectivePort(expected) &&
+ - + - ]
33 [ + - + - : 97 : candidate.userName().isEmpty() && candidate.password().isEmpty();
+ - + - +
- + + + +
+ + - - -
- ]
34 : : }
35 : : } // namespace
36 : :
37 : 96 : NextcloudAuth::NextcloudAuth(QObject *parent) : QObject(parent) {
38 [ + - + - : 96 : m_nam = new QNetworkAccessManager(this);
- + - - ]
39 [ + - ]: 96 : m_nam->setRedirectPolicy(QNetworkRequest::SameOriginRedirectPolicy);
40 [ + - + - : 96 : m_pollTimer = new QTimer(this);
- + - - ]
41 [ + - ]: 96 : m_pollTimer->setInterval(2000);
42 [ + - ]: 96 : connect(m_pollTimer, &QTimer::timeout, this, &NextcloudAuth::poll);
43 [ + - + - : 96 : m_replyDeadlineTimer = new QTimer(this);
- + - - ]
44 [ + - ]: 96 : m_replyDeadlineTimer->setSingleShot(true);
45 : 96 : connect(m_replyDeadlineTimer, &QTimer::timeout, this,
46 [ + - ]: 96 : &NextcloudAuth::onReplyDeadline);
47 : 96 : }
48 : :
49 : 177 : NextcloudAuth::~NextcloudAuth() { cancel(); }
50 : :
51 : 8 : void NextcloudAuth::setNetworkAccessManager(QNetworkAccessManager *nam) {
52 [ + - + - : 8 : if (m_nam && m_nam->parent() == this)
+ - ]
53 [ + - ]: 8 : delete m_nam;
54 : 8 : m_nam = nam;
55 [ + - ]: 8 : if (m_nam) {
56 : 8 : m_nam->setParent(this);
57 : 8 : m_nam->setRedirectPolicy(QNetworkRequest::SameOriginRedirectPolicy);
58 : : }
59 : 8 : }
60 : :
61 : 18 : void NextcloudAuth::startLogin(const QString &serverUrl) {
62 [ + - ]: 18 : cancel(); // Cancel any ongoing flow
63 : :
64 : 18 : QString url = serverUrl;
65 [ + - + + ]: 18 : if (url.endsWith('/'))
66 [ + - ]: 1 : url.chop(1);
67 : :
68 [ + - ]: 18 : QUrl parsedUrl(url);
69 [ + - + + ]: 18 : if (!isServerUrlAllowedForLogin(parsedUrl)) {
70 [ + - + - : 6 : qCWarning(lcNextcloudAuth)
+ + ]
71 [ + - + - ]: 3 : << "Rejected insecure Nextcloud Login Flow URL:" << url;
72 [ + - + - ]: 3 : emit loginFailed(tr("Nextcloud Login Flow requires HTTPS"));
73 : 3 : return;
74 : : }
75 : :
76 : : // T-611/SEC-10: Save original server URL for SSRF origin validation
77 : 15 : m_originalServerUrl = url;
78 : :
79 : : QNetworkRequest request{
80 [ + - + - : 15 : QUrl(url + QStringLiteral("/index.php/login/v2"))};
+ - ]
81 [ + - ]: 15 : request.setHeader(QNetworkRequest::ContentTypeHeader,
82 : 30 : QStringLiteral("application/x-www-form-urlencoded"));
83 : : // Nextcloud requires a User-Agent for the app name display
84 [ + - + - : 15 : request.setRawHeader("User-Agent", "MailJD/1.0");
+ - ]
85 : :
86 [ + - + - ]: 15 : beginReply(m_nam->post(request, QByteArray()));
87 : 15 : connect(m_currentReply, &QNetworkReply::finished, this,
88 [ + - ]: 15 : &NextcloudAuth::onInitReply);
89 [ + + + + ]: 21 : }
90 : :
91 : 118 : void NextcloudAuth::cancel() {
92 : 118 : m_pollTimer->stop();
93 : 118 : m_replyDeadlineTimer->stop();
94 : 118 : m_pollCount = 0;
95 : 118 : m_pollEndpoint.clear();
96 : 118 : m_pollToken.clear();
97 [ + + ]: 118 : if (m_currentReply) {
98 : : // Qt 6.8: QNetworkReply::abort() emits 'finished' synchronously, which
99 : : // would re-enter the connected poll/onInit lambda (it nulls m_currentReply
100 : : // and may emit loginFailed/loginSuccess). Disconnect first so cancel()
101 : : // owns the cleanup exclusively, then abort+deleteLater safely.
102 : 2 : disconnect(m_currentReply, nullptr, this, nullptr);
103 : 2 : m_currentReply->abort();
104 : 2 : m_currentReply->deleteLater();
105 : 2 : m_currentReply = nullptr;
106 : : }
107 : 118 : m_replyData.clear();
108 : 118 : m_replyFailure = ReplyFailure::None;
109 : 118 : }
110 : :
111 : 39 : bool NextcloudAuth::isPolling() const { return m_pollTimer->isActive(); }
112 : :
113 : 32 : bool NextcloudAuth::isServerUrlAllowedForLogin(const QUrl &url) {
114 [ + - + - : 64 : if (!url.isValid() || url.host().isEmpty())
+ - - + +
- - + -
- ]
115 : 0 : return false;
116 : :
117 [ + - + + ]: 32 : if (url.scheme() == QLatin1String("https"))
118 : 22 : return true;
119 : :
120 : : #ifdef MAILJD_UNIT_TEST
121 [ + - + - ]: 8 : if (url.scheme() == QLatin1String("http")) {
122 [ + - + - ]: 8 : const QString host = url.host().toLower();
123 [ + + ]: 15 : return host == QLatin1String("localhost") ||
124 [ + + - + ]: 15 : host == QLatin1String("127.0.0.1") ||
125 : 2 : host == QLatin1String("::1");
126 : 8 : }
127 : : #endif
128 : :
129 : 2 : return false;
130 : : }
131 : :
132 : 25 : void NextcloudAuth::beginReply(QNetworkReply *reply) {
133 [ - + ]: 25 : Q_ASSERT(reply);
134 [ - + ]: 25 : Q_ASSERT(!m_currentReply);
135 : 25 : m_currentReply = reply;
136 : 25 : m_replyData.clear();
137 : 25 : m_replyFailure = ReplyFailure::None;
138 : : // Backpressure keeps QNetworkReply itself bounded while readyRead drains
139 : : // into the explicitly capped accumulator below.
140 : 25 : reply->setReadBufferSize(MaxResponseBytes + 1);
141 : 25 : connect(reply, &QNetworkReply::readyRead, this,
142 [ + - ]: 25 : &NextcloudAuth::onReplyReadyRead);
143 : 25 : connect(reply, &QNetworkReply::metaDataChanged, this,
144 [ + - ]: 25 : &NextcloudAuth::onReplyReadyRead);
145 : 25 : m_replyDeadlineTimer->start(ReplyDeadlineMs);
146 : 25 : }
147 : :
148 : 1 : void NextcloudAuth::abortCurrentReply(ReplyFailure failure) {
149 [ + - - + ]: 1 : if (!m_currentReply || m_replyFailure != ReplyFailure::None)
150 : 0 : return;
151 : 1 : m_replyFailure = failure;
152 : 1 : m_replyDeadlineTimer->stop();
153 : 1 : m_currentReply->abort();
154 : : }
155 : :
156 : 2 : void NextcloudAuth::onReplyReadyRead() {
157 [ + - - + ]: 2 : if (!m_currentReply || m_replyFailure != ReplyFailure::None)
158 : 1 : return;
159 : :
160 : 2 : bool lengthOk = false;
161 : : const qint64 contentLength =
162 [ + - ]: 2 : m_currentReply->header(QNetworkRequest::ContentLengthHeader)
163 [ + - ]: 2 : .toLongLong(&lengthOk);
164 [ + - - + ]: 2 : if (lengthOk && contentLength > MaxResponseBytes) {
165 [ # # # # : 0 : qCWarning(lcNextcloudAuth) << "Rejecting oversized Nextcloud response by"
# # ]
166 [ # # ]: 0 : " Content-Length:"
167 [ # # ]: 0 : << contentLength;
168 [ # # ]: 0 : abortCurrentReply(ReplyFailure::TooLarge);
169 : 0 : return;
170 : : }
171 : :
172 : 2 : const qint64 remaining = MaxResponseBytes - m_replyData.size();
173 [ + - ]: 2 : const qint64 available = m_currentReply->bytesAvailable();
174 [ + + ]: 2 : if (available <= 0)
175 : 1 : return;
176 : :
177 [ + - ]: 1 : const QByteArray chunk = m_currentReply->read(qMin(available, remaining + 1));
178 [ - + ]: 1 : if (chunk.size() > remaining) {
179 [ # # # # : 0 : qCWarning(lcNextcloudAuth) << "Rejecting streamed Nextcloud response above"
# # ]
180 [ # # ]: 0 : " byte limit"
181 [ # # ]: 0 : << MaxResponseBytes;
182 [ # # ]: 0 : abortCurrentReply(ReplyFailure::TooLarge);
183 : 0 : return;
184 : : }
185 [ + - ]: 1 : m_replyData.append(chunk);
186 [ + - ]: 1 : }
187 : :
188 : 1 : void NextcloudAuth::onReplyDeadline() {
189 [ - + ]: 1 : if (!m_currentReply)
190 : 0 : return;
191 [ + - + - : 2 : qCWarning(lcNextcloudAuth) << "Nextcloud Login Flow request exceeded"
+ - + + ]
192 [ + - + - ]: 1 : << ReplyDeadlineMs << "ms deadline";
193 : 1 : abortCurrentReply(ReplyFailure::Timeout);
194 : : }
195 : :
196 : 23 : QByteArray NextcloudAuth::finishReply(QNetworkReply *reply,
197 : : QString *failureReason) {
198 [ + - - + ]: 23 : if (!reply || reply != m_currentReply) {
199 [ # # ]: 0 : if (failureReason)
200 [ # # ]: 0 : *failureReason = tr("Unexpected Nextcloud Login Flow reply");
201 : 0 : return {};
202 : : }
203 : :
204 [ + - ]: 23 : m_replyDeadlineTimer->stop();
205 [ + + ]: 23 : if (m_replyFailure == ReplyFailure::None) {
206 : 22 : bool lengthOk = false;
207 : : const qint64 contentLength =
208 [ + - ]: 22 : reply->header(QNetworkRequest::ContentLengthHeader)
209 [ + - ]: 22 : .toLongLong(&lengthOk);
210 [ + - ]: 22 : const QByteArray tail = reply->readAll();
211 [ + + + - : 44 : if ((lengthOk && contentLength > MaxResponseBytes) ||
+ + ]
212 [ + + ]: 22 : tail.size() > MaxResponseBytes - m_replyData.size()) {
213 : 2 : m_replyFailure = ReplyFailure::TooLarge;
214 : : } else {
215 [ + - ]: 20 : m_replyData.append(tail);
216 : : }
217 : 22 : }
218 : :
219 [ + - ]: 23 : if (failureReason) {
220 [ + + + - ]: 23 : switch (m_replyFailure) {
221 : 20 : case ReplyFailure::None:
222 : 20 : failureReason->clear();
223 : 20 : break;
224 : 2 : case ReplyFailure::TooLarge:
225 [ + - ]: 2 : *failureReason = tr("Nextcloud Login Flow response is too large");
226 : 2 : break;
227 : 1 : case ReplyFailure::Timeout:
228 [ + - ]: 1 : *failureReason = tr("Nextcloud Login Flow request timed out");
229 : 1 : break;
230 : : }
231 : : }
232 : :
233 : 23 : QByteArray result;
234 [ + + ]: 23 : if (m_replyFailure == ReplyFailure::None)
235 : 20 : result = std::move(m_replyData);
236 [ + - ]: 23 : m_replyData.clear();
237 : 23 : m_replyFailure = ReplyFailure::None;
238 : 23 : m_currentReply = nullptr;
239 : 23 : return result;
240 : : }
241 : :
242 : 15 : void NextcloudAuth::onInitReply() {
243 [ + - + - ]: 15 : auto *reply = qobject_cast<QNetworkReply *>(sender());
244 [ - + ]: 15 : if (!reply)
245 : 6 : return;
246 : 15 : QString boundedReplyFailure;
247 [ + - ]: 15 : const QByteArray responseData = finishReply(reply, &boundedReplyFailure);
248 [ + - ]: 15 : reply->deleteLater();
249 : :
250 [ + + ]: 15 : if (!boundedReplyFailure.isEmpty()) {
251 [ + - ]: 2 : emit loginFailed(boundedReplyFailure);
252 : 2 : return;
253 : : }
254 : :
255 [ + - + + ]: 13 : if (reply->error() != QNetworkReply::NoError) {
256 [ + - + - : 2 : qCWarning(lcNextcloudAuth)
+ + ]
257 [ + - + - : 1 : << "Login flow init failed:" << reply->errorString();
+ - ]
258 [ + - + - ]: 1 : emit loginFailed(reply->errorString());
259 : 1 : return;
260 : : }
261 : :
262 : 12 : QJsonParseError parseError;
263 [ + - ]: 12 : QJsonDocument doc = QJsonDocument::fromJson(responseData, &parseError);
264 [ + - + - : 12 : if (parseError.error != QJsonParseError::NoError || !doc.isObject()) {
- + - + ]
265 [ # # # # ]: 0 : emit loginFailed(tr("Invalid Login Flow v2 response"));
266 : 0 : return;
267 : : }
268 [ + - ]: 12 : QJsonObject root = doc.object();
269 : :
270 [ + - + - ]: 12 : QJsonObject pollObj = root[QStringLiteral("poll")].toObject();
271 [ + - + - ]: 12 : m_pollEndpoint = pollObj[QStringLiteral("endpoint")].toString();
272 [ + - + - ]: 12 : m_pollToken = pollObj[QStringLiteral("token")].toString();
273 [ + - + - ]: 12 : QString loginUrl = root[QStringLiteral("login")].toString();
274 : :
275 [ + + + - : 23 : if (m_pollEndpoint.isEmpty() || m_pollToken.isEmpty() ||
- + + + ]
276 : 11 : loginUrl.isEmpty()) {
277 [ + - + - ]: 1 : emit loginFailed(tr("Invalid Login Flow v2 response"));
278 : 1 : return;
279 : : }
280 : :
281 : : // T-611/SEC-10: Validate poll endpoint origin to prevent SSRF
282 [ + - ]: 11 : QUrl pollUrl(m_pollEndpoint);
283 [ + - ]: 11 : QUrl serverUrl(m_originalServerUrl);
284 [ + - + + ]: 11 : if (!isSameOrigin(pollUrl, serverUrl)) {
285 [ + - + - : 2 : qCWarning(lcNextcloudAuth)
+ + ]
286 [ + - + - ]: 1 : << "Rejected cross-origin poll endpoint:" << m_pollEndpoint
287 [ + - + - : 1 : << "(expected origin:" << m_originalServerUrl << ")";
+ - ]
288 [ + - + - ]: 1 : emit loginFailed(tr("Security error: poll endpoint has a foreign origin"));
289 : 1 : return;
290 : : }
291 : :
292 [ + - ]: 10 : const QUrl parsedLoginUrl(loginUrl);
293 [ + - + - : 20 : if (!isServerUrlAllowedForLogin(parsedLoginUrl) ||
+ + ]
294 [ + - + + ]: 10 : !isSameOrigin(parsedLoginUrl, serverUrl)) {
295 [ + - + - : 2 : qCWarning(lcNextcloudAuth)
+ + ]
296 [ + - + - ]: 1 : << "Rejected cross-origin login URL:" << loginUrl
297 [ + - + - : 1 : << "(expected origin:" << m_originalServerUrl << ")";
+ - ]
298 [ + - + - ]: 1 : emit loginFailed(tr("Security error: login URL has a foreign origin"));
299 : 1 : return;
300 : : }
301 : :
302 [ + - + - : 18 : qCInfo(lcNextcloudAuth) << "Opening browser for Nextcloud login";
+ - + + ]
303 [ + - ]: 9 : QDesktopServices::openUrl(parsedLoginUrl);
304 : :
305 : : // Start polling
306 : 9 : m_pollCount = 0;
307 [ + - ]: 9 : m_pollTimer->start();
308 [ + + + + : 38 : }
+ + + + +
+ + + + +
+ + + + ]
309 : :
310 : 71 : void NextcloudAuth::poll() {
311 [ + + ]: 71 : if (++m_pollCount > MaxPollAttempts) {
312 [ + - ]: 1 : m_pollTimer->stop();
313 : 1 : m_pollEndpoint.clear();
314 : 1 : m_pollToken.clear();
315 [ + - ]: 1 : if (m_currentReply)
316 [ + - ]: 1 : m_currentReply->abort();
317 [ + - + - : 2 : qCWarning(lcNextcloudAuth) << "Login flow timed out after"
+ - + + ]
318 [ + - + - ]: 1 : << (MaxPollAttempts * 2) << "seconds";
319 [ + - + - ]: 1 : emit loginFailed(tr("Login timed out (120 seconds)"));
320 : 61 : return;
321 : : }
322 : :
323 [ + + ]: 70 : if (m_currentReply) {
324 [ + - + - : 120 : qCDebug(lcNextcloudAuth)
+ + ]
325 [ + - ]: 60 : << "Skipping Nextcloud login poll while previous poll is in flight";
326 : 60 : return;
327 : : }
328 : :
329 [ + - + - ]: 10 : QNetworkRequest request{QUrl(m_pollEndpoint)};
330 [ + - ]: 10 : request.setHeader(QNetworkRequest::ContentTypeHeader,
331 : 20 : QStringLiteral("application/x-www-form-urlencoded"));
332 : :
333 [ + - ]: 10 : QUrlQuery params;
334 [ + - ]: 20 : params.addQueryItem(QStringLiteral("token"), m_pollToken);
335 : :
336 [ + - + - ]: 10 : beginReply(
337 [ + - + - ]: 20 : m_nam->post(request, params.query(QUrl::FullyEncoded).toUtf8()));
338 [ + - ]: 10 : connect(m_currentReply, &QNetworkReply::finished, this, [this]() {
339 [ + - + - ]: 8 : auto *reply = qobject_cast<QNetworkReply *>(sender());
340 [ - + ]: 8 : if (!reply)
341 : 5 : return;
342 : 8 : QString boundedReplyFailure;
343 [ + - ]: 8 : const QByteArray responseData = finishReply(reply, &boundedReplyFailure);
344 [ + - ]: 8 : reply->deleteLater();
345 : :
346 [ - + ]: 8 : if (m_pollToken.isEmpty())
347 : 0 : return;
348 : :
349 [ + + ]: 8 : if (!boundedReplyFailure.isEmpty()) {
350 [ + - ]: 1 : m_pollTimer->stop();
351 : 1 : m_pollEndpoint.clear();
352 : 1 : m_pollToken.clear();
353 [ + - ]: 1 : emit loginFailed(boundedReplyFailure);
354 : 1 : return;
355 : : }
356 : :
357 [ + - ]: 7 : int status = reply->attribute(
358 [ + - ]: 7 : QNetworkRequest::HttpStatusCodeAttribute).toInt();
359 : :
360 [ + + ]: 7 : if (status == 404) {
361 : : // Not yet authorized — keep polling
362 : 1 : return;
363 : : }
364 : :
365 [ + - + + ]: 6 : if (reply->error() != QNetworkReply::NoError) {
366 [ + - ]: 1 : m_pollTimer->stop();
367 : 1 : m_pollEndpoint.clear();
368 : 1 : m_pollToken.clear();
369 [ + - + - ]: 1 : emit loginFailed(reply->errorString());
370 : 1 : return;
371 : : }
372 : :
373 : : // Success!
374 [ + - ]: 5 : m_pollTimer->stop();
375 : :
376 : 5 : QJsonParseError parseError;
377 [ + - ]: 5 : QJsonDocument doc = QJsonDocument::fromJson(responseData, &parseError);
378 [ + - + - : 5 : if (parseError.error != QJsonParseError::NoError || !doc.isObject()) {
- + - + ]
379 : 0 : m_pollEndpoint.clear();
380 : 0 : m_pollToken.clear();
381 [ # # # # ]: 0 : emit loginFailed(tr("Invalid response from server"));
382 : 0 : return;
383 : : }
384 [ + - ]: 5 : QJsonObject obj = doc.object();
385 : :
386 [ + - + - ]: 5 : QString server = obj[QStringLiteral("server")].toString();
387 [ + - + - ]: 5 : QString loginName = obj[QStringLiteral("loginName")].toString();
388 [ + - + - ]: 5 : QString appPassword = obj[QStringLiteral("appPassword")].toString();
389 : :
390 [ + + + - : 5 : if (server.isEmpty() || loginName.isEmpty() || appPassword.isEmpty()) {
- + + + ]
391 : 1 : m_pollEndpoint.clear();
392 : 1 : m_pollToken.clear();
393 [ + - + - ]: 1 : emit loginFailed(tr("Invalid response from server"));
394 : 1 : return;
395 : : }
396 : :
397 : : // SEC-2026-07-21-05: The final "server" field of the success response is
398 : : // attacker-controllable and was the only field of the response that
399 : : // escaped the same-origin validation applied to poll endpoint and login
400 : : // URL above. A foreign "server" value would be persisted as the account
401 : : // endpoint while the app password belongs to the original server,
402 : : // forwarding credentials across origins on the next sync. Require the
403 : : // same origin as the login flow was started against.
404 [ + - ]: 4 : QUrl serverFieldUrl(server);
405 [ + - ]: 4 : QUrl originalUrl(m_originalServerUrl);
406 [ + - + + ]: 4 : if (!isSameOrigin(serverFieldUrl, originalUrl)) {
407 [ + - + - : 2 : qCWarning(lcNextcloudAuth)
+ + ]
408 [ + - ]: 1 : << "Rejected cross-origin server field in login success:"
409 [ + - ]: 1 : << server
410 [ + - + - : 1 : << "(expected origin:" << m_originalServerUrl << ")";
+ - ]
411 : 1 : m_pollEndpoint.clear();
412 : 1 : m_pollToken.clear();
413 [ + - ]: 1 : emit loginFailed(
414 [ + - ]: 2 : tr("Security error: login response has a foreign server origin"));
415 : 1 : return;
416 : : }
417 : :
418 [ + - + - : 6 : qCInfo(lcNextcloudAuth) << "Login successful for" << loginName
+ - + - +
+ ]
419 [ + - + - ]: 3 : << "on" << server;
420 : 3 : m_pollEndpoint.clear();
421 : 3 : m_pollToken.clear();
422 [ + - ]: 3 : emit loginSuccess(server, loginName, appPassword);
423 [ + + + + : 25 : });
+ + + + +
+ + + + +
+ + + + ]
424 : 10 : }
|