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: 98.9 % 174 172
Test Date: 2026-06-21 21:10:19 Functions: 100.0 % 13 13
Legend: Lines:     hit not hit
Branches: + taken - not taken # not executed
Branches: 58.0 % 476 276

             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   [ +  +  +  -  :          77 : Q_LOGGING_CATEGORY(lcNextcloudAuth, "mailjd.nextcloudauth")
             +  -  -  - ]
      15                 :             : 
      16                 :             : namespace {
      17                 :          30 : int effectivePort(const QUrl &url) {
      18         [ +  + ]:          30 :   if (url.port() >= 0)
      19                 :           4 :     return url.port();
      20         [ +  - ]:          78 :   return url.scheme().compare(QStringLiteral("https"), Qt::CaseInsensitive) == 0
      21         [ +  + ]:          26 :              ? 443
      22                 :          26 :              : 80;
      23                 :             : }
      24                 :             : 
      25                 :          17 : bool isSameOrigin(const QUrl &candidate, const QUrl &expected) {
      26   [ +  -  +  -  :          51 :   return candidate.isValid() && !candidate.host().isEmpty() &&
             +  +  -  - ]
      27   [ +  -  +  -  :          34 :          candidate.scheme().compare(expected.scheme(), Qt::CaseInsensitive) ==
          +  -  +  -  -  
                -  -  - ]
      28         [ +  + ]:          32 :              0 &&
      29   [ +  -  +  -  :          49 :          candidate.host().compare(expected.host(), Qt::CaseInsensitive) == 0 &&
          +  +  +  -  -  
                -  -  - ]
      30   [ +  -  +  -  :          30 :          effectivePort(candidate) == effectivePort(expected) &&
             +  -  +  - ]
      31   [ +  -  +  -  :          66 :          candidate.userName().isEmpty() && candidate.password().isEmpty();
          +  -  +  -  +  
          -  +  +  +  +  
          +  +  -  -  -  
                      - ]
      32                 :             : }
      33                 :             : } // namespace
      34                 :             : 
      35                 :          91 : NextcloudAuth::NextcloudAuth(QObject *parent) : QObject(parent) {
      36   [ +  -  +  -  :          91 :   m_nam = new QNetworkAccessManager(this);
             -  +  -  - ]
      37         [ +  - ]:          91 :   m_nam->setRedirectPolicy(QNetworkRequest::SameOriginRedirectPolicy);
      38   [ +  -  +  -  :          91 :   m_pollTimer = new QTimer(this);
             -  +  -  - ]
      39         [ +  - ]:          91 :   m_pollTimer->setInterval(2000);
      40         [ +  - ]:          91 :   connect(m_pollTimer, &QTimer::timeout, this, &NextcloudAuth::poll);
      41                 :          91 : }
      42                 :             : 
      43                 :         170 : NextcloudAuth::~NextcloudAuth() { cancel(); }
      44                 :             : 
      45                 :           6 : void NextcloudAuth::setNetworkAccessManager(QNetworkAccessManager *nam) {
      46   [ +  -  +  -  :           6 :   if (m_nam && m_nam->parent() == this)
                   +  - ]
      47         [ +  - ]:           6 :     delete m_nam;
      48                 :           6 :   m_nam = nam;
      49         [ +  - ]:           6 :   if (m_nam) {
      50                 :           6 :     m_nam->setParent(this);
      51                 :           6 :     m_nam->setRedirectPolicy(QNetworkRequest::SameOriginRedirectPolicy);
      52                 :             :   }
      53                 :           6 : }
      54                 :             : 
      55                 :          14 : void NextcloudAuth::startLogin(const QString &serverUrl) {
      56         [ +  - ]:          14 :   cancel(); // Cancel any ongoing flow
      57                 :             : 
      58                 :          14 :   QString url = serverUrl;
      59   [ +  -  +  + ]:          14 :   if (url.endsWith('/'))
      60         [ +  - ]:           1 :     url.chop(1);
      61                 :             : 
      62         [ +  - ]:          14 :   QUrl parsedUrl(url);
      63   [ +  -  +  + ]:          14 :   if (!isServerUrlAllowedForLogin(parsedUrl)) {
      64   [ +  -  +  -  :           6 :     qCWarning(lcNextcloudAuth)
                   +  + ]
      65   [ +  -  +  - ]:           3 :         << "Rejected insecure Nextcloud Login Flow URL:" << url;
      66   [ +  -  +  - ]:           3 :     emit loginFailed(tr("Nextcloud Login Flow requires HTTPS"));
      67                 :           3 :     return;
      68                 :             :   }
      69                 :             : 
      70                 :             :   // T-611/SEC-10: Save original server URL for SSRF origin validation
      71                 :          11 :   m_originalServerUrl = url;
      72                 :             : 
      73                 :             :   QNetworkRequest request{
      74   [ +  -  +  -  :          11 :       QUrl(url + QStringLiteral("/index.php/login/v2"))};
                   +  - ]
      75         [ +  - ]:          11 :   request.setHeader(QNetworkRequest::ContentTypeHeader,
      76                 :          22 :                     QStringLiteral("application/x-www-form-urlencoded"));
      77                 :             :   // Nextcloud requires a User-Agent for the app name display
      78   [ +  -  +  -  :          11 :   request.setRawHeader("User-Agent", "MailJD/1.0");
                   +  - ]
      79                 :             : 
      80         [ +  - ]:          11 :   m_currentReply = m_nam->post(request, QByteArray());
      81                 :          11 :   connect(m_currentReply, &QNetworkReply::finished, this,
      82         [ +  - ]:          11 :           &NextcloudAuth::onInitReply);
      83   [ +  +  +  + ]:          17 : }
      84                 :             : 
      85                 :         109 : void NextcloudAuth::cancel() {
      86                 :         109 :   m_pollTimer->stop();
      87                 :         109 :   m_pollCount = 0;
      88                 :         109 :   m_pollEndpoint.clear();
      89                 :         109 :   m_pollToken.clear();
      90         [ +  + ]:         109 :   if (m_currentReply) {
      91                 :             :     // Qt 6.8: QNetworkReply::abort() emits 'finished' synchronously, which
      92                 :             :     // would re-enter the connected poll/onInit lambda (it nulls m_currentReply
      93                 :             :     // and may emit loginFailed/loginSuccess). Disconnect first so cancel()
      94                 :             :     // owns the cleanup exclusively, then abort+deleteLater safely.
      95                 :           2 :     disconnect(m_currentReply, nullptr, this, nullptr);
      96                 :           2 :     m_currentReply->abort();
      97                 :           2 :     m_currentReply->deleteLater();
      98                 :           2 :     m_currentReply = nullptr;
      99                 :             :   }
     100                 :         109 : }
     101                 :             : 
     102                 :          32 : bool NextcloudAuth::isPolling() const { return m_pollTimer->isActive(); }
     103                 :             : 
     104                 :          26 : bool NextcloudAuth::isServerUrlAllowedForLogin(const QUrl &url) {
     105   [ +  -  +  -  :          52 :   if (!url.isValid() || url.host().isEmpty())
          +  -  -  +  +  
             -  -  +  -  
                      - ]
     106                 :           0 :     return false;
     107                 :             : 
     108   [ +  -  +  + ]:          26 :   if (url.scheme() == QLatin1String("https"))
     109                 :          17 :     return true;
     110                 :             : 
     111                 :             : #ifdef MAILJD_UNIT_TEST
     112   [ +  -  +  - ]:           7 :   if (url.scheme() == QLatin1String("http")) {
     113   [ +  -  +  - ]:           7 :     const QString host = url.host().toLower();
     114         [ +  + ]:          13 :     return host == QLatin1String("localhost") ||
     115   [ +  +  -  + ]:          13 :            host == QLatin1String("127.0.0.1") ||
     116                 :           2 :            host == QLatin1String("::1");
     117                 :           7 :   }
     118                 :             : #endif
     119                 :             : 
     120                 :           2 :   return false;
     121                 :             : }
     122                 :             : 
     123                 :          11 : void NextcloudAuth::onInitReply() {
     124   [ +  -  +  - ]:          11 :   auto *reply = qobject_cast<QNetworkReply *>(sender());
     125         [ -  + ]:          11 :   if (!reply)
     126                 :           4 :     return;
     127         [ +  - ]:          11 :   reply->deleteLater();
     128                 :          11 :   m_currentReply = nullptr;
     129                 :             : 
     130   [ +  -  +  + ]:          11 :   if (reply->error() != QNetworkReply::NoError) {
     131   [ +  -  +  -  :           2 :     qCWarning(lcNextcloudAuth)
                   +  + ]
     132   [ +  -  +  -  :           1 :         << "Login flow init failed:" << reply->errorString();
                   +  - ]
     133   [ +  -  +  - ]:           1 :     emit loginFailed(reply->errorString());
     134                 :           1 :     return;
     135                 :             :   }
     136                 :             : 
     137   [ +  -  +  - ]:          10 :   QJsonDocument doc = QJsonDocument::fromJson(reply->readAll());
     138         [ +  - ]:          10 :   QJsonObject root = doc.object();
     139                 :             : 
     140   [ +  -  +  - ]:          10 :   QJsonObject pollObj = root[QStringLiteral("poll")].toObject();
     141   [ +  -  +  - ]:          10 :   m_pollEndpoint = pollObj[QStringLiteral("endpoint")].toString();
     142   [ +  -  +  - ]:          10 :   m_pollToken = pollObj[QStringLiteral("token")].toString();
     143   [ +  -  +  - ]:          10 :   QString loginUrl = root[QStringLiteral("login")].toString();
     144                 :             : 
     145   [ +  +  +  -  :          19 :   if (m_pollEndpoint.isEmpty() || m_pollToken.isEmpty() ||
             -  +  +  + ]
     146                 :           9 :       loginUrl.isEmpty()) {
     147   [ +  -  +  - ]:           1 :     emit loginFailed(tr("Invalid Login Flow v2 response"));
     148                 :           1 :     return;
     149                 :             :   }
     150                 :             : 
     151                 :             :   // T-611/SEC-10: Validate poll endpoint origin to prevent SSRF
     152         [ +  - ]:           9 :   QUrl pollUrl(m_pollEndpoint);
     153         [ +  - ]:           9 :   QUrl serverUrl(m_originalServerUrl);
     154   [ +  -  +  + ]:           9 :   if (!isSameOrigin(pollUrl, serverUrl)) {
     155   [ +  -  +  -  :           2 :     qCWarning(lcNextcloudAuth)
                   +  + ]
     156   [ +  -  +  - ]:           1 :         << "Rejected cross-origin poll endpoint:" << m_pollEndpoint
     157   [ +  -  +  -  :           1 :         << "(expected origin:" << m_originalServerUrl << ")";
                   +  - ]
     158   [ +  -  +  - ]:           1 :     emit loginFailed(tr("Security error: poll endpoint has a foreign origin"));
     159                 :           1 :     return;
     160                 :             :   }
     161                 :             : 
     162         [ +  - ]:           8 :   const QUrl parsedLoginUrl(loginUrl);
     163   [ +  -  +  -  :          16 :   if (!isServerUrlAllowedForLogin(parsedLoginUrl) ||
                   +  + ]
     164   [ +  -  +  + ]:           8 :       !isSameOrigin(parsedLoginUrl, serverUrl)) {
     165   [ +  -  +  -  :           2 :     qCWarning(lcNextcloudAuth)
                   +  + ]
     166   [ +  -  +  - ]:           1 :         << "Rejected cross-origin login URL:" << loginUrl
     167   [ +  -  +  -  :           1 :         << "(expected origin:" << m_originalServerUrl << ")";
                   +  - ]
     168   [ +  -  +  - ]:           1 :     emit loginFailed(tr("Security error: login URL has a foreign origin"));
     169                 :           1 :     return;
     170                 :             :   }
     171                 :             : 
     172   [ +  -  +  -  :          14 :   qCInfo(lcNextcloudAuth) << "Opening browser for Nextcloud login";
             +  -  +  + ]
     173         [ +  - ]:           7 :   QDesktopServices::openUrl(parsedLoginUrl);
     174                 :             : 
     175                 :             :   // Start polling
     176                 :           7 :   m_pollCount = 0;
     177         [ +  - ]:           7 :   m_pollTimer->start();
     178   [ +  +  +  +  :          24 : }
          +  +  +  +  +  
             +  +  +  +  
                      + ]
     179                 :             : 
     180                 :          69 : void NextcloudAuth::poll() {
     181         [ +  + ]:          69 :   if (++m_pollCount > MaxPollAttempts) {
     182         [ +  - ]:           1 :     m_pollTimer->stop();
     183                 :           1 :     m_pollEndpoint.clear();
     184                 :           1 :     m_pollToken.clear();
     185         [ +  - ]:           1 :     if (m_currentReply)
     186         [ +  - ]:           1 :       m_currentReply->abort();
     187   [ +  -  +  -  :           2 :     qCWarning(lcNextcloudAuth) << "Login flow timed out after"
             +  -  +  + ]
     188   [ +  -  +  - ]:           1 :                                << (MaxPollAttempts * 2) << "seconds";
     189   [ +  -  +  - ]:           1 :     emit loginFailed(tr("Login timed out (120 seconds)"));
     190                 :          61 :     return;
     191                 :             :   }
     192                 :             : 
     193         [ +  + ]:          68 :   if (m_currentReply) {
     194   [ +  -  +  -  :         120 :     qCDebug(lcNextcloudAuth)
                   +  + ]
     195         [ +  - ]:          60 :         << "Skipping Nextcloud login poll while previous poll is in flight";
     196                 :          60 :     return;
     197                 :             :   }
     198                 :             : 
     199   [ +  -  +  - ]:           8 :   QNetworkRequest request{QUrl(m_pollEndpoint)};
     200         [ +  - ]:           8 :   request.setHeader(QNetworkRequest::ContentTypeHeader,
     201                 :          16 :                     QStringLiteral("application/x-www-form-urlencoded"));
     202                 :             : 
     203         [ +  - ]:           8 :   QUrlQuery params;
     204         [ +  - ]:          16 :   params.addQueryItem(QStringLiteral("token"), m_pollToken);
     205                 :             : 
     206                 :           8 :   m_currentReply =
     207   [ +  -  +  -  :           8 :       m_nam->post(request, params.query(QUrl::FullyEncoded).toUtf8());
                   +  - ]
     208         [ +  - ]:           8 :   connect(m_currentReply, &QNetworkReply::finished, this, [this]() {
     209   [ +  -  +  - ]:           6 :     auto *reply = qobject_cast<QNetworkReply *>(sender());
     210         [ -  + ]:           6 :     if (!reply)
     211                 :           3 :       return;
     212         [ +  - ]:           6 :     reply->deleteLater();
     213                 :           6 :     m_currentReply = nullptr;
     214                 :             : 
     215         [ -  + ]:           6 :     if (m_pollToken.isEmpty())
     216                 :           0 :       return;
     217                 :             : 
     218         [ +  - ]:           6 :     int status = reply->attribute(
     219         [ +  - ]:           6 :         QNetworkRequest::HttpStatusCodeAttribute).toInt();
     220                 :             : 
     221         [ +  + ]:           6 :     if (status == 404) {
     222                 :             :       // Not yet authorized — keep polling
     223                 :           1 :       return;
     224                 :             :     }
     225                 :             : 
     226   [ +  -  +  + ]:           5 :     if (reply->error() != QNetworkReply::NoError) {
     227         [ +  - ]:           1 :       m_pollTimer->stop();
     228                 :           1 :       m_pollEndpoint.clear();
     229                 :           1 :       m_pollToken.clear();
     230   [ +  -  +  - ]:           1 :       emit loginFailed(reply->errorString());
     231                 :           1 :       return;
     232                 :             :     }
     233                 :             : 
     234                 :             :     // Success!
     235         [ +  - ]:           4 :     m_pollTimer->stop();
     236                 :             : 
     237   [ +  -  +  - ]:           4 :     QJsonDocument doc = QJsonDocument::fromJson(reply->readAll());
     238         [ +  - ]:           4 :     QJsonObject obj = doc.object();
     239                 :             : 
     240   [ +  -  +  - ]:           4 :     QString server = obj[QStringLiteral("server")].toString();
     241   [ +  -  +  - ]:           4 :     QString loginName = obj[QStringLiteral("loginName")].toString();
     242   [ +  -  +  - ]:           4 :     QString appPassword = obj[QStringLiteral("appPassword")].toString();
     243                 :             : 
     244   [ +  +  +  -  :           4 :     if (server.isEmpty() || loginName.isEmpty() || appPassword.isEmpty()) {
             -  +  +  + ]
     245                 :           1 :       m_pollEndpoint.clear();
     246                 :           1 :       m_pollToken.clear();
     247   [ +  -  +  - ]:           1 :       emit loginFailed(tr("Invalid response from server"));
     248                 :           1 :       return;
     249                 :             :     }
     250                 :             : 
     251   [ +  -  +  -  :           6 :     qCInfo(lcNextcloudAuth) << "Login successful for" << loginName
          +  -  +  -  +  
                      + ]
     252   [ +  -  +  - ]:           3 :                             << "on" << server;
     253                 :           3 :     m_pollEndpoint.clear();
     254                 :           3 :     m_pollToken.clear();
     255         [ +  - ]:           3 :     emit loginSuccess(server, loginName, appPassword);
     256   [ +  +  +  +  :           8 :   });
          +  +  +  +  +  
                      + ]
     257                 :           8 : }
        

Generated by: LCOV version 2.0-1