Correctly handle "torrent finished" events
[qBittorrent.git] / src / webui / webapplication.cpp
blob54d2b404f22853ad522cd77d722fc6af1266ac56
1 /*
2 * Bittorrent Client using Qt and libtorrent.
3 * Copyright (C) 2014-2024 Vladimir Golovnev <glassez@yandex.ru>
4 * Copyright (C) 2024 Radu Carpa <radu.carpa@cern.ch>
6 * This program is free software; you can redistribute it and/or
7 * modify it under the terms of the GNU General Public License
8 * as published by the Free Software Foundation; either version 2
9 * of the License, or (at your option) any later version.
11 * This program is distributed in the hope that it will be useful,
12 * but WITHOUT ANY WARRANTY; without even the implied warranty of
13 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 * GNU General Public License for more details.
16 * You should have received a copy of the GNU General Public License
17 * along with this program; if not, write to the Free Software
18 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
20 * In addition, as a special exception, the copyright holders give permission to
21 * link this program with the OpenSSL project's "OpenSSL" library (or with
22 * modified versions of it that use the same license as the "OpenSSL" library),
23 * and distribute the linked executables. You must obey the GNU General Public
24 * License in all respects for all of the code used other than "OpenSSL". If you
25 * modify file(s), you may extend this exception to your version of the file(s),
26 * but you are not obligated to do so. If you do not wish to do so, delete this
27 * exception statement from your version.
30 #include "webapplication.h"
32 #include <algorithm>
33 #include <chrono>
35 #include <QDateTime>
36 #include <QDebug>
37 #include <QDir>
38 #include <QFileInfo>
39 #include <QJsonDocument>
40 #include <QMetaObject>
41 #include <QMimeDatabase>
42 #include <QMimeType>
43 #include <QNetworkCookie>
44 #include <QRegularExpression>
45 #include <QThread>
46 #include <QTimer>
47 #include <QUrl>
49 #include "base/algorithm.h"
50 #include "base/bittorrent/torrentcreationmanager.h"
51 #include "base/http/httperror.h"
52 #include "base/logger.h"
53 #include "base/preferences.h"
54 #include "base/types.h"
55 #include "base/utils/fs.h"
56 #include "base/utils/io.h"
57 #include "base/utils/misc.h"
58 #include "base/utils/random.h"
59 #include "base/utils/string.h"
60 #include "api/apierror.h"
61 #include "api/appcontroller.h"
62 #include "api/authcontroller.h"
63 #include "api/logcontroller.h"
64 #include "api/rsscontroller.h"
65 #include "api/searchcontroller.h"
66 #include "api/synccontroller.h"
67 #include "api/torrentcreatorcontroller.h"
68 #include "api/torrentscontroller.h"
69 #include "api/transfercontroller.h"
70 #include "freediskspacechecker.h"
72 const int MAX_ALLOWED_FILESIZE = 10 * 1024 * 1024;
73 const QString DEFAULT_SESSION_COOKIE_NAME = u"SID"_s;
75 const QString WWW_FOLDER = u":/www"_s;
76 const QString PUBLIC_FOLDER = u"/public"_s;
77 const QString PRIVATE_FOLDER = u"/private"_s;
79 using namespace std::chrono_literals;
81 const std::chrono::seconds FREEDISKSPACE_CHECK_TIMEOUT = 30s;
83 namespace
85 QStringMap parseCookie(const QStringView cookieStr)
87 // [rfc6265] 4.2.1. Syntax
88 QStringMap ret;
89 const QList<QStringView> cookies = cookieStr.split(u';', Qt::SkipEmptyParts);
91 for (const auto &cookie : cookies)
93 const int idx = cookie.indexOf(u'=');
94 if (idx < 0)
95 continue;
97 const QString name = cookie.left(idx).trimmed().toString();
98 const QString value = Utils::String::unquote(cookie.mid(idx + 1).trimmed()).toString();
99 ret.insert(name, value);
101 return ret;
104 QUrl urlFromHostHeader(const QString &hostHeader)
106 if (!hostHeader.contains(u"://"))
107 return {u"http://"_s + hostHeader};
108 return hostHeader;
111 QString getCachingInterval(QString contentType)
113 contentType = contentType.toLower();
115 if (contentType.startsWith(u"image/"))
116 return u"private, max-age=604800"_s; // 1 week
118 if ((contentType == Http::CONTENT_TYPE_CSS) || (contentType == Http::CONTENT_TYPE_JS))
120 // short interval in case of program update
121 return u"private, max-age=43200"_s; // 12 hrs
124 return u"no-store"_s;
127 QString createLanguagesOptionsHtml()
129 // List language files
130 const QStringList langFiles = QDir(u":/www/translations"_s)
131 .entryList({u"webui_*.qm"_s}, QDir::Files, QDir::Name);
133 QStringList languages;
134 languages.reserve(langFiles.size());
136 for (const QString &langFile : langFiles)
138 const auto langCode = QStringView(langFile).sliced(6).chopped(3); // remove "webui_" and ".qm"
139 const QString entry = u"<option value=\"%1\">%2</option>"_s
140 .arg(langCode, Utils::Misc::languageToLocalizedString(langCode));
141 languages.append(entry);
144 return languages.join(u'\n');
147 bool isValidCookieName(const QString &cookieName)
149 if (cookieName.isEmpty() || (cookieName.size() > 128))
150 return false;
152 const QRegularExpression invalidNameRegex {u"[^a-zA-Z0-9_\\-]"_s};
153 if (invalidNameRegex.match(cookieName).hasMatch())
154 return false;
156 return true;
160 WebApplication::WebApplication(IApplication *app, QObject *parent)
161 : ApplicationComponent(app, parent)
162 , m_cacheID {QString::number(Utils::Random::rand(), 36)}
163 , m_authController {new AuthController(this, app, this)}
164 , m_workerThread {new QThread}
165 , m_freeDiskSpaceChecker {new FreeDiskSpaceChecker}
166 , m_freeDiskSpaceCheckingTimer {new QTimer(this)}
167 , m_torrentCreationManager {new BitTorrent::TorrentCreationManager(app, this)}
169 declarePublicAPI(u"auth/login"_s);
171 configure();
172 connect(Preferences::instance(), &Preferences::changed, this, &WebApplication::configure);
174 m_sessionCookieName = Preferences::instance()->getWebAPISessionCookieName();
175 if (!isValidCookieName(m_sessionCookieName))
177 if (!m_sessionCookieName.isEmpty())
179 LogMsg(tr("Unacceptable session cookie name is specified: '%1'. Default one is used.")
180 .arg(m_sessionCookieName), Log::WARNING);
182 m_sessionCookieName = DEFAULT_SESSION_COOKIE_NAME;
185 m_freeDiskSpaceChecker->moveToThread(m_workerThread.get());
186 connect(m_workerThread.get(), &QThread::finished, m_freeDiskSpaceChecker, &QObject::deleteLater);
187 m_workerThread->setObjectName("WebApplication m_workerThread");
188 m_workerThread->start();
190 m_freeDiskSpaceCheckingTimer->setInterval(FREEDISKSPACE_CHECK_TIMEOUT);
191 m_freeDiskSpaceCheckingTimer->setSingleShot(true);
192 connect(m_freeDiskSpaceCheckingTimer, &QTimer::timeout, m_freeDiskSpaceChecker, &FreeDiskSpaceChecker::check);
193 connect(m_freeDiskSpaceChecker, &FreeDiskSpaceChecker::checked, m_freeDiskSpaceCheckingTimer, qOverload<>(&QTimer::start));
194 QMetaObject::invokeMethod(m_freeDiskSpaceChecker, &FreeDiskSpaceChecker::check);
197 WebApplication::~WebApplication()
199 // cleanup sessions data
200 qDeleteAll(m_sessions);
203 void WebApplication::sendWebUIFile()
205 if (request().path.contains(u'\\'))
206 throw BadRequestHTTPError();
208 if (const QList<QStringView> pathItems = QStringView(request().path).split(u'/', Qt::SkipEmptyParts)
209 ; pathItems.contains(u".") || pathItems.contains(u".."))
211 throw BadRequestHTTPError();
214 const QString path = (request().path != u"/")
215 ? request().path
216 : u"/index.html"_s;
218 Path localPath = m_rootFolder
219 / Path(session() ? PRIVATE_FOLDER : PUBLIC_FOLDER)
220 / Path(path);
221 if (!localPath.exists() && session())
223 // try to send public file if there is no private one
224 localPath = m_rootFolder / Path(PUBLIC_FOLDER) / Path(path);
227 if (m_isAltUIUsed)
229 if (!Utils::Fs::isRegularFile(localPath))
230 throw InternalServerErrorHTTPError(tr("Unacceptable file type, only regular file is allowed."));
232 const QString rootFolder = m_rootFolder.data();
234 QFileInfo fileInfo {localPath.parentPath().data()};
235 while (fileInfo.path() != rootFolder)
237 if (fileInfo.isSymLink())
238 throw InternalServerErrorHTTPError(tr("Symlinks inside alternative UI folder are forbidden."));
240 fileInfo.setFile(fileInfo.path());
244 sendFile(localPath);
247 void WebApplication::translateDocument(QString &data) const
249 const QRegularExpression regex(u"QBT_TR\\((([^\\)]|\\)(?!QBT_TR))+)\\)QBT_TR\\[CONTEXT=([a-zA-Z_][a-zA-Z0-9_]*)\\]"_s);
251 int i = 0;
252 bool found = true;
253 while (i < data.size() && found)
255 QRegularExpressionMatch regexMatch;
256 i = data.indexOf(regex, i, &regexMatch);
257 if (i >= 0)
259 const QString sourceText = regexMatch.captured(1);
260 const QString context = regexMatch.captured(3);
262 const QString loadedText = m_translationFileLoaded
263 ? m_translator.translate(context.toUtf8().constData(), sourceText.toUtf8().constData())
264 : QString();
265 // `loadedText` is empty when translation is not provided
266 // it should fallback to `sourceText`
267 QString translation = loadedText.isEmpty() ? sourceText : loadedText;
269 // Escape quotes to workaround issues with HTML attributes
270 // FIXME: this is a dirty workaround to deal with broken translation strings:
271 // 1. Translation strings is the culprit of the issue, they should be fixed instead
272 // 2. The escaped quote/string is wrong for JS. JS use backslash to escape the quote: "\""
273 translation.replace(u'"', u"&#34;"_s);
275 data.replace(i, regexMatch.capturedLength(), translation);
276 i += translation.length();
278 else
280 found = false; // no more translatable strings
283 data.replace(u"${LANG}"_s, m_currentLocale.left(2));
284 data.replace(u"${CACHEID}"_s, m_cacheID);
288 WebSession *WebApplication::session()
290 return m_currentSession;
293 const Http::Request &WebApplication::request() const
295 return m_request;
298 const Http::Environment &WebApplication::env() const
300 return m_env;
303 void WebApplication::setUsername(const QString &username)
305 m_authController->setUsername(username);
308 void WebApplication::setPasswordHash(const QByteArray &passwordHash)
310 m_authController->setPasswordHash(passwordHash);
313 void WebApplication::doProcessRequest()
315 const QRegularExpressionMatch match = m_apiPathPattern.match(request().path);
316 if (!match.hasMatch())
318 sendWebUIFile();
319 return;
322 const QString action = match.captured(u"action"_s);
323 const QString scope = match.captured(u"scope"_s);
325 // Check public/private scope
326 if (!session() && !isPublicAPI(scope, action))
327 throw ForbiddenHTTPError();
329 // Find matching API
330 APIController *controller = nullptr;
331 if (session())
332 controller = session()->getAPIController(scope);
333 if (!controller)
335 if (scope == u"auth")
336 controller = m_authController;
337 else
338 throw NotFoundHTTPError();
341 // Filter HTTP methods
342 const auto allowedMethodIter = m_allowedMethod.find({scope, action});
343 if (allowedMethodIter == m_allowedMethod.end())
345 // by default allow both GET, POST methods
346 if ((m_request.method != Http::METHOD_GET) && (m_request.method != Http::METHOD_POST))
347 throw MethodNotAllowedHTTPError();
349 else
351 if (*allowedMethodIter != m_request.method)
352 throw MethodNotAllowedHTTPError();
355 DataMap data;
356 for (const Http::UploadedFile &torrent : request().files)
357 data[torrent.filename] = torrent.data;
361 const APIResult result = controller->run(action, m_params, data);
362 switch (result.data.userType())
364 case QMetaType::QJsonDocument:
365 print(result.data.toJsonDocument().toJson(QJsonDocument::Compact), Http::CONTENT_TYPE_JSON);
366 break;
367 case QMetaType::QByteArray:
369 const auto resultData = result.data.toByteArray();
370 print(resultData, (!result.mimeType.isEmpty() ? result.mimeType : Http::CONTENT_TYPE_TXT));
371 if (!result.filename.isEmpty())
373 setHeader({u"Content-Disposition"_s, u"attachment; filename=\"%1\""_s.arg(result.filename)});
376 break;
377 case QMetaType::QString:
378 default:
379 print(result.data.toString(), Http::CONTENT_TYPE_TXT);
380 break;
383 catch (const APIError &error)
385 // re-throw as HTTPError
386 switch (error.type())
388 case APIErrorType::AccessDenied:
389 throw ForbiddenHTTPError(error.message());
390 case APIErrorType::BadData:
391 throw UnsupportedMediaTypeHTTPError(error.message());
392 case APIErrorType::BadParams:
393 throw BadRequestHTTPError(error.message());
394 case APIErrorType::Conflict:
395 throw ConflictHTTPError(error.message());
396 case APIErrorType::NotFound:
397 throw NotFoundHTTPError(error.message());
398 default:
399 Q_UNREACHABLE();
400 break;
405 void WebApplication::configure()
407 const auto *pref = Preferences::instance();
409 const bool isAltUIUsed = pref->isAltWebUIEnabled();
410 const Path rootFolder = (!isAltUIUsed ? Path(WWW_FOLDER) : pref->getWebUIRootFolder());
411 if ((isAltUIUsed != m_isAltUIUsed) || (rootFolder != m_rootFolder))
413 m_isAltUIUsed = isAltUIUsed;
414 m_rootFolder = rootFolder;
415 m_translatedFiles.clear();
416 if (!m_isAltUIUsed)
417 LogMsg(tr("Using built-in WebUI."));
418 else
419 LogMsg(tr("Using custom WebUI. Location: \"%1\".").arg(m_rootFolder.toString()));
422 const QString newLocale = pref->getLocale();
423 if (m_currentLocale != newLocale)
425 m_currentLocale = newLocale;
426 m_translatedFiles.clear();
428 m_translationFileLoaded = m_translator.load((m_rootFolder / Path(u"translations/webui_"_s) + newLocale).data());
429 if (m_translationFileLoaded)
431 LogMsg(tr("WebUI translation for selected locale (%1) has been successfully loaded.")
432 .arg(newLocale));
434 else
436 LogMsg(tr("Couldn't load WebUI translation for selected locale (%1).").arg(newLocale), Log::WARNING);
440 m_isLocalAuthEnabled = pref->isWebUILocalAuthEnabled();
441 m_isAuthSubnetWhitelistEnabled = pref->isWebUIAuthSubnetWhitelistEnabled();
442 m_authSubnetWhitelist = pref->getWebUIAuthSubnetWhitelist();
443 m_sessionTimeout = pref->getWebUISessionTimeout();
445 m_domainList = pref->getServerDomains().split(u';', Qt::SkipEmptyParts);
446 std::for_each(m_domainList.begin(), m_domainList.end(), [](QString &entry) { entry = entry.trimmed(); });
448 m_isCSRFProtectionEnabled = pref->isWebUICSRFProtectionEnabled();
449 m_isSecureCookieEnabled = pref->isWebUISecureCookieEnabled();
450 m_isHostHeaderValidationEnabled = pref->isWebUIHostHeaderValidationEnabled();
451 m_isHttpsEnabled = pref->isWebUIHttpsEnabled();
453 m_prebuiltHeaders.clear();
454 m_prebuiltHeaders.push_back({Http::HEADER_X_XSS_PROTECTION, u"1; mode=block"_s});
455 m_prebuiltHeaders.push_back({Http::HEADER_X_CONTENT_TYPE_OPTIONS, u"nosniff"_s});
457 if (!m_isAltUIUsed)
459 m_prebuiltHeaders.push_back({Http::HEADER_CROSS_ORIGIN_OPENER_POLICY, u"same-origin"_s});
460 m_prebuiltHeaders.push_back({Http::HEADER_REFERRER_POLICY, u"same-origin"_s});
463 const bool isClickjackingProtectionEnabled = pref->isWebUIClickjackingProtectionEnabled();
464 if (isClickjackingProtectionEnabled)
465 m_prebuiltHeaders.push_back({Http::HEADER_X_FRAME_OPTIONS, u"SAMEORIGIN"_s});
467 const QString contentSecurityPolicy =
468 (m_isAltUIUsed
469 ? QString()
470 : u"default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; script-src 'self' 'unsafe-inline'; object-src 'none'; form-action 'self';"_s)
471 + (isClickjackingProtectionEnabled ? u" frame-ancestors 'self';"_s : QString())
472 + (m_isHttpsEnabled ? u" upgrade-insecure-requests;"_s : QString());
473 if (!contentSecurityPolicy.isEmpty())
474 m_prebuiltHeaders.push_back({Http::HEADER_CONTENT_SECURITY_POLICY, contentSecurityPolicy});
476 if (pref->isWebUICustomHTTPHeadersEnabled())
478 const QString customHeaders = pref->getWebUICustomHTTPHeaders();
479 const QList<QStringView> customHeaderLines = QStringView(customHeaders).trimmed().split(u'\n', Qt::SkipEmptyParts);
481 for (const QStringView line : customHeaderLines)
483 const int idx = line.indexOf(u':');
484 if (idx < 0)
486 // require separator `:` to be present even if `value` field can be empty
487 LogMsg(tr("Missing ':' separator in WebUI custom HTTP header: \"%1\"").arg(line.toString()), Log::WARNING);
488 continue;
491 const QString header = line.left(idx).trimmed().toString();
492 const QString value = line.mid(idx + 1).trimmed().toString();
493 m_prebuiltHeaders.push_back({header, value});
497 m_isReverseProxySupportEnabled = pref->isWebUIReverseProxySupportEnabled();
498 if (m_isReverseProxySupportEnabled)
500 const QStringList proxyList = pref->getWebUITrustedReverseProxiesList().split(u';', Qt::SkipEmptyParts);
502 m_trustedReverseProxyList.clear();
503 m_trustedReverseProxyList.reserve(proxyList.size());
505 for (QString proxy : proxyList)
507 if (!proxy.contains(u'/'))
509 const QAbstractSocket::NetworkLayerProtocol protocol = QHostAddress(proxy).protocol();
510 if (protocol == QAbstractSocket::IPv4Protocol)
512 proxy.append(u"/32");
514 else if (protocol == QAbstractSocket::IPv6Protocol)
516 proxy.append(u"/128");
520 const std::optional<Utils::Net::Subnet> subnet = Utils::Net::parseSubnet(proxy);
521 if (subnet)
522 m_trustedReverseProxyList.push_back(subnet.value());
525 if (m_trustedReverseProxyList.isEmpty())
526 m_isReverseProxySupportEnabled = false;
530 void WebApplication::declarePublicAPI(const QString &apiPath)
532 m_publicAPIs << apiPath;
535 void WebApplication::sendFile(const Path &path)
537 const QDateTime lastModified = Utils::Fs::lastModified(path);
539 // find translated file in cache
540 if (!m_isAltUIUsed)
542 if (const auto it = m_translatedFiles.constFind(path);
543 (it != m_translatedFiles.constEnd()) && (lastModified <= it->lastModified))
545 print(it->data, it->mimeType);
546 setHeader({Http::HEADER_CACHE_CONTROL, getCachingInterval(it->mimeType)});
547 return;
551 const auto readResult = Utils::IO::readFile(path, MAX_ALLOWED_FILESIZE);
552 if (!readResult)
554 const QString message = tr("Web server error. %1").arg(readResult.error().message);
556 switch (readResult.error().status)
558 case Utils::IO::ReadError::NotExist:
559 qDebug("%s", qUtf8Printable(message));
560 // don't write log messages here to avoid exhausting the disk space
561 throw NotFoundHTTPError();
563 case Utils::IO::ReadError::ExceedSize:
564 qWarning("%s", qUtf8Printable(message));
565 LogMsg(message, Log::WARNING);
566 throw InternalServerErrorHTTPError(readResult.error().message);
568 case Utils::IO::ReadError::Failed:
569 case Utils::IO::ReadError::SizeMismatch:
570 LogMsg(message, Log::WARNING);
571 throw InternalServerErrorHTTPError(readResult.error().message);
574 throw InternalServerErrorHTTPError(tr("Web server error. Unknown error."));
577 QByteArray data = readResult.value();
578 const QMimeType mimeType = QMimeDatabase().mimeTypeForFileNameAndData(path.data(), data);
579 const bool isTranslatable = !m_isAltUIUsed && mimeType.inherits(u"text/plain"_s);
581 if (isTranslatable)
583 auto dataStr = QString::fromUtf8(data);
584 // Translate the file
585 translateDocument(dataStr);
587 // Add the language options
588 if (path == (m_rootFolder / Path(PRIVATE_FOLDER) / Path(u"views/preferences.html"_s)))
589 dataStr.replace(u"${LANGUAGE_OPTIONS}"_s, createLanguagesOptionsHtml());
591 data = dataStr.toUtf8();
592 m_translatedFiles[path] = {data, mimeType.name(), lastModified}; // caching translated file
595 print(data, mimeType.name());
596 setHeader({Http::HEADER_CACHE_CONTROL, getCachingInterval(mimeType.name())});
599 Http::Response WebApplication::processRequest(const Http::Request &request, const Http::Environment &env)
601 m_currentSession = nullptr;
602 m_request = request;
603 m_env = env;
604 m_params.clear();
606 if (m_request.method == Http::METHOD_GET)
608 for (auto iter = m_request.query.cbegin(); iter != m_request.query.cend(); ++iter)
609 m_params[iter.key()] = QString::fromUtf8(iter.value());
611 else
613 m_params = m_request.posts;
616 // clear response
617 clear();
621 // block suspicious requests
622 if ((m_isCSRFProtectionEnabled && isCrossSiteRequest(m_request))
623 || (m_isHostHeaderValidationEnabled && !validateHostHeader(m_domainList)))
625 throw UnauthorizedHTTPError();
628 // reverse proxy resolve client address
629 m_clientAddress = resolveClientAddress();
631 sessionInitialize();
632 doProcessRequest();
634 catch (const HTTPError &error)
636 status(error.statusCode(), error.statusText());
637 print((!error.message().isEmpty() ? error.message() : error.statusText()), Http::CONTENT_TYPE_TXT);
640 for (const Http::Header &prebuiltHeader : asConst(m_prebuiltHeaders))
641 setHeader(prebuiltHeader);
643 return response();
646 QString WebApplication::clientId() const
648 return m_clientAddress.toString();
651 void WebApplication::sessionInitialize()
653 Q_ASSERT(!m_currentSession);
655 const QString sessionId {parseCookie(m_request.headers.value(u"cookie"_s)).value(m_sessionCookieName)};
657 // TODO: Additional session check
659 if (!sessionId.isEmpty())
661 m_currentSession = m_sessions.value(sessionId);
662 if (m_currentSession)
664 if (m_currentSession->hasExpired(m_sessionTimeout))
666 // session is outdated - removing it
667 delete m_sessions.take(sessionId);
668 m_currentSession = nullptr;
670 else
672 m_currentSession->updateTimestamp();
675 else
677 qDebug() << Q_FUNC_INFO << "session does not exist!";
681 if (!m_currentSession && !isAuthNeeded())
682 sessionStart();
685 QString WebApplication::generateSid() const
687 QString sid;
691 const quint32 tmp[] =
692 {Utils::Random::rand(), Utils::Random::rand(), Utils::Random::rand()
693 , Utils::Random::rand(), Utils::Random::rand(), Utils::Random::rand()};
694 sid = QString::fromLatin1(QByteArray::fromRawData(reinterpret_cast<const char *>(tmp), sizeof(tmp)).toBase64());
696 while (m_sessions.contains(sid));
698 return sid;
701 bool WebApplication::isAuthNeeded()
703 if (!m_isLocalAuthEnabled && m_clientAddress.isLoopback())
704 return false;
705 if (m_isAuthSubnetWhitelistEnabled && Utils::Net::isIPInSubnets(m_clientAddress, m_authSubnetWhitelist))
706 return false;
707 return true;
710 bool WebApplication::isPublicAPI(const QString &scope, const QString &action) const
712 return m_publicAPIs.contains(u"%1/%2"_s.arg(scope, action));
715 void WebApplication::sessionStart()
717 Q_ASSERT(!m_currentSession);
719 // remove outdated sessions
720 Algorithm::removeIf(m_sessions, [this](const QString &, const WebSession *session)
722 if (session->hasExpired(m_sessionTimeout))
724 delete session;
725 return true;
728 return false;
731 m_currentSession = new WebSession(generateSid(), app());
732 m_sessions[m_currentSession->id()] = m_currentSession;
734 m_currentSession->registerAPIController(u"app"_s, new AppController(app(), m_currentSession));
735 m_currentSession->registerAPIController(u"log"_s, new LogController(app(), m_currentSession));
736 m_currentSession->registerAPIController(u"torrentcreator"_s, new TorrentCreatorController(m_torrentCreationManager, app(), m_currentSession));
737 m_currentSession->registerAPIController(u"rss"_s, new RSSController(app(), m_currentSession));
738 m_currentSession->registerAPIController(u"search"_s, new SearchController(app(), m_currentSession));
739 m_currentSession->registerAPIController(u"torrents"_s, new TorrentsController(app(), m_currentSession));
740 m_currentSession->registerAPIController(u"transfer"_s, new TransferController(app(), m_currentSession));
742 auto *syncController = new SyncController(app(), m_currentSession);
743 syncController->updateFreeDiskSpace(m_freeDiskSpaceChecker->lastResult());
744 connect(m_freeDiskSpaceChecker, &FreeDiskSpaceChecker::checked, syncController, &SyncController::updateFreeDiskSpace);
745 m_currentSession->registerAPIController(u"sync"_s, syncController);
747 QNetworkCookie cookie {m_sessionCookieName.toLatin1(), m_currentSession->id().toLatin1()};
748 cookie.setHttpOnly(true);
749 cookie.setSecure(m_isSecureCookieEnabled && isOriginTrustworthy()); // [rfc6265] 4.1.2.5. The Secure Attribute
750 cookie.setPath(u"/"_s);
751 if (m_isCSRFProtectionEnabled)
752 cookie.setSameSitePolicy(QNetworkCookie::SameSite::Strict);
753 else if (cookie.isSecure())
754 cookie.setSameSitePolicy(QNetworkCookie::SameSite::None);
755 setHeader({Http::HEADER_SET_COOKIE, QString::fromLatin1(cookie.toRawForm())});
758 void WebApplication::sessionEnd()
760 Q_ASSERT(m_currentSession);
762 QNetworkCookie cookie {m_sessionCookieName.toLatin1()};
763 cookie.setPath(u"/"_s);
764 cookie.setExpirationDate(QDateTime::currentDateTime().addDays(-1));
766 delete m_sessions.take(m_currentSession->id());
767 m_currentSession = nullptr;
769 setHeader({Http::HEADER_SET_COOKIE, QString::fromLatin1(cookie.toRawForm())});
772 bool WebApplication::isOriginTrustworthy() const
774 // https://w3c.github.io/webappsec-secure-contexts/#is-origin-trustworthy
776 if (m_isReverseProxySupportEnabled)
778 const QString forwardedProto = request().headers.value(Http::HEADER_X_FORWARDED_PROTO);
779 if (forwardedProto.compare(u"https", Qt::CaseInsensitive) == 0)
780 return true;
783 if (m_isHttpsEnabled)
784 return true;
786 // client is on localhost
787 if (env().clientAddress.isLoopback())
788 return true;
790 return false;
793 bool WebApplication::isCrossSiteRequest(const Http::Request &request) const
795 // https://www.owasp.org/index.php/Cross-Site_Request_Forgery_(CSRF)_Prevention_Cheat_Sheet#Verifying_Same_Origin_with_Standard_Headers
797 const auto isSameOrigin = [](const QUrl &left, const QUrl &right) -> bool
799 // [rfc6454] 5. Comparing Origins
800 return ((left.port() == right.port())
801 // && (left.scheme() == right.scheme()) // not present in this context
802 && (left.host() == right.host()));
805 const QString targetOrigin = request.headers.value(Http::HEADER_X_FORWARDED_HOST, request.headers.value(Http::HEADER_HOST));
806 const QString originValue = request.headers.value(Http::HEADER_ORIGIN);
807 const QString refererValue = request.headers.value(Http::HEADER_REFERER);
809 if (originValue.isEmpty() && refererValue.isEmpty())
811 // owasp.org recommends to block this request, but doing so will inevitably lead Web API users to spoof headers
812 // so lets be permissive here
813 return false;
816 // sent with CORS requests, as well as with POST requests
817 if (!originValue.isEmpty())
819 const bool isInvalid = !isSameOrigin(urlFromHostHeader(targetOrigin), originValue);
820 if (isInvalid)
822 LogMsg(tr("WebUI: Origin header & Target origin mismatch! Source IP: '%1'. Origin header: '%2'. Target origin: '%3'")
823 .arg(m_env.clientAddress.toString(), originValue, targetOrigin)
824 , Log::WARNING);
826 return isInvalid;
829 if (!refererValue.isEmpty())
831 const bool isInvalid = !isSameOrigin(urlFromHostHeader(targetOrigin), refererValue);
832 if (isInvalid)
834 LogMsg(tr("WebUI: Referer header & Target origin mismatch! Source IP: '%1'. Referer header: '%2'. Target origin: '%3'")
835 .arg(m_env.clientAddress.toString(), refererValue, targetOrigin)
836 , Log::WARNING);
838 return isInvalid;
841 return true;
844 bool WebApplication::validateHostHeader(const QStringList &domains) const
846 const QUrl hostHeader = urlFromHostHeader(m_request.headers[Http::HEADER_HOST]);
847 const QString requestHost = hostHeader.host();
849 // (if present) try matching host header's port with local port
850 const int requestPort = hostHeader.port();
851 if ((requestPort != -1) && (m_env.localPort != requestPort))
853 LogMsg(tr("WebUI: Invalid Host header, port mismatch. Request source IP: '%1'. Server port: '%2'. Received Host header: '%3'")
854 .arg(m_env.clientAddress.toString()).arg(m_env.localPort)
855 .arg(m_request.headers[Http::HEADER_HOST])
856 , Log::WARNING);
857 return false;
860 // try matching host header with local address
861 const bool sameAddr = m_env.localAddress.isEqual(QHostAddress(requestHost));
863 if (sameAddr)
864 return true;
866 // try matching host header with domain list
867 for (const auto &domain : domains)
869 const QRegularExpression domainRegex {Utils::String::wildcardToRegexPattern(domain), QRegularExpression::CaseInsensitiveOption};
870 if (requestHost.contains(domainRegex))
871 return true;
874 LogMsg(tr("WebUI: Invalid Host header. Request source IP: '%1'. Received Host header: '%2'")
875 .arg(m_env.clientAddress.toString(), m_request.headers[Http::HEADER_HOST])
876 , Log::WARNING);
877 return false;
880 QHostAddress WebApplication::resolveClientAddress() const
882 if (!m_isReverseProxySupportEnabled)
883 return m_env.clientAddress;
885 // Only reverse proxy can overwrite client address
886 if (!Utils::Net::isIPInSubnets(m_env.clientAddress, m_trustedReverseProxyList))
887 return m_env.clientAddress;
889 const QString forwardedFor = m_request.headers.value(Http::HEADER_X_FORWARDED_FOR);
891 if (!forwardedFor.isEmpty())
893 // client address is the 1st global IP in X-Forwarded-For or, if none available, the 1st IP in the list
894 const QStringList remoteIpList = forwardedFor.split(u',', Qt::SkipEmptyParts);
896 if (!remoteIpList.isEmpty())
898 QHostAddress clientAddress;
900 for (const QString &remoteIp : remoteIpList)
902 if (clientAddress.setAddress(remoteIp) && clientAddress.isGlobal())
903 return clientAddress;
906 if (clientAddress.setAddress(remoteIpList[0]))
907 return clientAddress;
911 return m_env.clientAddress;
914 // WebSession
916 WebSession::WebSession(const QString &sid, IApplication *app)
917 : ApplicationComponent(app)
918 , m_sid {sid}
920 updateTimestamp();
923 QString WebSession::id() const
925 return m_sid;
928 bool WebSession::hasExpired(const qint64 seconds) const
930 if (seconds <= 0)
931 return false;
932 return m_timer.hasExpired(seconds * 1000);
935 void WebSession::updateTimestamp()
937 m_timer.start();
940 void WebSession::registerAPIController(const QString &scope, APIController *controller)
942 Q_ASSERT(controller);
943 m_apiControllers[scope] = controller;
946 APIController *WebSession::getAPIController(const QString &scope) const
948 return m_apiControllers.value(scope);