Sync translations from Transifex and run lupdate
[qBittorrent.git] / src / webui / webapplication.cpp
blobbe1c1997220d0de3d8020f1b3de73129ffba1766
1 /*
2 * Bittorrent Client using Qt and libtorrent.
3 * Copyright (C) 2014, 2022-2023 Vladimir Golovnev <glassez@yandex.ru>
5 * This program is free software; you can redistribute it and/or
6 * modify it under the terms of the GNU General Public License
7 * as published by the Free Software Foundation; either version 2
8 * of the License, or (at your option) any later version.
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
15 * You should have received a copy of the GNU General Public License
16 * along with this program; if not, write to the Free Software
17 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19 * In addition, as a special exception, the copyright holders give permission to
20 * link this program with the OpenSSL project's "OpenSSL" library (or with
21 * modified versions of it that use the same license as the "OpenSSL" library),
22 * and distribute the linked executables. You must obey the GNU General Public
23 * License in all respects for all of the code used other than "OpenSSL". If you
24 * modify file(s), you may extend this exception to your version of the file(s),
25 * but you are not obligated to do so. If you do not wish to do so, delete this
26 * exception statement from your version.
29 #include "webapplication.h"
31 #include <algorithm>
32 #include <chrono>
34 #include <QDateTime>
35 #include <QDebug>
36 #include <QDir>
37 #include <QFileInfo>
38 #include <QJsonDocument>
39 #include <QMetaObject>
40 #include <QMimeDatabase>
41 #include <QMimeType>
42 #include <QNetworkCookie>
43 #include <QRegularExpression>
44 #include <QThread>
45 #include <QTimer>
46 #include <QUrl>
48 #include "base/algorithm.h"
49 #include "base/http/httperror.h"
50 #include "base/logger.h"
51 #include "base/preferences.h"
52 #include "base/types.h"
53 #include "base/utils/fs.h"
54 #include "base/utils/io.h"
55 #include "base/utils/misc.h"
56 #include "base/utils/random.h"
57 #include "base/utils/string.h"
58 #include "api/apierror.h"
59 #include "api/appcontroller.h"
60 #include "api/authcontroller.h"
61 #include "api/logcontroller.h"
62 #include "api/rsscontroller.h"
63 #include "api/searchcontroller.h"
64 #include "api/synccontroller.h"
65 #include "api/torrentscontroller.h"
66 #include "api/transfercontroller.h"
67 #include "freediskspacechecker.h"
69 const int MAX_ALLOWED_FILESIZE = 10 * 1024 * 1024;
70 const QString DEFAULT_SESSION_COOKIE_NAME = u"SID"_s;
72 const QString WWW_FOLDER = u":/www"_s;
73 const QString PUBLIC_FOLDER = u"/public"_s;
74 const QString PRIVATE_FOLDER = u"/private"_s;
76 using namespace std::chrono_literals;
78 const std::chrono::seconds FREEDISKSPACE_CHECK_TIMEOUT = 30s;
80 namespace
82 QStringMap parseCookie(const QStringView cookieStr)
84 // [rfc6265] 4.2.1. Syntax
85 QStringMap ret;
86 const QList<QStringView> cookies = cookieStr.split(u';', Qt::SkipEmptyParts);
88 for (const auto &cookie : cookies)
90 const int idx = cookie.indexOf(u'=');
91 if (idx < 0)
92 continue;
94 const QString name = cookie.left(idx).trimmed().toString();
95 const QString value = Utils::String::unquote(cookie.mid(idx + 1).trimmed()).toString();
96 ret.insert(name, value);
98 return ret;
101 QUrl urlFromHostHeader(const QString &hostHeader)
103 if (!hostHeader.contains(u"://"))
104 return {u"http://"_s + hostHeader};
105 return hostHeader;
108 QString getCachingInterval(QString contentType)
110 contentType = contentType.toLower();
112 if (contentType.startsWith(u"image/"))
113 return u"private, max-age=604800"_s; // 1 week
115 if ((contentType == Http::CONTENT_TYPE_CSS)
116 || (contentType == Http::CONTENT_TYPE_JS))
118 // short interval in case of program update
119 return u"private, max-age=43200"_s; // 12 hrs
122 return u"no-store"_s;
125 QString createLanguagesOptionsHtml()
127 // List language files
128 const QDir langDir {u":/www/translations"_s};
129 const QStringList langFiles = langDir.entryList(QStringList(u"webui_*.qm"_s), QDir::Files);
130 QStringList languages;
131 for (const QString &langFile : langFiles)
133 const QString localeStr = langFile.section(u"_"_s, 1, -1).section(u"."_s, 0, 0); // remove "webui_" and ".qm"
134 languages << u"<option value=\"%1\">%2</option>"_s.arg(localeStr, Utils::Misc::languageToLocalizedString(localeStr));
135 qDebug() << "Supported locale:" << localeStr;
138 return languages.join(u'\n');
141 bool isValidCookieName(const QString &cookieName)
143 if (cookieName.isEmpty() || (cookieName.size() > 128))
144 return false;
146 const QRegularExpression invalidNameRegex {u"[^a-zA-Z0-9_\\-]"_s};
147 if (invalidNameRegex.match(cookieName).hasMatch())
148 return false;
150 return true;
154 WebApplication::WebApplication(IApplication *app, QObject *parent)
155 : QObject(parent)
156 , ApplicationComponent(app)
157 , m_cacheID {QString::number(Utils::Random::rand(), 36)}
158 , m_authController {new AuthController(this, app, this)}
159 , m_workerThread {new QThread}
160 , m_freeDiskSpaceChecker {new FreeDiskSpaceChecker}
161 , m_freeDiskSpaceCheckingTimer {new QTimer(this)}
163 declarePublicAPI(u"auth/login"_s);
165 configure();
166 connect(Preferences::instance(), &Preferences::changed, this, &WebApplication::configure);
168 m_sessionCookieName = Preferences::instance()->getWebAPISessionCookieName();
169 if (!isValidCookieName(m_sessionCookieName))
171 if (!m_sessionCookieName.isEmpty())
173 LogMsg(tr("Unacceptable session cookie name is specified: '%1'. Default one is used.")
174 .arg(m_sessionCookieName), Log::WARNING);
176 m_sessionCookieName = DEFAULT_SESSION_COOKIE_NAME;
179 m_freeDiskSpaceChecker->moveToThread(m_workerThread.get());
180 connect(m_workerThread.get(), &QThread::finished, m_freeDiskSpaceChecker, &QObject::deleteLater);
181 m_workerThread->start();
183 m_freeDiskSpaceCheckingTimer->setInterval(FREEDISKSPACE_CHECK_TIMEOUT);
184 m_freeDiskSpaceCheckingTimer->setSingleShot(true);
185 connect(m_freeDiskSpaceCheckingTimer, &QTimer::timeout, m_freeDiskSpaceChecker, &FreeDiskSpaceChecker::check);
186 connect(m_freeDiskSpaceChecker, &FreeDiskSpaceChecker::checked, m_freeDiskSpaceCheckingTimer, qOverload<>(&QTimer::start));
187 QMetaObject::invokeMethod(m_freeDiskSpaceChecker, &FreeDiskSpaceChecker::check);
190 WebApplication::~WebApplication()
192 // cleanup sessions data
193 qDeleteAll(m_sessions);
196 void WebApplication::sendWebUIFile()
198 if (request().path.contains(u'\\'))
199 throw BadRequestHTTPError();
201 if (const QList<QStringView> pathItems = QStringView(request().path).split(u'/', Qt::SkipEmptyParts)
202 ; pathItems.contains(u".") || pathItems.contains(u".."))
204 throw BadRequestHTTPError();
207 const QString path = (request().path != u"/")
208 ? request().path
209 : u"/index.html"_s;
211 Path localPath = m_rootFolder
212 / Path(session() ? PRIVATE_FOLDER : PUBLIC_FOLDER)
213 / Path(path);
214 if (!localPath.exists() && session())
216 // try to send public file if there is no private one
217 localPath = m_rootFolder / Path(PUBLIC_FOLDER) / Path(path);
220 if (m_isAltUIUsed)
222 if (!Utils::Fs::isRegularFile(localPath))
223 throw InternalServerErrorHTTPError(tr("Unacceptable file type, only regular file is allowed."));
225 const QString rootFolder = m_rootFolder.data();
227 QFileInfo fileInfo {localPath.parentPath().data()};
228 while (fileInfo.path() != rootFolder)
230 if (fileInfo.isSymLink())
231 throw InternalServerErrorHTTPError(tr("Symlinks inside alternative UI folder are forbidden."));
233 fileInfo.setFile(fileInfo.path());
237 sendFile(localPath);
240 void WebApplication::translateDocument(QString &data) const
242 const QRegularExpression regex(u"QBT_TR\\((([^\\)]|\\)(?!QBT_TR))+)\\)QBT_TR\\[CONTEXT=([a-zA-Z_][a-zA-Z0-9_]*)\\]"_s);
244 int i = 0;
245 bool found = true;
246 while (i < data.size() && found)
248 QRegularExpressionMatch regexMatch;
249 i = data.indexOf(regex, i, &regexMatch);
250 if (i >= 0)
252 const QString sourceText = regexMatch.captured(1);
253 const QString context = regexMatch.captured(3);
255 const QString loadedText = m_translationFileLoaded
256 ? m_translator.translate(context.toUtf8().constData(), sourceText.toUtf8().constData())
257 : QString();
258 // `loadedText` is empty when translation is not provided
259 // it should fallback to `sourceText`
260 QString translation = loadedText.isEmpty() ? sourceText : loadedText;
262 // Use HTML code for quotes to prevent issues with JS
263 translation.replace(u'\'', u"&#39;"_s);
264 translation.replace(u'\"', u"&#34;"_s);
266 data.replace(i, regexMatch.capturedLength(), translation);
267 i += translation.length();
269 else
271 found = false; // no more translatable strings
274 data.replace(u"${LANG}"_s, m_currentLocale.left(2));
275 data.replace(u"${CACHEID}"_s, m_cacheID);
279 WebSession *WebApplication::session()
281 return m_currentSession;
284 const Http::Request &WebApplication::request() const
286 return m_request;
289 const Http::Environment &WebApplication::env() const
291 return m_env;
294 void WebApplication::setUsername(const QString &username)
296 m_authController->setUsername(username);
299 void WebApplication::setPasswordHash(const QByteArray &passwordHash)
301 m_authController->setPasswordHash(passwordHash);
304 void WebApplication::doProcessRequest()
306 const QRegularExpressionMatch match = m_apiPathPattern.match(request().path);
307 if (!match.hasMatch())
309 sendWebUIFile();
310 return;
313 const QString action = match.captured(u"action"_s);
314 const QString scope = match.captured(u"scope"_s);
316 // Check public/private scope
317 if (!session() && !isPublicAPI(scope, action))
318 throw ForbiddenHTTPError();
320 // Find matching API
321 APIController *controller = nullptr;
322 if (session())
323 controller = session()->getAPIController(scope);
324 if (!controller)
326 if (scope == u"auth")
327 controller = m_authController;
328 else
329 throw NotFoundHTTPError();
332 // Filter HTTP methods
333 const auto allowedMethodIter = m_allowedMethod.find({scope, action});
334 if (allowedMethodIter == m_allowedMethod.end())
336 // by default allow both GET, POST methods
337 if ((m_request.method != Http::METHOD_GET) && (m_request.method != Http::METHOD_POST))
338 throw MethodNotAllowedHTTPError();
340 else
342 if (*allowedMethodIter != m_request.method)
343 throw MethodNotAllowedHTTPError();
346 DataMap data;
347 for (const Http::UploadedFile &torrent : request().files)
348 data[torrent.filename] = torrent.data;
352 const QVariant result = controller->run(action, m_params, data);
353 switch (result.userType())
355 case QMetaType::QJsonDocument:
356 print(result.toJsonDocument().toJson(QJsonDocument::Compact), Http::CONTENT_TYPE_JSON);
357 break;
358 case QMetaType::QByteArray:
359 print(result.toByteArray(), Http::CONTENT_TYPE_TXT);
360 break;
361 case QMetaType::QString:
362 default:
363 print(result.toString(), Http::CONTENT_TYPE_TXT);
364 break;
367 catch (const APIError &error)
369 // re-throw as HTTPError
370 switch (error.type())
372 case APIErrorType::AccessDenied:
373 throw ForbiddenHTTPError(error.message());
374 case APIErrorType::BadData:
375 throw UnsupportedMediaTypeHTTPError(error.message());
376 case APIErrorType::BadParams:
377 throw BadRequestHTTPError(error.message());
378 case APIErrorType::Conflict:
379 throw ConflictHTTPError(error.message());
380 case APIErrorType::NotFound:
381 throw NotFoundHTTPError(error.message());
382 default:
383 Q_ASSERT(false);
388 void WebApplication::configure()
390 const auto *pref = Preferences::instance();
392 const bool isAltUIUsed = pref->isAltWebUIEnabled();
393 const Path rootFolder = (!isAltUIUsed ? Path(WWW_FOLDER) : pref->getWebUIRootFolder());
394 if ((isAltUIUsed != m_isAltUIUsed) || (rootFolder != m_rootFolder))
396 m_isAltUIUsed = isAltUIUsed;
397 m_rootFolder = rootFolder;
398 m_translatedFiles.clear();
399 if (!m_isAltUIUsed)
400 LogMsg(tr("Using built-in WebUI."));
401 else
402 LogMsg(tr("Using custom WebUI. Location: \"%1\".").arg(m_rootFolder.toString()));
405 const QString newLocale = pref->getLocale();
406 if (m_currentLocale != newLocale)
408 m_currentLocale = newLocale;
409 m_translatedFiles.clear();
411 m_translationFileLoaded = m_translator.load((m_rootFolder / Path(u"translations/webui_"_s) + newLocale).data());
412 if (m_translationFileLoaded)
414 LogMsg(tr("WebUI translation for selected locale (%1) has been successfully loaded.")
415 .arg(newLocale));
417 else
419 LogMsg(tr("Couldn't load WebUI translation for selected locale (%1).").arg(newLocale), Log::WARNING);
423 m_isLocalAuthEnabled = pref->isWebUILocalAuthEnabled();
424 m_isAuthSubnetWhitelistEnabled = pref->isWebUIAuthSubnetWhitelistEnabled();
425 m_authSubnetWhitelist = pref->getWebUIAuthSubnetWhitelist();
426 m_sessionTimeout = pref->getWebUISessionTimeout();
428 m_domainList = pref->getServerDomains().split(u';', Qt::SkipEmptyParts);
429 std::for_each(m_domainList.begin(), m_domainList.end(), [](QString &entry) { entry = entry.trimmed(); });
431 m_isCSRFProtectionEnabled = pref->isWebUICSRFProtectionEnabled();
432 m_isSecureCookieEnabled = pref->isWebUISecureCookieEnabled();
433 m_isHostHeaderValidationEnabled = pref->isWebUIHostHeaderValidationEnabled();
434 m_isHttpsEnabled = pref->isWebUIHttpsEnabled();
436 m_prebuiltHeaders.clear();
437 m_prebuiltHeaders.push_back({Http::HEADER_X_XSS_PROTECTION, u"1; mode=block"_s});
438 m_prebuiltHeaders.push_back({Http::HEADER_X_CONTENT_TYPE_OPTIONS, u"nosniff"_s});
440 if (!m_isAltUIUsed)
442 m_prebuiltHeaders.push_back({Http::HEADER_CROSS_ORIGIN_OPENER_POLICY, u"same-origin"_s});
443 m_prebuiltHeaders.push_back({Http::HEADER_REFERRER_POLICY, u"same-origin"_s});
446 const bool isClickjackingProtectionEnabled = pref->isWebUIClickjackingProtectionEnabled();
447 if (isClickjackingProtectionEnabled)
448 m_prebuiltHeaders.push_back({Http::HEADER_X_FRAME_OPTIONS, u"SAMEORIGIN"_s});
450 const QString contentSecurityPolicy =
451 (m_isAltUIUsed
452 ? QString()
453 : 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)
454 + (isClickjackingProtectionEnabled ? u" frame-ancestors 'self';"_s : QString())
455 + (m_isHttpsEnabled ? u" upgrade-insecure-requests;"_s : QString());
456 if (!contentSecurityPolicy.isEmpty())
457 m_prebuiltHeaders.push_back({Http::HEADER_CONTENT_SECURITY_POLICY, contentSecurityPolicy});
459 if (pref->isWebUICustomHTTPHeadersEnabled())
461 const QString customHeaders = pref->getWebUICustomHTTPHeaders();
462 const QList<QStringView> customHeaderLines = QStringView(customHeaders).trimmed().split(u'\n', Qt::SkipEmptyParts);
464 for (const QStringView line : customHeaderLines)
466 const int idx = line.indexOf(u':');
467 if (idx < 0)
469 // require separator `:` to be present even if `value` field can be empty
470 LogMsg(tr("Missing ':' separator in WebUI custom HTTP header: \"%1\"").arg(line.toString()), Log::WARNING);
471 continue;
474 const QString header = line.left(idx).trimmed().toString();
475 const QString value = line.mid(idx + 1).trimmed().toString();
476 m_prebuiltHeaders.push_back({header, value});
480 m_isReverseProxySupportEnabled = pref->isWebUIReverseProxySupportEnabled();
481 if (m_isReverseProxySupportEnabled)
483 const QStringList proxyList = pref->getWebUITrustedReverseProxiesList().split(u';', Qt::SkipEmptyParts);
485 m_trustedReverseProxyList.clear();
486 m_trustedReverseProxyList.reserve(proxyList.size());
488 for (QString proxy : proxyList)
490 if (!proxy.contains(u'/'))
492 const QAbstractSocket::NetworkLayerProtocol protocol = QHostAddress(proxy).protocol();
493 if (protocol == QAbstractSocket::IPv4Protocol)
495 proxy.append(u"/32");
497 else if (protocol == QAbstractSocket::IPv6Protocol)
499 proxy.append(u"/128");
503 const std::optional<Utils::Net::Subnet> subnet = Utils::Net::parseSubnet(proxy);
504 if (subnet)
505 m_trustedReverseProxyList.push_back(subnet.value());
508 if (m_trustedReverseProxyList.isEmpty())
509 m_isReverseProxySupportEnabled = false;
513 void WebApplication::declarePublicAPI(const QString &apiPath)
515 m_publicAPIs << apiPath;
518 void WebApplication::sendFile(const Path &path)
520 const QDateTime lastModified = Utils::Fs::lastModified(path);
522 // find translated file in cache
523 if (!m_isAltUIUsed)
525 if (const auto it = m_translatedFiles.constFind(path);
526 (it != m_translatedFiles.constEnd()) && (lastModified <= it->lastModified))
528 print(it->data, it->mimeType);
529 setHeader({Http::HEADER_CACHE_CONTROL, getCachingInterval(it->mimeType)});
530 return;
534 const auto readResult = Utils::IO::readFile(path, MAX_ALLOWED_FILESIZE);
535 if (!readResult)
537 const QString message = tr("Web server error. %1").arg(readResult.error().message);
539 switch (readResult.error().status)
541 case Utils::IO::ReadError::NotExist:
542 qDebug("%s", qUtf8Printable(message));
543 // don't write log messages here to avoid exhausting the disk space
544 throw NotFoundHTTPError();
546 case Utils::IO::ReadError::ExceedSize:
547 qWarning("%s", qUtf8Printable(message));
548 LogMsg(message, Log::WARNING);
549 throw InternalServerErrorHTTPError(readResult.error().message);
551 case Utils::IO::ReadError::Failed:
552 case Utils::IO::ReadError::SizeMismatch:
553 LogMsg(message, Log::WARNING);
554 throw InternalServerErrorHTTPError(readResult.error().message);
557 throw InternalServerErrorHTTPError(tr("Web server error. Unknown error."));
560 QByteArray data = readResult.value();
561 const QMimeType mimeType = QMimeDatabase().mimeTypeForFileNameAndData(path.data(), data);
562 const bool isTranslatable = !m_isAltUIUsed && mimeType.inherits(u"text/plain"_s);
564 if (isTranslatable)
566 auto dataStr = QString::fromUtf8(data);
567 // Translate the file
568 translateDocument(dataStr);
570 // Add the language options
571 if (path == (m_rootFolder / Path(PRIVATE_FOLDER) / Path(u"views/preferences.html"_s)))
572 dataStr.replace(u"${LANGUAGE_OPTIONS}"_s, createLanguagesOptionsHtml());
574 data = dataStr.toUtf8();
575 m_translatedFiles[path] = {data, mimeType.name(), lastModified}; // caching translated file
578 print(data, mimeType.name());
579 setHeader({Http::HEADER_CACHE_CONTROL, getCachingInterval(mimeType.name())});
582 Http::Response WebApplication::processRequest(const Http::Request &request, const Http::Environment &env)
584 m_currentSession = nullptr;
585 m_request = request;
586 m_env = env;
587 m_params.clear();
589 if (m_request.method == Http::METHOD_GET)
591 for (auto iter = m_request.query.cbegin(); iter != m_request.query.cend(); ++iter)
592 m_params[iter.key()] = QString::fromUtf8(iter.value());
594 else
596 m_params = m_request.posts;
599 // clear response
600 clear();
604 // block suspicious requests
605 if ((m_isCSRFProtectionEnabled && isCrossSiteRequest(m_request))
606 || (m_isHostHeaderValidationEnabled && !validateHostHeader(m_domainList)))
608 throw UnauthorizedHTTPError();
611 // reverse proxy resolve client address
612 m_clientAddress = resolveClientAddress();
614 sessionInitialize();
615 doProcessRequest();
617 catch (const HTTPError &error)
619 status(error.statusCode(), error.statusText());
620 print((!error.message().isEmpty() ? error.message() : error.statusText()), Http::CONTENT_TYPE_TXT);
623 for (const Http::Header &prebuiltHeader : asConst(m_prebuiltHeaders))
624 setHeader(prebuiltHeader);
626 return response();
629 QString WebApplication::clientId() const
631 return m_clientAddress.toString();
634 void WebApplication::sessionInitialize()
636 Q_ASSERT(!m_currentSession);
638 const QString sessionId {parseCookie(m_request.headers.value(u"cookie"_s)).value(m_sessionCookieName)};
640 // TODO: Additional session check
642 if (!sessionId.isEmpty())
644 m_currentSession = m_sessions.value(sessionId);
645 if (m_currentSession)
647 if (m_currentSession->hasExpired(m_sessionTimeout))
649 // session is outdated - removing it
650 delete m_sessions.take(sessionId);
651 m_currentSession = nullptr;
653 else
655 m_currentSession->updateTimestamp();
658 else
660 qDebug() << Q_FUNC_INFO << "session does not exist!";
664 if (!m_currentSession && !isAuthNeeded())
665 sessionStart();
668 QString WebApplication::generateSid() const
670 QString sid;
674 const quint32 tmp[] =
675 {Utils::Random::rand(), Utils::Random::rand(), Utils::Random::rand()
676 , Utils::Random::rand(), Utils::Random::rand(), Utils::Random::rand()};
677 sid = QString::fromLatin1(QByteArray::fromRawData(reinterpret_cast<const char *>(tmp), sizeof(tmp)).toBase64());
679 while (m_sessions.contains(sid));
681 return sid;
684 bool WebApplication::isAuthNeeded()
686 if (!m_isLocalAuthEnabled && Utils::Net::isLoopbackAddress(m_clientAddress))
687 return false;
688 if (m_isAuthSubnetWhitelistEnabled && Utils::Net::isIPInSubnets(m_clientAddress, m_authSubnetWhitelist))
689 return false;
690 return true;
693 bool WebApplication::isPublicAPI(const QString &scope, const QString &action) const
695 return m_publicAPIs.contains(u"%1/%2"_s.arg(scope, action));
698 void WebApplication::sessionStart()
700 Q_ASSERT(!m_currentSession);
702 // remove outdated sessions
703 Algorithm::removeIf(m_sessions, [this](const QString &, const WebSession *session)
705 if (session->hasExpired(m_sessionTimeout))
707 delete session;
708 return true;
711 return false;
714 m_currentSession = new WebSession(generateSid(), app());
715 m_sessions[m_currentSession->id()] = m_currentSession;
717 m_currentSession->registerAPIController<AppController>(u"app"_s);
718 m_currentSession->registerAPIController<LogController>(u"log"_s);
719 m_currentSession->registerAPIController<RSSController>(u"rss"_s);
720 m_currentSession->registerAPIController<SearchController>(u"search"_s);
721 m_currentSession->registerAPIController<TorrentsController>(u"torrents"_s);
722 m_currentSession->registerAPIController<TransferController>(u"transfer"_s);
724 auto *syncController = m_currentSession->registerAPIController<SyncController>(u"sync"_s);
725 syncController->updateFreeDiskSpace(m_freeDiskSpaceChecker->lastResult());
726 connect(m_freeDiskSpaceChecker, &FreeDiskSpaceChecker::checked, syncController, &SyncController::updateFreeDiskSpace);
728 QNetworkCookie cookie {m_sessionCookieName.toLatin1(), m_currentSession->id().toUtf8()};
729 cookie.setHttpOnly(true);
730 cookie.setSecure(m_isSecureCookieEnabled && m_isHttpsEnabled);
731 cookie.setPath(u"/"_s);
732 QByteArray cookieRawForm = cookie.toRawForm();
733 if (m_isCSRFProtectionEnabled)
734 cookieRawForm.append("; SameSite=Strict");
735 else if (cookie.isSecure())
736 cookieRawForm.append("; SameSite=None");
737 setHeader({Http::HEADER_SET_COOKIE, QString::fromLatin1(cookieRawForm)});
740 void WebApplication::sessionEnd()
742 Q_ASSERT(m_currentSession);
744 QNetworkCookie cookie {m_sessionCookieName.toLatin1()};
745 cookie.setPath(u"/"_s);
746 cookie.setExpirationDate(QDateTime::currentDateTime().addDays(-1));
748 delete m_sessions.take(m_currentSession->id());
749 m_currentSession = nullptr;
751 setHeader({Http::HEADER_SET_COOKIE, QString::fromLatin1(cookie.toRawForm())});
754 bool WebApplication::isCrossSiteRequest(const Http::Request &request) const
756 // https://www.owasp.org/index.php/Cross-Site_Request_Forgery_(CSRF)_Prevention_Cheat_Sheet#Verifying_Same_Origin_with_Standard_Headers
758 const auto isSameOrigin = [](const QUrl &left, const QUrl &right) -> bool
760 // [rfc6454] 5. Comparing Origins
761 return ((left.port() == right.port())
762 // && (left.scheme() == right.scheme()) // not present in this context
763 && (left.host() == right.host()));
766 const QString targetOrigin = request.headers.value(Http::HEADER_X_FORWARDED_HOST, request.headers.value(Http::HEADER_HOST));
767 const QString originValue = request.headers.value(Http::HEADER_ORIGIN);
768 const QString refererValue = request.headers.value(Http::HEADER_REFERER);
770 if (originValue.isEmpty() && refererValue.isEmpty())
772 // owasp.org recommends to block this request, but doing so will inevitably lead Web API users to spoof headers
773 // so lets be permissive here
774 return false;
777 // sent with CORS requests, as well as with POST requests
778 if (!originValue.isEmpty())
780 const bool isInvalid = !isSameOrigin(urlFromHostHeader(targetOrigin), originValue);
781 if (isInvalid)
783 LogMsg(tr("WebUI: Origin header & Target origin mismatch! Source IP: '%1'. Origin header: '%2'. Target origin: '%3'")
784 .arg(m_env.clientAddress.toString(), originValue, targetOrigin)
785 , Log::WARNING);
787 return isInvalid;
790 if (!refererValue.isEmpty())
792 const bool isInvalid = !isSameOrigin(urlFromHostHeader(targetOrigin), refererValue);
793 if (isInvalid)
795 LogMsg(tr("WebUI: Referer header & Target origin mismatch! Source IP: '%1'. Referer header: '%2'. Target origin: '%3'")
796 .arg(m_env.clientAddress.toString(), refererValue, targetOrigin)
797 , Log::WARNING);
799 return isInvalid;
802 return true;
805 bool WebApplication::validateHostHeader(const QStringList &domains) const
807 const QUrl hostHeader = urlFromHostHeader(m_request.headers[Http::HEADER_HOST]);
808 const QString requestHost = hostHeader.host();
810 // (if present) try matching host header's port with local port
811 const int requestPort = hostHeader.port();
812 if ((requestPort != -1) && (m_env.localPort != requestPort))
814 LogMsg(tr("WebUI: Invalid Host header, port mismatch. Request source IP: '%1'. Server port: '%2'. Received Host header: '%3'")
815 .arg(m_env.clientAddress.toString()).arg(m_env.localPort)
816 .arg(m_request.headers[Http::HEADER_HOST])
817 , Log::WARNING);
818 return false;
821 // try matching host header with local address
822 const bool sameAddr = m_env.localAddress.isEqual(QHostAddress(requestHost));
824 if (sameAddr)
825 return true;
827 // try matching host header with domain list
828 for (const auto &domain : domains)
830 const QRegularExpression domainRegex {Utils::String::wildcardToRegexPattern(domain), QRegularExpression::CaseInsensitiveOption};
831 if (requestHost.contains(domainRegex))
832 return true;
835 LogMsg(tr("WebUI: Invalid Host header. Request source IP: '%1'. Received Host header: '%2'")
836 .arg(m_env.clientAddress.toString(), m_request.headers[Http::HEADER_HOST])
837 , Log::WARNING);
838 return false;
841 QHostAddress WebApplication::resolveClientAddress() const
843 if (!m_isReverseProxySupportEnabled)
844 return m_env.clientAddress;
846 // Only reverse proxy can overwrite client address
847 if (!Utils::Net::isIPInSubnets(m_env.clientAddress, m_trustedReverseProxyList))
848 return m_env.clientAddress;
850 const QString forwardedFor = m_request.headers.value(Http::HEADER_X_FORWARDED_FOR);
852 if (!forwardedFor.isEmpty())
854 // client address is the 1st global IP in X-Forwarded-For or, if none available, the 1st IP in the list
855 const QStringList remoteIpList = forwardedFor.split(u',', Qt::SkipEmptyParts);
857 if (!remoteIpList.isEmpty())
859 QHostAddress clientAddress;
861 for (const QString &remoteIp : remoteIpList)
863 if (clientAddress.setAddress(remoteIp) && clientAddress.isGlobal())
864 return clientAddress;
867 if (clientAddress.setAddress(remoteIpList[0]))
868 return clientAddress;
872 return m_env.clientAddress;
875 // WebSession
877 WebSession::WebSession(const QString &sid, IApplication *app)
878 : ApplicationComponent(app)
879 , m_sid {sid}
881 updateTimestamp();
884 QString WebSession::id() const
886 return m_sid;
889 bool WebSession::hasExpired(const qint64 seconds) const
891 if (seconds <= 0)
892 return false;
893 return m_timer.hasExpired(seconds * 1000);
896 void WebSession::updateTimestamp()
898 m_timer.start();
901 APIController *WebSession::getAPIController(const QString &scope) const
903 return m_apiControllers.value(scope);