diff --git a/src/gui/accountmanager.cpp b/src/gui/accountmanager.cpp index 0158c22488ef8..8299530bf6c60 100644 --- a/src/gui/accountmanager.cpp +++ b/src/gui/accountmanager.cpp @@ -43,6 +43,7 @@ constexpr auto caCertsKeyC = "CaCertificates"; constexpr auto accountsC = "Accounts"; constexpr auto versionC = "version"; constexpr auto serverVersionC = "serverVersion"; +constexpr auto trustedRedirectHostsC = "trustedRedirectHosts"; constexpr auto serverColorC = "serverColor"; constexpr auto serverTextColorC = "serverTextColor"; constexpr auto skipE2eeMetadataChecksumValidationC = "skipE2eeMetadataChecksumValidation"; @@ -645,6 +646,9 @@ AccountPtr AccountManager::loadAccountHelper(QSettings &settings) qCInfo(lcAccountManager) << "Account for" << acc->url() << "using auth type" << authType; acc->_serverVersion = settings.value(QLatin1String(serverVersionC)).toString(); + if (const auto trustedRedirectHosts = settings.value(QLatin1String(trustedRedirectHostsC)).toStringList(); !trustedRedirectHosts.isEmpty()) { + acc->setTrustedRedirectHosts(trustedRedirectHosts); + } acc->_serverColor = settings.value(QLatin1String(serverColorC)).value(); acc->_serverTextColor = settings.value(QLatin1String(serverTextColorC)).value(); acc->_serverHasValidSubscription = settings.value(QLatin1String(serverHasValidSubscriptionC), false).value(); diff --git a/src/libsync/abstractnetworkjob.cpp b/src/libsync/abstractnetworkjob.cpp index 199f7c0546d9c..d16d80c494792 100644 --- a/src/libsync/abstractnetworkjob.cpp +++ b/src/libsync/abstractnetworkjob.cpp @@ -36,6 +36,50 @@ namespace OCC { Q_LOGGING_CATEGORY(lcNetworkJob, "nextcloud.sync.networkjob", QtInfoMsg) +namespace { + +// Registrable domain (eTLD+1) of url, using Qt's public-suffix list. +// Falls back to the bare host for IP addresses and intranet names that have +// no public suffix, so those only ever match themselves exactly. +QString registrableDomain(const QUrl &url) +{ + const auto host = url.host(); + const auto tld = url.topLevelDomain(); + if (tld.isEmpty() || !host.endsWith(tld)) { + return host; + } + auto label = host.left(host.size() - tld.size()); + const auto lastDot = label.lastIndexOf(QLatin1Char('.')); + return (lastDot < 0 ? label : label.mid(lastDot + 1)) + tld; +} + +// Whether the Authorization header / credentials may follow a redirect from +// origin to target without leaking them to an untrusted host. +bool redirectKeepsCredentials(const QUrl &origin, const QUrl &target, + const QUrl &accountUrl, const QStringList &trustedHosts) +{ + // Never carry credentials across a scheme change (e.g. HTTPS -> HTTP). + if (origin.scheme() != target.scheme()) { + return false; + } + // Same host:port as the immediate origin. + if (origin.host() == target.host() && origin.port() == target.port()) { + return true; + } + // Same registrable domain as the account's own server. Covers SSO / + // reverse-proxy setups such as auth.example.com <-> cloud.example.com. + if (accountUrl.scheme() == target.scheme()) { + const auto accountDomain = registrableDomain(accountUrl); + if (!accountDomain.isEmpty() && accountDomain == registrableDomain(target)) { + return true; + } + } + // Explicitly trusted host (e.g. a third-party identity provider). + return trustedHosts.contains(target.host(), Qt::CaseInsensitive); +} + +} // anonymous namespace + // If not set, it is overwritten by the Application constructor with the value from the config int AbstractNetworkJob::httpTimeout = qEnvironmentVariableIntValue("OWNCLOUD_TIMEOUT"); bool AbstractNetworkJob::enableTimeout = true; @@ -276,8 +320,8 @@ void AbstractNetworkJob::slotFinished() auto request = reply()->request(); - if (!(requestedUrl.host() == redirectUrl.host() && requestedUrl.port() == redirectUrl.port())) { - qCWarning(lcNetworkJob).nospace() << "redirect target mismatches origin, removing credentials" + if (!redirectKeepsCredentials(requestedUrl, redirectUrl, _account->url(), _account->trustedRedirectHosts())) { + qCWarning(lcNetworkJob).nospace() << "redirect target is not trusted, removing credentials" << " origin=" << requestedUrl.host() << ":" << requestedUrl.port() << " target=" << redirectUrl.host() << ":" << redirectUrl.port(); diff --git a/src/libsync/account.cpp b/src/libsync/account.cpp index e4ef525c1fa1e..c8ff4c14103fb 100644 --- a/src/libsync/account.cpp +++ b/src/libsync/account.cpp @@ -584,6 +584,16 @@ void Account::setUserVisibleHost(const QString &host) _userVisibleUrl.setHost(host); } +QStringList Account::trustedRedirectHosts() const +{ + return _settingsMap.value(QStringLiteral("trustedRedirectHosts")).toStringList(); +} + +void Account::setTrustedRedirectHosts(const QStringList &hosts) +{ + _settingsMap.insert(QStringLiteral("trustedRedirectHosts"), hosts); +} + QVariant Account::credentialSetting(const QString &key) const { if (_credentials) { diff --git a/src/libsync/account.h b/src/libsync/account.h index a7aa5b46b9004..b6070205f349c 100644 --- a/src/libsync/account.h +++ b/src/libsync/account.h @@ -17,6 +17,7 @@ #include "updatechannel.h" #include +#include #include #include #include @@ -243,6 +244,13 @@ class OWNCLOUDSYNC_EXPORT Account : public QObject void setApprovedCerts(const QList certs); void addApprovedCerts(const QList certs); + /** Additional hosts to which credentials may still be sent when an HTTP + * redirect targets a host other than the account's own. Redirects within + * the account's own registrable domain are always trusted; this list + * covers cross-domain cases such as a third-party SSO / identity provider. */ + [[nodiscard]] QStringList trustedRedirectHosts() const; + void setTrustedRedirectHosts(const QStringList &hosts); + // Usually when a user explicitly rejects a certificate we don't // ask again. After this call, a dialog will again be shown when // the next unknown certificate is encountered.