MailJD nbsp;·nbsp; Test Dashboard nbsp;·nbsp; Coverage
LCOV - code coverage report
Current view: top level - service - NextcloudAuth.cpp (source / functions) Coverage Total Hit
Test: MailJD Coverage (Unit + E2E) Lines: 92.3 % 298 275
Test Date: 2026-07-27 17:53:44 Functions: 100.0 % 18 18
Legend: Lines:     hit not hit
Branches: + taken - not taken # not executed
Branches: 55.4 % 682 378

             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 : }
        

Generated by: LCOV version 2.0-1