mirror of
https://github.com/qbittorrent/qBittorrent.git
synced 2025-12-21 07:57:22 -06:00
The majority of the CSP is tuned for built-in WebUI, it may not be suitable for alternative UI. Also add QLatin1String to strings. This code path is called repeatedly, it is worth adding QLatin1String to squeeze out the last bit of performance.
811 lines
29 KiB
C++
811 lines
29 KiB
C++
/*
|
|
* Bittorrent Client using Qt and libtorrent.
|
|
* Copyright (C) 2014 Vladimir Golovnev <glassez@yandex.ru>
|
|
*
|
|
* This program is free software; you can redistribute it and/or
|
|
* modify it under the terms of the GNU General Public License
|
|
* as published by the Free Software Foundation; either version 2
|
|
* of the License, or (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU General Public License
|
|
* along with this program; if not, write to the Free Software
|
|
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
|
*
|
|
* In addition, as a special exception, the copyright holders give permission to
|
|
* link this program with the OpenSSL project's "OpenSSL" library (or with
|
|
* modified versions of it that use the same license as the "OpenSSL" library),
|
|
* and distribute the linked executables. You must obey the GNU General Public
|
|
* License in all respects for all of the code used other than "OpenSSL". If you
|
|
* modify file(s), you may extend this exception to your version of the file(s),
|
|
* but you are not obligated to do so. If you do not wish to do so, delete this
|
|
* exception statement from your version.
|
|
*/
|
|
|
|
#include "webapplication.h"
|
|
|
|
#include <algorithm>
|
|
#include <functional>
|
|
#include <queue>
|
|
#include <stdexcept>
|
|
#include <vector>
|
|
|
|
#include <QDateTime>
|
|
#include <QDebug>
|
|
#include <QFile>
|
|
#include <QFileInfo>
|
|
#include <QJsonDocument>
|
|
#include <QMimeDatabase>
|
|
#include <QMimeType>
|
|
#include <QRegExp>
|
|
#include <QUrl>
|
|
|
|
#include "base/global.h"
|
|
#include "base/http/httperror.h"
|
|
#include "base/iconprovider.h"
|
|
#include "base/logger.h"
|
|
#include "base/preferences.h"
|
|
#include "base/utils/bytearray.h"
|
|
#include "base/utils/fs.h"
|
|
#include "base/utils/misc.h"
|
|
#include "base/utils/random.h"
|
|
#include "base/utils/string.h"
|
|
#include "api/apierror.h"
|
|
#include "api/appcontroller.h"
|
|
#include "api/authcontroller.h"
|
|
#include "api/logcontroller.h"
|
|
#include "api/rsscontroller.h"
|
|
#include "api/searchcontroller.h"
|
|
#include "api/synccontroller.h"
|
|
#include "api/torrentscontroller.h"
|
|
#include "api/transfercontroller.h"
|
|
|
|
constexpr int MAX_ALLOWED_FILESIZE = 10 * 1024 * 1024;
|
|
|
|
const QString PATH_PREFIX_IMAGES {QStringLiteral("/images/")};
|
|
const QString WWW_FOLDER {QStringLiteral(":/www")};
|
|
const QString PUBLIC_FOLDER {QStringLiteral("/public")};
|
|
const QString PRIVATE_FOLDER {QStringLiteral("/private")};
|
|
|
|
namespace
|
|
{
|
|
QStringMap parseCookie(const QString &cookieStr)
|
|
{
|
|
// [rfc6265] 4.2.1. Syntax
|
|
QStringMap ret;
|
|
const QVector<QStringRef> cookies = cookieStr.splitRef(';', QString::SkipEmptyParts);
|
|
|
|
for (const auto &cookie : cookies) {
|
|
const int idx = cookie.indexOf('=');
|
|
if (idx < 0)
|
|
continue;
|
|
|
|
const QString name = cookie.left(idx).trimmed().toString();
|
|
const QString value = Utils::String::unquote(cookie.mid(idx + 1).trimmed()).toString();
|
|
ret.insert(name, value);
|
|
}
|
|
return ret;
|
|
}
|
|
|
|
inline QUrl urlFromHostHeader(const QString &hostHeader)
|
|
{
|
|
if (!hostHeader.contains(QLatin1String("://")))
|
|
return QUrl(QLatin1String("http://") + hostHeader);
|
|
return hostHeader;
|
|
}
|
|
|
|
QString getCachingInterval(QString contentType)
|
|
{
|
|
contentType = contentType.toLower();
|
|
|
|
if (contentType.startsWith(QLatin1String("image/")))
|
|
return QLatin1String("private, max-age=604800"); // 1 week
|
|
|
|
if ((contentType == Http::CONTENT_TYPE_CSS)
|
|
|| (contentType == Http::CONTENT_TYPE_JS)) {
|
|
// short interval in case of program update
|
|
return QLatin1String("private, max-age=43200"); // 12 hrs
|
|
}
|
|
|
|
return QLatin1String("no-store");
|
|
}
|
|
}
|
|
|
|
WebApplication::WebApplication(QObject *parent)
|
|
: QObject(parent)
|
|
{
|
|
registerAPIController(QLatin1String("app"), new AppController(this, this));
|
|
registerAPIController(QLatin1String("auth"), new AuthController(this, this));
|
|
registerAPIController(QLatin1String("log"), new LogController(this, this));
|
|
registerAPIController(QLatin1String("rss"), new RSSController(this, this));
|
|
registerAPIController(QLatin1String("search"), new SearchController(this, this));
|
|
registerAPIController(QLatin1String("sync"), new SyncController(this, this));
|
|
registerAPIController(QLatin1String("torrents"), new TorrentsController(this, this));
|
|
registerAPIController(QLatin1String("transfer"), new TransferController(this, this));
|
|
|
|
declarePublicAPI(QLatin1String("auth/login"));
|
|
|
|
configure();
|
|
connect(Preferences::instance(), &Preferences::changed, this, &WebApplication::configure);
|
|
}
|
|
|
|
WebApplication::~WebApplication()
|
|
{
|
|
// cleanup sessions data
|
|
qDeleteAll(m_sessions);
|
|
}
|
|
|
|
void WebApplication::sendWebUIFile()
|
|
{
|
|
const QStringList pathItems {request().path.split('/', QString::SkipEmptyParts)};
|
|
if (pathItems.contains(".") || pathItems.contains(".."))
|
|
throw InternalServerErrorHTTPError();
|
|
|
|
if (!m_isAltUIUsed) {
|
|
if (request().path.startsWith(PATH_PREFIX_IMAGES)) {
|
|
const QString imageFilename {request().path.mid(PATH_PREFIX_IMAGES.size())};
|
|
sendFile(QLatin1String(":/icons/") + imageFilename);
|
|
return;
|
|
}
|
|
}
|
|
|
|
const QString path {
|
|
(request().path != QLatin1String("/")
|
|
? request().path
|
|
: (session()
|
|
? QLatin1String("/index.html")
|
|
: QLatin1String("/login.html")))
|
|
};
|
|
|
|
QString localPath {
|
|
m_rootFolder
|
|
+ (session() ? PRIVATE_FOLDER : PUBLIC_FOLDER)
|
|
+ path
|
|
};
|
|
|
|
QFileInfo fileInfo {localPath};
|
|
|
|
if (!fileInfo.exists() && session()) {
|
|
// try to send public file if there is no private one
|
|
localPath = m_rootFolder + PUBLIC_FOLDER + path;
|
|
fileInfo.setFile(localPath);
|
|
}
|
|
|
|
if (m_isAltUIUsed) {
|
|
#ifdef Q_OS_UNIX
|
|
if (!Utils::Fs::isRegularFile(localPath)) {
|
|
status(500, "Internal Server Error");
|
|
print(tr("Unacceptable file type, only regular file is allowed."), Http::CONTENT_TYPE_TXT);
|
|
return;
|
|
}
|
|
#endif
|
|
|
|
while (fileInfo.filePath() != m_rootFolder) {
|
|
if (fileInfo.isSymLink())
|
|
throw InternalServerErrorHTTPError(tr("Symlinks inside alternative UI folder are forbidden."));
|
|
|
|
fileInfo.setFile(fileInfo.path());
|
|
}
|
|
}
|
|
|
|
sendFile(localPath);
|
|
}
|
|
|
|
void WebApplication::translateDocument(QString &data)
|
|
{
|
|
const QRegularExpression regex("QBT_TR\\((([^\\)]|\\)(?!QBT_TR))+)\\)QBT_TR\\[CONTEXT=([a-zA-Z_][a-zA-Z0-9_]*)\\]");
|
|
|
|
const bool isTranslationNeeded = !m_currentLocale.startsWith("en")
|
|
|| m_currentLocale.startsWith("en_AU") || m_currentLocale.startsWith("en_GB")
|
|
|| !m_translator.isEmpty();
|
|
|
|
int i = 0;
|
|
bool found = true;
|
|
while (i < data.size() && found) {
|
|
QRegularExpressionMatch regexMatch;
|
|
i = data.indexOf(regex, i, ®exMatch);
|
|
if (i >= 0) {
|
|
const QString sourceText = regexMatch.captured(1);
|
|
const QString context = regexMatch.captured(3);
|
|
|
|
QString translation = sourceText;
|
|
if (isTranslationNeeded) {
|
|
const QString loadedText = m_translator.translate(context.toUtf8().constData(), sourceText.toUtf8().constData(), nullptr, 1);
|
|
if (!loadedText.isEmpty())
|
|
translation = loadedText;
|
|
}
|
|
|
|
// Use HTML code for quotes to prevent issues with JS
|
|
translation.replace('\'', "'");
|
|
translation.replace('\"', """);
|
|
|
|
data.replace(i, regexMatch.capturedLength(), translation);
|
|
i += translation.length();
|
|
}
|
|
else {
|
|
found = false; // no more translatable strings
|
|
}
|
|
|
|
data.replace(QLatin1String("${LANG}"), m_currentLocale.left(2));
|
|
data.replace(QLatin1String("${VERSION}"), QBT_VERSION);
|
|
}
|
|
}
|
|
|
|
WebSession *WebApplication::session()
|
|
{
|
|
return m_currentSession;
|
|
}
|
|
|
|
const Http::Request &WebApplication::request() const
|
|
{
|
|
return m_request;
|
|
}
|
|
|
|
const Http::Environment &WebApplication::env() const
|
|
{
|
|
return m_env;
|
|
}
|
|
|
|
void WebApplication::doProcessRequest()
|
|
{
|
|
QString scope, action;
|
|
|
|
const auto findAPICall = [&]() -> bool
|
|
{
|
|
QRegularExpressionMatch match = m_apiPathPattern.match(request().path);
|
|
if (!match.hasMatch()) return false;
|
|
|
|
action = match.captured(QLatin1String("action"));
|
|
scope = match.captured(QLatin1String("scope"));
|
|
return true;
|
|
};
|
|
|
|
const auto findLegacyAPICall = [&]() -> bool
|
|
{
|
|
QRegularExpressionMatch match = m_apiLegacyPathPattern.match(request().path);
|
|
if (!match.hasMatch()) return false;
|
|
|
|
struct APICompatInfo
|
|
{
|
|
QString scope;
|
|
QString action;
|
|
std::function<void ()> convertFunc;
|
|
};
|
|
const QMap<QString, APICompatInfo> APICompatMapping {
|
|
{"sync/maindata", {"sync", "maindata", nullptr}},
|
|
{"sync/torrent_peers", {"sync", "torrentPeers", nullptr}},
|
|
|
|
{"login", {"auth", "login", nullptr}},
|
|
{"logout", {"auth", "logout", nullptr}},
|
|
|
|
{"command/shutdown", {"app", "shutdown", nullptr}},
|
|
{"query/preferences", {"app", "preferences", nullptr}},
|
|
{"command/setPreferences", {"app", "setPreferences", nullptr}},
|
|
{"command/getSavePath", {"app", "defaultSavePath", nullptr}},
|
|
|
|
{"query/getLog", {"log", "main", nullptr}},
|
|
{"query/getPeerLog", {"log", "peers", nullptr}},
|
|
|
|
{"query/torrents", {"torrents", "info", nullptr}},
|
|
{"query/propertiesGeneral", {"torrents", "properties", nullptr}},
|
|
{"query/propertiesTrackers", {"torrents", "trackers", nullptr}},
|
|
{"query/propertiesWebSeeds", {"torrents", "webseeds", nullptr}},
|
|
{"query/propertiesFiles", {"torrents", "files", nullptr}},
|
|
{"query/getPieceHashes", {"torrents", "pieceHashes", nullptr}},
|
|
{"query/getPieceStates", {"torrents", "pieceStates", nullptr}},
|
|
{"command/resume", {"torrents", "resume", [this]() { m_params["hashes"] = m_params.take("hash"); }}},
|
|
{"command/pause", {"torrents", "pause", [this]() { m_params["hashes"] = m_params.take("hash"); }}},
|
|
{"command/recheck", {"torrents", "recheck", [this]() { m_params["hashes"] = m_params.take("hash"); }}},
|
|
{"command/resumeAll", {"torrents", "resume", [this]() { m_params["hashes"] = "all"; }}},
|
|
{"command/pauseAll", {"torrents", "pause", [this]() { m_params["hashes"] = "all"; }}},
|
|
{"command/rename", {"torrents", "rename", nullptr}},
|
|
{"command/download", {"torrents", "add", nullptr}},
|
|
{"command/upload", {"torrents", "add", nullptr}},
|
|
{"command/delete", {"torrents", "delete", [this]() { m_params["deleteFiles"] = "false"; }}},
|
|
{"command/deletePerm", {"torrents", "delete", [this]() { m_params["deleteFiles"] = "true"; }}},
|
|
{"command/addTrackers", {"torrents", "addTrackers", nullptr}},
|
|
{"command/setFilePrio", {"torrents", "filePrio", nullptr}},
|
|
{"command/setCategory", {"torrents", "setCategory", nullptr}},
|
|
{"command/addCategory", {"torrents", "createCategory", nullptr}},
|
|
{"command/removeCategories", {"torrents", "removeCategories", nullptr}},
|
|
{"command/getTorrentsUpLimit", {"torrents", "uploadLimit", nullptr}},
|
|
{"command/getTorrentsDlLimit", {"torrents", "downloadLimit", nullptr}},
|
|
{"command/setTorrentsUpLimit", {"torrents", "setUploadLimit", nullptr}},
|
|
{"command/setTorrentsDlLimit", {"torrents", "setDownloadLimit", nullptr}},
|
|
{"command/increasePrio", {"torrents", "increasePrio", nullptr}},
|
|
{"command/decreasePrio", {"torrents", "decreasePrio", nullptr}},
|
|
{"command/topPrio", {"torrents", "topPrio", nullptr}},
|
|
{"command/bottomPrio", {"torrents", "bottomPrio", nullptr}},
|
|
{"command/setLocation", {"torrents", "setLocation", nullptr}},
|
|
{"command/setAutoTMM", {"torrents", "setAutoManagement", nullptr}},
|
|
{"command/setSuperSeeding", {"torrents", "setSuperSeeding", nullptr}},
|
|
{"command/setForceStart", {"torrents", "setForceStart", nullptr}},
|
|
{"command/toggleSequentialDownload", {"torrents", "toggleSequentialDownload", nullptr}},
|
|
{"command/toggleFirstLastPiecePrio", {"torrents", "toggleFirstLastPiecePrio", nullptr}},
|
|
|
|
{"query/transferInfo", {"transfer", "info", nullptr}},
|
|
{"command/alternativeSpeedLimitsEnabled", {"transfer", "speedLimitsMode", nullptr}},
|
|
{"command/toggleAlternativeSpeedLimits", {"transfer", "toggleSpeedLimitsMode", nullptr}},
|
|
{"command/getGlobalUpLimit", {"transfer", "uploadLimit", nullptr}},
|
|
{"command/getGlobalDlLimit", {"transfer", "downloadLimit", nullptr}},
|
|
{"command/setGlobalUpLimit", {"transfer", "setUploadLimit", nullptr}},
|
|
{"command/setGlobalDlLimit", {"transfer", "setDownloadLimit", nullptr}}
|
|
};
|
|
|
|
const QString legacyAction {match.captured(QLatin1String("action"))};
|
|
const APICompatInfo compatInfo = APICompatMapping.value(legacyAction);
|
|
|
|
scope = compatInfo.scope;
|
|
action = compatInfo.action;
|
|
if (compatInfo.convertFunc)
|
|
compatInfo.convertFunc();
|
|
|
|
const QString hash {match.captured(QLatin1String("hash"))};
|
|
if (!hash.isEmpty())
|
|
m_params[QLatin1String("hash")] = hash;
|
|
|
|
return true;
|
|
};
|
|
|
|
if (!findAPICall())
|
|
findLegacyAPICall();
|
|
|
|
APIController *controller = m_apiControllers.value(scope);
|
|
if (!controller) {
|
|
if (request().path == QLatin1String("/version/api")) {
|
|
print(QString::number(COMPAT_API_VERSION), Http::CONTENT_TYPE_TXT);
|
|
return;
|
|
}
|
|
|
|
if (request().path == QLatin1String("/version/api_min")) {
|
|
print(QString::number(COMPAT_API_VERSION_MIN), Http::CONTENT_TYPE_TXT);
|
|
return;
|
|
}
|
|
|
|
if (request().path == QLatin1String("/version/qbittorrent")) {
|
|
print(QString(QBT_VERSION), Http::CONTENT_TYPE_TXT);
|
|
return;
|
|
}
|
|
|
|
sendWebUIFile();
|
|
}
|
|
else {
|
|
if (!session() && !isPublicAPI(scope, action))
|
|
throw ForbiddenHTTPError();
|
|
|
|
DataMap data;
|
|
for (const Http::UploadedFile &torrent : request().files)
|
|
data[torrent.filename] = torrent.data;
|
|
|
|
try {
|
|
const QVariant result = controller->run(action, m_params, data);
|
|
switch (result.userType()) {
|
|
case QMetaType::QString:
|
|
print(result.toString(), Http::CONTENT_TYPE_TXT);
|
|
break;
|
|
case QMetaType::QJsonDocument:
|
|
print(result.toJsonDocument().toJson(QJsonDocument::Compact), Http::CONTENT_TYPE_JSON);
|
|
break;
|
|
default:
|
|
print(result.toString(), Http::CONTENT_TYPE_TXT);
|
|
break;
|
|
}
|
|
}
|
|
catch (const APIError &error) {
|
|
// re-throw as HTTPError
|
|
switch (error.type()) {
|
|
case APIErrorType::AccessDenied:
|
|
throw ForbiddenHTTPError(error.message());
|
|
case APIErrorType::BadData:
|
|
throw UnsupportedMediaTypeHTTPError(error.message());
|
|
case APIErrorType::BadParams:
|
|
throw BadRequestHTTPError(error.message());
|
|
case APIErrorType::Conflict:
|
|
throw ConflictHTTPError(error.message());
|
|
case APIErrorType::NotFound:
|
|
throw NotFoundHTTPError(error.message());
|
|
default:
|
|
Q_ASSERT(false);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
void WebApplication::configure()
|
|
{
|
|
const auto *pref = Preferences::instance();
|
|
|
|
const bool isAltUIUsed = pref->isAltWebUiEnabled();
|
|
const QString rootFolder = Utils::Fs::expandPathAbs(
|
|
!isAltUIUsed ? WWW_FOLDER : pref->getWebUiRootFolder());
|
|
if ((isAltUIUsed != m_isAltUIUsed) || (rootFolder != m_rootFolder)) {
|
|
m_isAltUIUsed = isAltUIUsed;
|
|
m_rootFolder = rootFolder;
|
|
m_translatedFiles.clear();
|
|
if (!m_isAltUIUsed)
|
|
LogMsg(tr("Using built-in Web UI."));
|
|
else
|
|
LogMsg(tr("Using custom Web UI. Location: \"%1\".").arg(m_rootFolder));
|
|
}
|
|
|
|
const QString newLocale = pref->getLocale();
|
|
if (m_currentLocale != newLocale) {
|
|
m_currentLocale = newLocale;
|
|
m_translatedFiles.clear();
|
|
if (m_translator.load(m_rootFolder + QLatin1String("/translations/webui_") + m_currentLocale)) {
|
|
LogMsg(tr("Web UI translation for selected locale (%1) is successfully loaded.")
|
|
.arg(m_currentLocale));
|
|
}
|
|
else {
|
|
LogMsg(tr("Couldn't load Web UI translation for selected locale (%1). Falling back to default (en).")
|
|
.arg(m_currentLocale), Log::WARNING);
|
|
}
|
|
}
|
|
|
|
m_isLocalAuthEnabled = pref->isWebUiLocalAuthEnabled();
|
|
m_isAuthSubnetWhitelistEnabled = pref->isWebUiAuthSubnetWhitelistEnabled();
|
|
m_authSubnetWhitelist = pref->getWebUiAuthSubnetWhitelist();
|
|
|
|
m_domainList = pref->getServerDomains().split(';', QString::SkipEmptyParts);
|
|
std::for_each(m_domainList.begin(), m_domainList.end(), [](QString &entry) { entry = entry.trimmed(); });
|
|
|
|
m_isClickjackingProtectionEnabled = pref->isWebUiClickjackingProtectionEnabled();
|
|
m_isCSRFProtectionEnabled = pref->isWebUiCSRFProtectionEnabled();
|
|
m_isHostHeaderValidationEnabled = pref->isWebUIHostHeaderValidationEnabled();
|
|
m_isHttpsEnabled = pref->isWebUiHttpsEnabled();
|
|
|
|
m_contentSecurityPolicy =
|
|
(m_isAltUIUsed
|
|
? QLatin1String("")
|
|
: QLatin1String("default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; script-src 'self' 'unsafe-inline'; object-src 'none'; form-action 'self';"))
|
|
+ (m_isClickjackingProtectionEnabled ? QLatin1String(" frame-ancestors 'self';") : QLatin1String(""))
|
|
+ (m_isHttpsEnabled ? QLatin1String(" upgrade-insecure-requests;") : QLatin1String(""));
|
|
}
|
|
|
|
void WebApplication::registerAPIController(const QString &scope, APIController *controller)
|
|
{
|
|
Q_ASSERT(controller);
|
|
Q_ASSERT(!m_apiControllers.value(scope));
|
|
|
|
m_apiControllers[scope] = controller;
|
|
}
|
|
|
|
void WebApplication::declarePublicAPI(const QString &apiPath)
|
|
{
|
|
m_publicAPIs << apiPath;
|
|
}
|
|
|
|
void WebApplication::sendFile(const QString &path)
|
|
{
|
|
const QDateTime lastModified {QFileInfo(path).lastModified()};
|
|
|
|
// find translated file in cache
|
|
auto it = m_translatedFiles.constFind(path);
|
|
if ((it != m_translatedFiles.constEnd()) && (lastModified <= (*it).lastModified)) {
|
|
const QString mimeName {QMimeDatabase().mimeTypeForFileNameAndData(path, (*it).data).name()};
|
|
print((*it).data, mimeName);
|
|
header(Http::HEADER_CACHE_CONTROL, getCachingInterval(mimeName));
|
|
return;
|
|
}
|
|
|
|
QFile file {path};
|
|
if (!file.open(QIODevice::ReadOnly)) {
|
|
qDebug("File %s was not found!", qUtf8Printable(path));
|
|
throw NotFoundHTTPError();
|
|
}
|
|
|
|
if (file.size() > MAX_ALLOWED_FILESIZE) {
|
|
qWarning("%s: exceeded the maximum allowed file size!", qUtf8Printable(path));
|
|
throw InternalServerErrorHTTPError(tr("Exceeded the maximum allowed file size (%1)!")
|
|
.arg(Utils::Misc::friendlyUnit(MAX_ALLOWED_FILESIZE)));
|
|
}
|
|
|
|
QByteArray data {file.readAll()};
|
|
file.close();
|
|
|
|
const QMimeType mimeType {QMimeDatabase().mimeTypeForFileNameAndData(path, data)};
|
|
const bool isTranslatable {mimeType.inherits(QLatin1String("text/plain"))};
|
|
|
|
// Translate the file
|
|
if (isTranslatable) {
|
|
QString dataStr {data};
|
|
translateDocument(dataStr);
|
|
data = dataStr.toUtf8();
|
|
|
|
m_translatedFiles[path] = {data, lastModified}; // caching translated file
|
|
}
|
|
|
|
print(data, mimeType.name());
|
|
header(Http::HEADER_CACHE_CONTROL, getCachingInterval(mimeType.name()));
|
|
}
|
|
|
|
Http::Response WebApplication::processRequest(const Http::Request &request, const Http::Environment &env)
|
|
{
|
|
m_currentSession = nullptr;
|
|
m_request = request;
|
|
m_env = env;
|
|
m_params.clear();
|
|
if (m_request.method == Http::METHOD_GET) {
|
|
// Parse GET parameters
|
|
using namespace Utils::ByteArray;
|
|
for (const QByteArray ¶m : asConst(splitToViews(m_request.query, "&"))) {
|
|
const int sepPos = param.indexOf('=');
|
|
if (sepPos <= 0) continue; // ignores params without name
|
|
|
|
const QString paramName {QString::fromUtf8(param.constData(), sepPos)};
|
|
const int valuePos = sepPos + 1;
|
|
const QString paramValue {
|
|
QString::fromUtf8(param.constData() + valuePos, param.size() - valuePos)};
|
|
m_params[paramName] = paramValue;
|
|
}
|
|
}
|
|
else {
|
|
m_params = m_request.posts;
|
|
}
|
|
|
|
// clear response
|
|
clear();
|
|
|
|
try {
|
|
// block suspicious requests
|
|
if ((m_isCSRFProtectionEnabled && isCrossSiteRequest(m_request))
|
|
|| (m_isHostHeaderValidationEnabled && !validateHostHeader(m_domainList))) {
|
|
throw UnauthorizedHTTPError();
|
|
}
|
|
|
|
sessionInitialize();
|
|
doProcessRequest();
|
|
}
|
|
catch (const HTTPError &error) {
|
|
status(error.statusCode(), error.statusText());
|
|
if (!error.message().isEmpty())
|
|
print(error.message(), Http::CONTENT_TYPE_TXT);
|
|
}
|
|
|
|
header(QLatin1String(Http::HEADER_X_XSS_PROTECTION), QLatin1String("1; mode=block"));
|
|
header(QLatin1String(Http::HEADER_X_CONTENT_TYPE_OPTIONS), QLatin1String("nosniff"));
|
|
|
|
if (m_isClickjackingProtectionEnabled)
|
|
header(QLatin1String(Http::HEADER_X_FRAME_OPTIONS), QLatin1String("SAMEORIGIN"));
|
|
|
|
if (!m_isAltUIUsed)
|
|
header(QLatin1String(Http::HEADER_REFERRER_POLICY), QLatin1String("same-origin"));
|
|
|
|
if (!m_contentSecurityPolicy.isEmpty())
|
|
header(QLatin1String(Http::HEADER_CONTENT_SECURITY_POLICY), m_contentSecurityPolicy);
|
|
|
|
return response();
|
|
}
|
|
|
|
QString WebApplication::clientId() const
|
|
{
|
|
return env().clientAddress.toString();
|
|
}
|
|
|
|
void WebApplication::sessionInitialize()
|
|
{
|
|
Q_ASSERT(!m_currentSession);
|
|
|
|
const QString sessionId {parseCookie(m_request.headers.value(QLatin1String("cookie"))).value(C_SID)};
|
|
|
|
// TODO: Additional session check
|
|
|
|
if (!sessionId.isEmpty()) {
|
|
m_currentSession = m_sessions.value(sessionId);
|
|
if (m_currentSession) {
|
|
const qint64 now = QDateTime::currentMSecsSinceEpoch() / 1000;
|
|
if ((now - m_currentSession->m_timestamp) > INACTIVE_TIME) {
|
|
// session is outdated - removing it
|
|
delete m_sessions.take(sessionId);
|
|
m_currentSession = nullptr;
|
|
}
|
|
else {
|
|
m_currentSession->updateTimestamp();
|
|
}
|
|
}
|
|
else {
|
|
qDebug() << Q_FUNC_INFO << "session does not exist!";
|
|
}
|
|
}
|
|
|
|
if (!m_currentSession && !isAuthNeeded())
|
|
sessionStart();
|
|
}
|
|
|
|
QString WebApplication::generateSid() const
|
|
{
|
|
QString sid;
|
|
|
|
do {
|
|
const size_t size = 6;
|
|
quint32 tmp[size];
|
|
|
|
for (size_t i = 0; i < size; ++i)
|
|
tmp[i] = Utils::Random::rand();
|
|
|
|
sid = QByteArray::fromRawData(reinterpret_cast<const char *>(tmp), sizeof(quint32) * size).toBase64();
|
|
}
|
|
while (m_sessions.contains(sid));
|
|
|
|
return sid;
|
|
}
|
|
|
|
bool WebApplication::isAuthNeeded()
|
|
{
|
|
if (!m_isLocalAuthEnabled && Utils::Net::isLoopbackAddress(m_env.clientAddress))
|
|
return false;
|
|
if (m_isAuthSubnetWhitelistEnabled && Utils::Net::isIPInRange(m_env.clientAddress, m_authSubnetWhitelist))
|
|
return false;
|
|
return true;
|
|
}
|
|
|
|
bool WebApplication::isPublicAPI(const QString &scope, const QString &action) const
|
|
{
|
|
return m_publicAPIs.contains(QString::fromLatin1("%1/%2").arg(scope, action));
|
|
}
|
|
|
|
void WebApplication::sessionStart()
|
|
{
|
|
Q_ASSERT(!m_currentSession);
|
|
|
|
// remove outdated sessions
|
|
const qint64 now = QDateTime::currentMSecsSinceEpoch() / 1000;
|
|
const QMap<QString, WebSession *> sessionsCopy {m_sessions};
|
|
for (const auto session : sessionsCopy) {
|
|
if ((now - session->timestamp()) > INACTIVE_TIME)
|
|
delete m_sessions.take(session->id());
|
|
}
|
|
|
|
m_currentSession = new WebSession(generateSid());
|
|
m_sessions[m_currentSession->id()] = m_currentSession;
|
|
|
|
QNetworkCookie cookie(C_SID, m_currentSession->id().toUtf8());
|
|
cookie.setHttpOnly(true);
|
|
cookie.setPath(QLatin1String("/"));
|
|
QByteArray cookieRawForm = cookie.toRawForm();
|
|
if (m_isCSRFProtectionEnabled)
|
|
cookieRawForm.append("; SameSite=Strict");
|
|
header(Http::HEADER_SET_COOKIE, cookieRawForm);
|
|
}
|
|
|
|
void WebApplication::sessionEnd()
|
|
{
|
|
Q_ASSERT(m_currentSession);
|
|
|
|
QNetworkCookie cookie(C_SID);
|
|
cookie.setPath(QLatin1String("/"));
|
|
cookie.setExpirationDate(QDateTime::currentDateTime().addDays(-1));
|
|
|
|
delete m_sessions.take(m_currentSession->id());
|
|
m_currentSession = nullptr;
|
|
|
|
header(Http::HEADER_SET_COOKIE, cookie.toRawForm());
|
|
}
|
|
|
|
bool WebApplication::isCrossSiteRequest(const Http::Request &request) const
|
|
{
|
|
// https://www.owasp.org/index.php/Cross-Site_Request_Forgery_(CSRF)_Prevention_Cheat_Sheet#Verifying_Same_Origin_with_Standard_Headers
|
|
|
|
const auto isSameOrigin = [](const QUrl &left, const QUrl &right) -> bool
|
|
{
|
|
// [rfc6454] 5. Comparing Origins
|
|
return ((left.port() == right.port())
|
|
// && (left.scheme() == right.scheme()) // not present in this context
|
|
&& (left.host() == right.host()));
|
|
};
|
|
|
|
const QString targetOrigin = request.headers.value(Http::HEADER_X_FORWARDED_HOST, request.headers.value(Http::HEADER_HOST));
|
|
const QString originValue = request.headers.value(Http::HEADER_ORIGIN);
|
|
const QString refererValue = request.headers.value(Http::HEADER_REFERER);
|
|
|
|
if (originValue.isEmpty() && refererValue.isEmpty()) {
|
|
// owasp.org recommends to block this request, but doing so will inevitably lead Web API users to spoof headers
|
|
// so lets be permissive here
|
|
return false;
|
|
}
|
|
|
|
// sent with CORS requests, as well as with POST requests
|
|
if (!originValue.isEmpty()) {
|
|
const bool isInvalid = !isSameOrigin(urlFromHostHeader(targetOrigin), originValue);
|
|
if (isInvalid)
|
|
LogMsg(tr("WebUI: Origin header & Target origin mismatch! Source IP: '%1'. Origin header: '%2'. Target origin: '%3'")
|
|
.arg(m_env.clientAddress.toString(), originValue, targetOrigin)
|
|
, Log::WARNING);
|
|
return isInvalid;
|
|
}
|
|
|
|
if (!refererValue.isEmpty()) {
|
|
const bool isInvalid = !isSameOrigin(urlFromHostHeader(targetOrigin), refererValue);
|
|
if (isInvalid)
|
|
LogMsg(tr("WebUI: Referer header & Target origin mismatch! Source IP: '%1'. Referer header: '%2'. Target origin: '%3'")
|
|
.arg(m_env.clientAddress.toString(), refererValue, targetOrigin)
|
|
, Log::WARNING);
|
|
return isInvalid;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
bool WebApplication::validateHostHeader(const QStringList &domains) const
|
|
{
|
|
const QUrl hostHeader = urlFromHostHeader(m_request.headers[Http::HEADER_HOST]);
|
|
const QString requestHost = hostHeader.host();
|
|
|
|
// (if present) try matching host header's port with local port
|
|
const int requestPort = hostHeader.port();
|
|
if ((requestPort != -1) && (m_env.localPort != requestPort)) {
|
|
LogMsg(tr("WebUI: Invalid Host header, port mismatch. Request source IP: '%1'. Server port: '%2'. Received Host header: '%3'")
|
|
.arg(m_env.clientAddress.toString()).arg(m_env.localPort)
|
|
.arg(m_request.headers[Http::HEADER_HOST])
|
|
, Log::WARNING);
|
|
return false;
|
|
}
|
|
|
|
// try matching host header with local address
|
|
#if (QT_VERSION >= QT_VERSION_CHECK(5, 8, 0))
|
|
const bool sameAddr = m_env.localAddress.isEqual(QHostAddress(requestHost));
|
|
#else
|
|
const auto equal = [](const Q_IPV6ADDR &l, const Q_IPV6ADDR &r) -> bool
|
|
{
|
|
for (int i = 0; i < 16; ++i) {
|
|
if (l[i] != r[i])
|
|
return false;
|
|
}
|
|
return true;
|
|
};
|
|
const bool sameAddr = equal(m_env.localAddress.toIPv6Address(), QHostAddress(requestHost).toIPv6Address());
|
|
#endif
|
|
|
|
if (sameAddr)
|
|
return true;
|
|
|
|
// try matching host header with domain list
|
|
for (const auto &domain : domains) {
|
|
QRegExp domainRegex(domain, Qt::CaseInsensitive, QRegExp::Wildcard);
|
|
if (requestHost.contains(domainRegex))
|
|
return true;
|
|
}
|
|
|
|
LogMsg(tr("WebUI: Invalid Host header. Request source IP: '%1'. Received Host header: '%2'")
|
|
.arg(m_env.clientAddress.toString(), m_request.headers[Http::HEADER_HOST])
|
|
, Log::WARNING);
|
|
return false;
|
|
}
|
|
|
|
// WebSession
|
|
|
|
WebSession::WebSession(const QString &sid)
|
|
: m_sid {sid}
|
|
{
|
|
updateTimestamp();
|
|
}
|
|
|
|
QString WebSession::id() const
|
|
{
|
|
return m_sid;
|
|
}
|
|
|
|
qint64 WebSession::timestamp() const
|
|
{
|
|
return m_timestamp;
|
|
}
|
|
|
|
QVariant WebSession::getData(const QString &id) const
|
|
{
|
|
return m_data.value(id);
|
|
}
|
|
|
|
void WebSession::setData(const QString &id, const QVariant &data)
|
|
{
|
|
m_data[id] = data;
|
|
}
|
|
|
|
void WebSession::updateTimestamp()
|
|
{
|
|
m_timestamp = QDateTime::currentMSecsSinceEpoch() / 1000;
|
|
}
|