Fix incorrect function being used
[qBittorrent.git] / src / webui / webapplication.cpp
bloba1ea0c51a0810452b897e7ac50c51fe74486460c
1 /*
2 * Bittorrent Client using Qt and libtorrent.
3 * Copyright (C) 2014 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>
33 #include <QDateTime>
34 #include <QDebug>
35 #include <QFile>
36 #include <QFileInfo>
37 #include <QJsonDocument>
38 #include <QMimeDatabase>
39 #include <QMimeType>
40 #include <QNetworkCookie>
41 #include <QRegExp>
42 #include <QUrl>
44 #include "base/algorithm.h"
45 #include "base/global.h"
46 #include "base/http/httperror.h"
47 #include "base/logger.h"
48 #include "base/preferences.h"
49 #include "base/utils/bytearray.h"
50 #include "base/utils/fs.h"
51 #include "base/utils/misc.h"
52 #include "base/utils/random.h"
53 #include "base/utils/string.h"
54 #include "api/apierror.h"
55 #include "api/appcontroller.h"
56 #include "api/authcontroller.h"
57 #include "api/logcontroller.h"
58 #include "api/rsscontroller.h"
59 #include "api/searchcontroller.h"
60 #include "api/synccontroller.h"
61 #include "api/torrentscontroller.h"
62 #include "api/transfercontroller.h"
64 constexpr int MAX_ALLOWED_FILESIZE = 10 * 1024 * 1024;
66 const QString PATH_PREFIX_IMAGES {QStringLiteral("/images/")};
67 const QString WWW_FOLDER {QStringLiteral(":/www")};
68 const QString PUBLIC_FOLDER {QStringLiteral("/public")};
69 const QString PRIVATE_FOLDER {QStringLiteral("/private")};
71 namespace
73 QStringMap parseCookie(const QString &cookieStr)
75 // [rfc6265] 4.2.1. Syntax
76 QStringMap ret;
77 const QVector<QStringRef> cookies = cookieStr.splitRef(';', QString::SkipEmptyParts);
79 for (const auto &cookie : cookies) {
80 const int idx = cookie.indexOf('=');
81 if (idx < 0)
82 continue;
84 const QString name = cookie.left(idx).trimmed().toString();
85 const QString value = Utils::String::unquote(cookie.mid(idx + 1).trimmed()).toString();
86 ret.insert(name, value);
88 return ret;
91 QUrl urlFromHostHeader(const QString &hostHeader)
93 if (!hostHeader.contains(QLatin1String("://")))
94 return {QLatin1String("http://") + hostHeader};
95 return hostHeader;
98 QString getCachingInterval(QString contentType)
100 contentType = contentType.toLower();
102 if (contentType.startsWith(QLatin1String("image/")))
103 return QLatin1String("private, max-age=604800"); // 1 week
105 if ((contentType == Http::CONTENT_TYPE_CSS)
106 || (contentType == Http::CONTENT_TYPE_JS)) {
107 // short interval in case of program update
108 return QLatin1String("private, max-age=43200"); // 12 hrs
111 return QLatin1String("no-store");
115 WebApplication::WebApplication(QObject *parent)
116 : QObject(parent)
117 , m_cacheID {QString::number(Utils::Random::rand(), 36)}
119 registerAPIController(QLatin1String("app"), new AppController(this, this));
120 registerAPIController(QLatin1String("auth"), new AuthController(this, this));
121 registerAPIController(QLatin1String("log"), new LogController(this, this));
122 registerAPIController(QLatin1String("rss"), new RSSController(this, this));
123 registerAPIController(QLatin1String("search"), new SearchController(this, this));
124 registerAPIController(QLatin1String("sync"), new SyncController(this, this));
125 registerAPIController(QLatin1String("torrents"), new TorrentsController(this, this));
126 registerAPIController(QLatin1String("transfer"), new TransferController(this, this));
128 declarePublicAPI(QLatin1String("auth/login"));
130 configure();
131 connect(Preferences::instance(), &Preferences::changed, this, &WebApplication::configure);
134 WebApplication::~WebApplication()
136 // cleanup sessions data
137 qDeleteAll(m_sessions);
140 void WebApplication::sendWebUIFile()
142 const QStringList pathItems {request().path.split('/', QString::SkipEmptyParts)};
143 if (pathItems.contains(".") || pathItems.contains(".."))
144 throw InternalServerErrorHTTPError();
146 if (!m_isAltUIUsed) {
147 if (request().path.startsWith(PATH_PREFIX_IMAGES)) {
148 const QString imageFilename {request().path.mid(PATH_PREFIX_IMAGES.size())};
149 sendFile(QLatin1String(":/icons/") + imageFilename);
150 return;
154 const QString path {
155 (request().path != QLatin1String("/")
156 ? request().path
157 : QLatin1String("/index.html"))
160 QString localPath {
161 m_rootFolder
162 + (session() ? PRIVATE_FOLDER : PUBLIC_FOLDER)
163 + path
166 QFileInfo fileInfo {localPath};
168 if (!fileInfo.exists() && session()) {
169 // try to send public file if there is no private one
170 localPath = m_rootFolder + PUBLIC_FOLDER + path;
171 fileInfo.setFile(localPath);
174 if (m_isAltUIUsed) {
175 #ifdef Q_OS_UNIX
176 if (!Utils::Fs::isRegularFile(localPath)) {
177 status(500, "Internal Server Error");
178 print(tr("Unacceptable file type, only regular file is allowed."), Http::CONTENT_TYPE_TXT);
179 return;
181 #endif
183 while (fileInfo.filePath() != m_rootFolder) {
184 if (fileInfo.isSymLink())
185 throw InternalServerErrorHTTPError(tr("Symlinks inside alternative UI folder are forbidden."));
187 fileInfo.setFile(fileInfo.path());
191 sendFile(localPath);
194 void WebApplication::translateDocument(QString &data) const
196 const QRegularExpression regex("QBT_TR\\((([^\\)]|\\)(?!QBT_TR))+)\\)QBT_TR\\[CONTEXT=([a-zA-Z_][a-zA-Z0-9_]*)\\]");
198 int i = 0;
199 bool found = true;
200 while (i < data.size() && found) {
201 QRegularExpressionMatch regexMatch;
202 i = data.indexOf(regex, i, &regexMatch);
203 if (i >= 0) {
204 const QString sourceText = regexMatch.captured(1);
205 const QString context = regexMatch.captured(3);
207 const QString loadedText = m_translationFileLoaded
208 ? m_translator.translate(context.toUtf8().constData(), sourceText.toUtf8().constData())
209 : QString();
210 // `loadedText` is empty when translation is not provided
211 // it should fallback to `sourceText`
212 QString translation = loadedText.isEmpty() ? sourceText : loadedText;
214 // Use HTML code for quotes to prevent issues with JS
215 translation.replace('\'', "&#39;");
216 translation.replace('\"', "&#34;");
218 data.replace(i, regexMatch.capturedLength(), translation);
219 i += translation.length();
221 else {
222 found = false; // no more translatable strings
225 data.replace(QLatin1String("${LANG}"), m_currentLocale.left(2));
226 data.replace(QLatin1String("${CACHEID}"), m_cacheID);
230 WebSession *WebApplication::session()
232 return m_currentSession;
235 const Http::Request &WebApplication::request() const
237 return m_request;
240 const Http::Environment &WebApplication::env() const
242 return m_env;
245 void WebApplication::doProcessRequest()
247 const QRegularExpressionMatch match = m_apiPathPattern.match(request().path);
248 if (!match.hasMatch()) {
249 sendWebUIFile();
250 return;
253 const QString action = match.captured(QLatin1String("action"));
254 const QString scope = match.captured(QLatin1String("scope"));
256 APIController *controller = m_apiControllers.value(scope);
257 if (!controller)
258 throw NotFoundHTTPError();
260 if (!session() && !isPublicAPI(scope, action))
261 throw ForbiddenHTTPError();
263 DataMap data;
264 for (const Http::UploadedFile &torrent : request().files)
265 data[torrent.filename] = torrent.data;
267 try {
268 const QVariant result = controller->run(action, m_params, data);
269 switch (result.userType()) {
270 case QMetaType::QJsonDocument:
271 print(result.toJsonDocument().toJson(QJsonDocument::Compact), Http::CONTENT_TYPE_JSON);
272 break;
273 case QMetaType::QString:
274 default:
275 print(result.toString(), Http::CONTENT_TYPE_TXT);
276 break;
279 catch (const APIError &error) {
280 // re-throw as HTTPError
281 switch (error.type()) {
282 case APIErrorType::AccessDenied:
283 throw ForbiddenHTTPError(error.message());
284 case APIErrorType::BadData:
285 throw UnsupportedMediaTypeHTTPError(error.message());
286 case APIErrorType::BadParams:
287 throw BadRequestHTTPError(error.message());
288 case APIErrorType::Conflict:
289 throw ConflictHTTPError(error.message());
290 case APIErrorType::NotFound:
291 throw NotFoundHTTPError(error.message());
292 default:
293 Q_ASSERT(false);
298 void WebApplication::configure()
300 const auto *pref = Preferences::instance();
302 const bool isAltUIUsed = pref->isAltWebUiEnabled();
303 const QString rootFolder = Utils::Fs::expandPathAbs(
304 !isAltUIUsed ? WWW_FOLDER : pref->getWebUiRootFolder());
305 if ((isAltUIUsed != m_isAltUIUsed) || (rootFolder != m_rootFolder)) {
306 m_isAltUIUsed = isAltUIUsed;
307 m_rootFolder = rootFolder;
308 m_translatedFiles.clear();
309 if (!m_isAltUIUsed)
310 LogMsg(tr("Using built-in Web UI."));
311 else
312 LogMsg(tr("Using custom Web UI. Location: \"%1\".").arg(m_rootFolder));
315 const QString newLocale = pref->getLocale();
316 if (m_currentLocale != newLocale) {
317 m_currentLocale = newLocale;
318 m_translatedFiles.clear();
320 m_translationFileLoaded = m_translator.load(m_rootFolder + QLatin1String("/translations/webui_") + newLocale);
321 if (m_translationFileLoaded) {
322 LogMsg(tr("Web UI translation for selected locale (%1) has been successfully loaded.")
323 .arg(newLocale));
325 else {
326 LogMsg(tr("Couldn't load Web UI translation for selected locale (%1).").arg(newLocale), Log::WARNING);
330 m_isLocalAuthEnabled = pref->isWebUiLocalAuthEnabled();
331 m_isAuthSubnetWhitelistEnabled = pref->isWebUiAuthSubnetWhitelistEnabled();
332 m_authSubnetWhitelist = pref->getWebUiAuthSubnetWhitelist();
333 m_sessionTimeout = pref->getWebUISessionTimeout();
335 m_domainList = pref->getServerDomains().split(';', QString::SkipEmptyParts);
336 std::for_each(m_domainList.begin(), m_domainList.end(), [](QString &entry) { entry = entry.trimmed(); });
338 m_isClickjackingProtectionEnabled = pref->isWebUiClickjackingProtectionEnabled();
339 m_isCSRFProtectionEnabled = pref->isWebUiCSRFProtectionEnabled();
340 m_isHostHeaderValidationEnabled = pref->isWebUIHostHeaderValidationEnabled();
341 m_isHttpsEnabled = pref->isWebUiHttpsEnabled();
343 m_contentSecurityPolicy =
344 (m_isAltUIUsed
345 ? QLatin1String("")
346 : QLatin1String("default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; script-src 'self' 'unsafe-inline'; object-src 'none'; form-action 'self';"))
347 + (m_isClickjackingProtectionEnabled ? QLatin1String(" frame-ancestors 'self';") : QLatin1String(""))
348 + (m_isHttpsEnabled ? QLatin1String(" upgrade-insecure-requests;") : QLatin1String(""));
351 void WebApplication::registerAPIController(const QString &scope, APIController *controller)
353 Q_ASSERT(controller);
354 Q_ASSERT(!m_apiControllers.value(scope));
356 m_apiControllers[scope] = controller;
359 void WebApplication::declarePublicAPI(const QString &apiPath)
361 m_publicAPIs << apiPath;
364 void WebApplication::sendFile(const QString &path)
366 const QDateTime lastModified {QFileInfo(path).lastModified()};
368 // find translated file in cache
369 const auto it = m_translatedFiles.constFind(path);
370 if ((it != m_translatedFiles.constEnd()) && (lastModified <= it->lastModified)) {
371 print(it->data, it->mimeType);
372 header(Http::HEADER_CACHE_CONTROL, getCachingInterval(it->mimeType));
373 return;
376 QFile file {path};
377 if (!file.open(QIODevice::ReadOnly)) {
378 qDebug("File %s was not found!", qUtf8Printable(path));
379 throw NotFoundHTTPError();
382 if (file.size() > MAX_ALLOWED_FILESIZE) {
383 qWarning("%s: exceeded the maximum allowed file size!", qUtf8Printable(path));
384 throw InternalServerErrorHTTPError(tr("Exceeded the maximum allowed file size (%1)!")
385 .arg(Utils::Misc::friendlyUnit(MAX_ALLOWED_FILESIZE)));
388 QByteArray data {file.readAll()};
389 file.close();
391 const QMimeType mimeType {QMimeDatabase().mimeTypeForFileNameAndData(path, data)};
392 const bool isTranslatable {mimeType.inherits(QLatin1String("text/plain"))};
394 // Translate the file
395 if (isTranslatable) {
396 QString dataStr {data};
397 translateDocument(dataStr);
398 data = dataStr.toUtf8();
400 m_translatedFiles[path] = {data, mimeType.name(), lastModified}; // caching translated file
403 print(data, mimeType.name());
404 header(Http::HEADER_CACHE_CONTROL, getCachingInterval(mimeType.name()));
407 Http::Response WebApplication::processRequest(const Http::Request &request, const Http::Environment &env)
409 m_currentSession = nullptr;
410 m_request = request;
411 m_env = env;
412 m_params.clear();
414 if (m_request.method == Http::METHOD_GET) {
415 for (auto iter = m_request.query.cbegin(); iter != m_request.query.cend(); ++iter)
416 m_params[iter.key()] = QString::fromUtf8(iter.value());
418 else {
419 m_params = m_request.posts;
422 // clear response
423 clear();
425 try {
426 // block suspicious requests
427 if ((m_isCSRFProtectionEnabled && isCrossSiteRequest(m_request))
428 || (m_isHostHeaderValidationEnabled && !validateHostHeader(m_domainList))) {
429 throw UnauthorizedHTTPError();
432 sessionInitialize();
433 doProcessRequest();
435 catch (const HTTPError &error) {
436 status(error.statusCode(), error.statusText());
437 if (!error.message().isEmpty())
438 print(error.message(), Http::CONTENT_TYPE_TXT);
441 header(QLatin1String(Http::HEADER_X_XSS_PROTECTION), QLatin1String("1; mode=block"));
442 header(QLatin1String(Http::HEADER_X_CONTENT_TYPE_OPTIONS), QLatin1String("nosniff"));
444 if (m_isClickjackingProtectionEnabled)
445 header(QLatin1String(Http::HEADER_X_FRAME_OPTIONS), QLatin1String("SAMEORIGIN"));
447 if (!m_isAltUIUsed)
448 header(QLatin1String(Http::HEADER_REFERRER_POLICY), QLatin1String("same-origin"));
450 if (!m_contentSecurityPolicy.isEmpty())
451 header(QLatin1String(Http::HEADER_CONTENT_SECURITY_POLICY), m_contentSecurityPolicy);
453 return response();
456 QString WebApplication::clientId() const
458 return env().clientAddress.toString();
461 void WebApplication::sessionInitialize()
463 Q_ASSERT(!m_currentSession);
465 const QString sessionId {parseCookie(m_request.headers.value(QLatin1String("cookie"))).value(C_SID)};
467 // TODO: Additional session check
469 if (!sessionId.isEmpty()) {
470 m_currentSession = m_sessions.value(sessionId);
471 if (m_currentSession) {
472 if (m_currentSession->hasExpired(m_sessionTimeout)) {
473 // session is outdated - removing it
474 delete m_sessions.take(sessionId);
475 m_currentSession = nullptr;
477 else {
478 m_currentSession->updateTimestamp();
481 else {
482 qDebug() << Q_FUNC_INFO << "session does not exist!";
486 if (!m_currentSession && !isAuthNeeded())
487 sessionStart();
490 QString WebApplication::generateSid() const
492 QString sid;
494 do {
495 const quint32 tmp[] = {Utils::Random::rand(), Utils::Random::rand(), Utils::Random::rand()
496 , Utils::Random::rand(), Utils::Random::rand(), Utils::Random::rand()};
497 sid = QByteArray::fromRawData(reinterpret_cast<const char *>(tmp), sizeof(tmp)).toBase64();
499 while (m_sessions.contains(sid));
501 return sid;
504 bool WebApplication::isAuthNeeded()
506 if (!m_isLocalAuthEnabled && Utils::Net::isLoopbackAddress(m_env.clientAddress))
507 return false;
508 if (m_isAuthSubnetWhitelistEnabled && Utils::Net::isIPInRange(m_env.clientAddress, m_authSubnetWhitelist))
509 return false;
510 return true;
513 bool WebApplication::isPublicAPI(const QString &scope, const QString &action) const
515 return m_publicAPIs.contains(QString::fromLatin1("%1/%2").arg(scope, action));
518 void WebApplication::sessionStart()
520 Q_ASSERT(!m_currentSession);
522 // remove outdated sessions
523 Algorithm::removeIf(m_sessions, [this](const QString &, const WebSession *session)
525 if (session->hasExpired(m_sessionTimeout)) {
526 delete session;
527 return true;
530 return false;
533 m_currentSession = new WebSession(generateSid());
534 m_sessions[m_currentSession->id()] = m_currentSession;
536 QNetworkCookie cookie(C_SID, m_currentSession->id().toUtf8());
537 cookie.setHttpOnly(true);
538 cookie.setPath(QLatin1String("/"));
539 QByteArray cookieRawForm = cookie.toRawForm();
540 if (m_isCSRFProtectionEnabled)
541 cookieRawForm.append("; SameSite=Strict");
542 header(Http::HEADER_SET_COOKIE, cookieRawForm);
545 void WebApplication::sessionEnd()
547 Q_ASSERT(m_currentSession);
549 QNetworkCookie cookie(C_SID);
550 cookie.setPath(QLatin1String("/"));
551 cookie.setExpirationDate(QDateTime::currentDateTime().addDays(-1));
553 delete m_sessions.take(m_currentSession->id());
554 m_currentSession = nullptr;
556 header(Http::HEADER_SET_COOKIE, cookie.toRawForm());
559 bool WebApplication::isCrossSiteRequest(const Http::Request &request) const
561 // https://www.owasp.org/index.php/Cross-Site_Request_Forgery_(CSRF)_Prevention_Cheat_Sheet#Verifying_Same_Origin_with_Standard_Headers
563 const auto isSameOrigin = [](const QUrl &left, const QUrl &right) -> bool
565 // [rfc6454] 5. Comparing Origins
566 return ((left.port() == right.port())
567 // && (left.scheme() == right.scheme()) // not present in this context
568 && (left.host() == right.host()));
571 const QString targetOrigin = request.headers.value(Http::HEADER_X_FORWARDED_HOST, request.headers.value(Http::HEADER_HOST));
572 const QString originValue = request.headers.value(Http::HEADER_ORIGIN);
573 const QString refererValue = request.headers.value(Http::HEADER_REFERER);
575 if (originValue.isEmpty() && refererValue.isEmpty()) {
576 // owasp.org recommends to block this request, but doing so will inevitably lead Web API users to spoof headers
577 // so lets be permissive here
578 return false;
581 // sent with CORS requests, as well as with POST requests
582 if (!originValue.isEmpty()) {
583 const bool isInvalid = !isSameOrigin(urlFromHostHeader(targetOrigin), originValue);
584 if (isInvalid)
585 LogMsg(tr("WebUI: Origin header & Target origin mismatch! Source IP: '%1'. Origin header: '%2'. Target origin: '%3'")
586 .arg(m_env.clientAddress.toString(), originValue, targetOrigin)
587 , Log::WARNING);
588 return isInvalid;
591 if (!refererValue.isEmpty()) {
592 const bool isInvalid = !isSameOrigin(urlFromHostHeader(targetOrigin), refererValue);
593 if (isInvalid)
594 LogMsg(tr("WebUI: Referer header & Target origin mismatch! Source IP: '%1'. Referer header: '%2'. Target origin: '%3'")
595 .arg(m_env.clientAddress.toString(), refererValue, targetOrigin)
596 , Log::WARNING);
597 return isInvalid;
600 return true;
603 bool WebApplication::validateHostHeader(const QStringList &domains) const
605 const QUrl hostHeader = urlFromHostHeader(m_request.headers[Http::HEADER_HOST]);
606 const QString requestHost = hostHeader.host();
608 // (if present) try matching host header's port with local port
609 const int requestPort = hostHeader.port();
610 if ((requestPort != -1) && (m_env.localPort != requestPort)) {
611 LogMsg(tr("WebUI: Invalid Host header, port mismatch. Request source IP: '%1'. Server port: '%2'. Received Host header: '%3'")
612 .arg(m_env.clientAddress.toString()).arg(m_env.localPort)
613 .arg(m_request.headers[Http::HEADER_HOST])
614 , Log::WARNING);
615 return false;
618 // try matching host header with local address
619 const bool sameAddr = m_env.localAddress.isEqual(QHostAddress(requestHost));
621 if (sameAddr)
622 return true;
624 // try matching host header with domain list
625 for (const auto &domain : domains) {
626 QRegExp domainRegex(domain, Qt::CaseInsensitive, QRegExp::Wildcard);
627 if (requestHost.contains(domainRegex))
628 return true;
631 LogMsg(tr("WebUI: Invalid Host header. Request source IP: '%1'. Received Host header: '%2'")
632 .arg(m_env.clientAddress.toString(), m_request.headers[Http::HEADER_HOST])
633 , Log::WARNING);
634 return false;
637 // WebSession
639 WebSession::WebSession(const QString &sid)
640 : m_sid {sid}
642 updateTimestamp();
645 QString WebSession::id() const
647 return m_sid;
650 bool WebSession::hasExpired(const qint64 seconds) const
652 if (seconds <= 0)
653 return false;
654 return m_timer.hasExpired(seconds * 1000);
657 void WebSession::updateTimestamp()
659 m_timer.start();
662 QVariant WebSession::getData(const QString &id) const
664 return m_data.value(id);
667 void WebSession::setData(const QString &id, const QVariant &data)
669 m_data[id] = data;