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"
39 #include <QJsonDocument>
40 #include <QMetaObject>
41 #include <QMimeDatabase>
43 #include <QNetworkCookie>
44 #include <QRegularExpression>
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
;
85 QStringMap
parseCookie(const QStringView cookieStr
)
87 // [rfc6265] 4.2.1. Syntax
89 const QList
<QStringView
> cookies
= cookieStr
.split(u
';', Qt::SkipEmptyParts
);
91 for (const auto &cookie
: cookies
)
93 const int idx
= cookie
.indexOf(u
'=');
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
);
104 QUrl
urlFromHostHeader(const QString
&hostHeader
)
106 if (!hostHeader
.contains(u
"://"))
107 return {u
"http://"_s
+ 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))
152 const QRegularExpression invalidNameRegex
{u
"[^a-zA-Z0-9_\\-]"_s
};
153 if (invalidNameRegex
.match(cookieName
).hasMatch())
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
);
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
"/")
218 Path localPath
= m_rootFolder
219 / Path(session() ? PRIVATE_FOLDER
: PUBLIC_FOLDER
)
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
);
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());
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
);
253 while (i
< data
.size() && found
)
255 QRegularExpressionMatch regexMatch
;
256 i
= data
.indexOf(regex
, i
, ®exMatch
);
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())
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
"""_s
);
275 data
.replace(i
, regexMatch
.capturedLength(), translation
);
276 i
+= translation
.length();
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
298 const Http::Environment
&WebApplication::env() const
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())
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();
330 APIController
*controller
= nullptr;
332 controller
= session()->getAPIController(scope
);
335 if (scope
== u
"auth")
336 controller
= m_authController
;
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();
351 if (*allowedMethodIter
!= m_request
.method
)
352 throw MethodNotAllowedHTTPError();
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
);
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
)});
377 case QMetaType::QString
:
379 print(result
.data
.toString(), Http::CONTENT_TYPE_TXT
);
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());
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();
417 LogMsg(tr("Using built-in WebUI."));
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.")
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
});
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
=
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
':');
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
);
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
);
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
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
)});
551 const auto readResult
= Utils::IO::readFile(path
, MAX_ALLOWED_FILESIZE
);
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
);
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;
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());
613 m_params
= m_request
.posts
;
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();
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
);
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;
672 m_currentSession
->updateTimestamp();
677 qDebug() << Q_FUNC_INFO
<< "session does not exist!";
681 if (!m_currentSession
&& !isAuthNeeded())
685 QString
WebApplication::generateSid() const
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
));
701 bool WebApplication::isAuthNeeded()
703 if (!m_isLocalAuthEnabled
&& m_clientAddress
.isLoopback())
705 if (m_isAuthSubnetWhitelistEnabled
&& Utils::Net::isIPInSubnets(m_clientAddress
, m_authSubnetWhitelist
))
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
))
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)
783 if (m_isHttpsEnabled
)
786 // client is on localhost
787 if (env().clientAddress
.isLoopback())
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
816 // sent with CORS requests, as well as with POST requests
817 if (!originValue
.isEmpty())
819 const bool isInvalid
= !isSameOrigin(urlFromHostHeader(targetOrigin
), originValue
);
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
)
829 if (!refererValue
.isEmpty())
831 const bool isInvalid
= !isSameOrigin(urlFromHostHeader(targetOrigin
), refererValue
);
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
)
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
])
860 // try matching host header with local address
861 const bool sameAddr
= m_env
.localAddress
.isEqual(QHostAddress(requestHost
));
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
))
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
])
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
;
916 WebSession::WebSession(const QString
&sid
, IApplication
*app
)
917 : ApplicationComponent(app
)
923 QString
WebSession::id() const
928 bool WebSession::hasExpired(const qint64 seconds
) const
932 return m_timer
.hasExpired(seconds
* 1000);
935 void WebSession::updateTimestamp()
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
);