Rename isSeed to isFinished to correctly represent its meaning
[qBittorrent.git] / src / webui / webapplication.cpp
blobe96a469bbb9415a1009e3bef11b808aa3af3cf8e
1 /*
2 * Bittorrent Client using Qt and libtorrent.
3 * Copyright (C) 2014, 2022 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 <QDir>
36 #include <QFile>
37 #include <QFileInfo>
38 #include <QJsonDocument>
39 #include <QMimeDatabase>
40 #include <QMimeType>
41 #include <QNetworkCookie>
42 #include <QRegularExpression>
43 #include <QUrl>
45 #include "base/algorithm.h"
46 #include "base/http/httperror.h"
47 #include "base/logger.h"
48 #include "base/preferences.h"
49 #include "base/types.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 const int MAX_ALLOWED_FILESIZE = 10 * 1024 * 1024;
65 const QString DEFAULT_SESSION_COOKIE_NAME = u"SID"_qs;
67 const QString WWW_FOLDER = u":/www"_qs;
68 const QString PUBLIC_FOLDER = u"/public"_qs;
69 const QString PRIVATE_FOLDER = u"/private"_qs;
71 namespace
73 QStringMap parseCookie(const QStringView cookieStr)
75 // [rfc6265] 4.2.1. Syntax
76 QStringMap ret;
77 const QList<QStringView> cookies = cookieStr.split(u';', Qt::SkipEmptyParts);
79 for (const auto &cookie : cookies)
81 const int idx = cookie.indexOf(u'=');
82 if (idx < 0)
83 continue;
85 const QString name = cookie.left(idx).trimmed().toString();
86 const QString value = Utils::String::unquote(cookie.mid(idx + 1).trimmed()).toString();
87 ret.insert(name, value);
89 return ret;
92 QUrl urlFromHostHeader(const QString &hostHeader)
94 if (!hostHeader.contains(u"://"))
95 return {u"http://"_qs + hostHeader};
96 return hostHeader;
99 QString getCachingInterval(QString contentType)
101 contentType = contentType.toLower();
103 if (contentType.startsWith(u"image/"))
104 return u"private, max-age=604800"_qs; // 1 week
106 if ((contentType == Http::CONTENT_TYPE_CSS)
107 || (contentType == Http::CONTENT_TYPE_JS))
109 // short interval in case of program update
110 return u"private, max-age=43200"_qs; // 12 hrs
113 return u"no-store"_qs;
116 QString createLanguagesOptionsHtml()
118 // List language files
119 const QDir langDir {u":/www/translations"_qs};
120 const QStringList langFiles = langDir.entryList(QStringList(u"webui_*.qm"_qs), QDir::Files);
121 QStringList languages;
122 for (const QString &langFile : langFiles)
124 const QString localeStr = langFile.section(u"_"_qs, 1, -1).section(u"."_qs, 0, 0); // remove "webui_" and ".qm"
125 languages << u"<option value=\"%1\">%2</option>"_qs.arg(localeStr, Utils::Misc::languageToLocalizedString(localeStr));
126 qDebug() << "Supported locale:" << localeStr;
129 return languages.join(u'\n');
132 bool isValidCookieName(const QString &cookieName)
134 if (cookieName.isEmpty() || (cookieName.size() > 128))
135 return false;
137 const QRegularExpression invalidNameRegex {u"[^a-zA-Z0-9_\\-]"_qs};
138 if (invalidNameRegex.match(cookieName).hasMatch())
139 return false;
141 return true;
145 WebApplication::WebApplication(IApplication *app, QObject *parent)
146 : QObject(parent)
147 , ApplicationComponent(app)
148 , m_cacheID {QString::number(Utils::Random::rand(), 36)}
149 , m_authController {new AuthController(this, app, this)}
151 declarePublicAPI(u"auth/login"_qs);
153 configure();
154 connect(Preferences::instance(), &Preferences::changed, this, &WebApplication::configure);
156 m_sessionCookieName = Preferences::instance()->getWebAPISessionCookieName();
157 if (!isValidCookieName(m_sessionCookieName))
159 if (!m_sessionCookieName.isEmpty())
161 LogMsg(tr("Unacceptable session cookie name is specified: '%1'. Default one is used.")
162 .arg(m_sessionCookieName), Log::WARNING);
164 m_sessionCookieName = DEFAULT_SESSION_COOKIE_NAME;
168 WebApplication::~WebApplication()
170 // cleanup sessions data
171 qDeleteAll(m_sessions);
174 void WebApplication::sendWebUIFile()
176 const QStringList pathItems {request().path.split(u'/', Qt::SkipEmptyParts)};
177 if (pathItems.contains(u".") || pathItems.contains(u".."))
178 throw InternalServerErrorHTTPError();
180 const QString path = (request().path != u"/")
181 ? request().path
182 : u"/index.html"_qs;
184 Path localPath = m_rootFolder
185 / Path(session() ? PRIVATE_FOLDER : PUBLIC_FOLDER)
186 / Path(path);
187 if (!localPath.exists() && session())
189 // try to send public file if there is no private one
190 localPath = m_rootFolder / Path(PUBLIC_FOLDER) / Path(path);
193 if (m_isAltUIUsed)
195 if (!Utils::Fs::isRegularFile(localPath))
196 throw InternalServerErrorHTTPError(tr("Unacceptable file type, only regular file is allowed."));
198 const QString rootFolder = m_rootFolder.data();
200 QFileInfo fileInfo {localPath.parentPath().data()};
201 while (fileInfo.path() != rootFolder)
203 if (fileInfo.isSymLink())
204 throw InternalServerErrorHTTPError(tr("Symlinks inside alternative UI folder are forbidden."));
206 fileInfo.setFile(fileInfo.path());
210 sendFile(localPath);
213 void WebApplication::translateDocument(QString &data) const
215 const QRegularExpression regex(u"QBT_TR\\((([^\\)]|\\)(?!QBT_TR))+)\\)QBT_TR\\[CONTEXT=([a-zA-Z_][a-zA-Z0-9_]*)\\]"_qs);
217 int i = 0;
218 bool found = true;
219 while (i < data.size() && found)
221 QRegularExpressionMatch regexMatch;
222 i = data.indexOf(regex, i, &regexMatch);
223 if (i >= 0)
225 const QString sourceText = regexMatch.captured(1);
226 const QString context = regexMatch.captured(3);
228 const QString loadedText = m_translationFileLoaded
229 ? m_translator.translate(context.toUtf8().constData(), sourceText.toUtf8().constData())
230 : QString();
231 // `loadedText` is empty when translation is not provided
232 // it should fallback to `sourceText`
233 QString translation = loadedText.isEmpty() ? sourceText : loadedText;
235 // Use HTML code for quotes to prevent issues with JS
236 translation.replace(u'\'', u"&#39;"_qs);
237 translation.replace(u'\"', u"&#34;"_qs);
239 data.replace(i, regexMatch.capturedLength(), translation);
240 i += translation.length();
242 else
244 found = false; // no more translatable strings
247 data.replace(u"${LANG}"_qs, m_currentLocale.left(2));
248 data.replace(u"${CACHEID}"_qs, m_cacheID);
252 WebSession *WebApplication::session()
254 return m_currentSession;
257 const Http::Request &WebApplication::request() const
259 return m_request;
262 const Http::Environment &WebApplication::env() const
264 return m_env;
267 void WebApplication::doProcessRequest()
269 const QRegularExpressionMatch match = m_apiPathPattern.match(request().path);
270 if (!match.hasMatch())
272 sendWebUIFile();
273 return;
276 const QString action = match.captured(u"action"_qs);
277 const QString scope = match.captured(u"scope"_qs);
279 // Check public/private scope
280 if (!session() && !isPublicAPI(scope, action))
281 throw ForbiddenHTTPError();
283 // Find matching API
284 APIController *controller = nullptr;
285 if (session())
286 controller = session()->getAPIController(scope);
287 if (!controller)
289 if (scope == u"auth")
290 controller = m_authController;
291 else
292 throw NotFoundHTTPError();
295 // Filter HTTP methods
296 const auto allowedMethodIter = m_allowedMethod.find({scope, action});
297 if (allowedMethodIter == m_allowedMethod.end())
299 // by default allow both GET, POST methods
300 if ((m_request.method != Http::METHOD_GET) && (m_request.method != Http::METHOD_POST))
301 throw MethodNotAllowedHTTPError();
303 else
305 if (*allowedMethodIter != m_request.method)
306 throw MethodNotAllowedHTTPError();
309 DataMap data;
310 for (const Http::UploadedFile &torrent : request().files)
311 data[torrent.filename] = torrent.data;
315 const QVariant result = controller->run(action, m_params, data);
316 switch (result.userType())
318 case QMetaType::QJsonDocument:
319 print(result.toJsonDocument().toJson(QJsonDocument::Compact), Http::CONTENT_TYPE_JSON);
320 break;
321 case QMetaType::QByteArray:
322 print(result.toByteArray(), Http::CONTENT_TYPE_TXT);
323 break;
324 case QMetaType::QString:
325 default:
326 print(result.toString(), Http::CONTENT_TYPE_TXT);
327 break;
330 catch (const APIError &error)
332 // re-throw as HTTPError
333 switch (error.type())
335 case APIErrorType::AccessDenied:
336 throw ForbiddenHTTPError(error.message());
337 case APIErrorType::BadData:
338 throw UnsupportedMediaTypeHTTPError(error.message());
339 case APIErrorType::BadParams:
340 throw BadRequestHTTPError(error.message());
341 case APIErrorType::Conflict:
342 throw ConflictHTTPError(error.message());
343 case APIErrorType::NotFound:
344 throw NotFoundHTTPError(error.message());
345 default:
346 Q_ASSERT(false);
351 void WebApplication::configure()
353 const auto *pref = Preferences::instance();
355 const bool isAltUIUsed = pref->isAltWebUiEnabled();
356 const Path rootFolder = (!isAltUIUsed ? Path(WWW_FOLDER) : pref->getWebUiRootFolder());
357 if ((isAltUIUsed != m_isAltUIUsed) || (rootFolder != m_rootFolder))
359 m_isAltUIUsed = isAltUIUsed;
360 m_rootFolder = rootFolder;
361 m_translatedFiles.clear();
362 if (!m_isAltUIUsed)
363 LogMsg(tr("Using built-in Web UI."));
364 else
365 LogMsg(tr("Using custom Web UI. Location: \"%1\".").arg(m_rootFolder.toString()));
368 const QString newLocale = pref->getLocale();
369 if (m_currentLocale != newLocale)
371 m_currentLocale = newLocale;
372 m_translatedFiles.clear();
374 m_translationFileLoaded = m_translator.load((m_rootFolder / Path(u"translations/webui_"_qs) + newLocale).data());
375 if (m_translationFileLoaded)
377 LogMsg(tr("Web UI translation for selected locale (%1) has been successfully loaded.")
378 .arg(newLocale));
380 else
382 LogMsg(tr("Couldn't load Web UI translation for selected locale (%1).").arg(newLocale), Log::WARNING);
386 m_isLocalAuthEnabled = pref->isWebUiLocalAuthEnabled();
387 m_isAuthSubnetWhitelistEnabled = pref->isWebUiAuthSubnetWhitelistEnabled();
388 m_authSubnetWhitelist = pref->getWebUiAuthSubnetWhitelist();
389 m_sessionTimeout = pref->getWebUISessionTimeout();
391 m_domainList = pref->getServerDomains().split(u';', Qt::SkipEmptyParts);
392 std::for_each(m_domainList.begin(), m_domainList.end(), [](QString &entry) { entry = entry.trimmed(); });
394 m_isCSRFProtectionEnabled = pref->isWebUiCSRFProtectionEnabled();
395 m_isSecureCookieEnabled = pref->isWebUiSecureCookieEnabled();
396 m_isHostHeaderValidationEnabled = pref->isWebUIHostHeaderValidationEnabled();
397 m_isHttpsEnabled = pref->isWebUiHttpsEnabled();
399 m_prebuiltHeaders.clear();
400 m_prebuiltHeaders.push_back({Http::HEADER_X_XSS_PROTECTION, u"1; mode=block"_qs});
401 m_prebuiltHeaders.push_back({Http::HEADER_X_CONTENT_TYPE_OPTIONS, u"nosniff"_qs});
403 if (!m_isAltUIUsed)
404 m_prebuiltHeaders.push_back({Http::HEADER_REFERRER_POLICY, u"same-origin"_qs});
406 const bool isClickjackingProtectionEnabled = pref->isWebUiClickjackingProtectionEnabled();
407 if (isClickjackingProtectionEnabled)
408 m_prebuiltHeaders.push_back({Http::HEADER_X_FRAME_OPTIONS, u"SAMEORIGIN"_qs});
410 const QString contentSecurityPolicy =
411 (m_isAltUIUsed
412 ? QString()
413 : u"default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; script-src 'self' 'unsafe-inline'; object-src 'none'; form-action 'self';"_qs)
414 + (isClickjackingProtectionEnabled ? u" frame-ancestors 'self';"_qs : QString())
415 + (m_isHttpsEnabled ? u" upgrade-insecure-requests;"_qs : QString());
416 if (!contentSecurityPolicy.isEmpty())
417 m_prebuiltHeaders.push_back({Http::HEADER_CONTENT_SECURITY_POLICY, contentSecurityPolicy});
419 if (pref->isWebUICustomHTTPHeadersEnabled())
421 const QString customHeaders = pref->getWebUICustomHTTPHeaders();
422 const QList<QStringView> customHeaderLines = QStringView(customHeaders).trimmed().split(u'\n', Qt::SkipEmptyParts);
424 for (const QStringView line : customHeaderLines)
426 const int idx = line.indexOf(u':');
427 if (idx < 0)
429 // require separator `:` to be present even if `value` field can be empty
430 LogMsg(tr("Missing ':' separator in WebUI custom HTTP header: \"%1\"").arg(line.toString()), Log::WARNING);
431 continue;
434 const QString header = line.left(idx).trimmed().toString();
435 const QString value = line.mid(idx + 1).trimmed().toString();
436 m_prebuiltHeaders.push_back({header, value});
440 m_isReverseProxySupportEnabled = pref->isWebUIReverseProxySupportEnabled();
441 if (m_isReverseProxySupportEnabled)
443 const QStringList proxyList = pref->getWebUITrustedReverseProxiesList().split(u';', Qt::SkipEmptyParts);
445 m_trustedReverseProxyList.clear();
446 m_trustedReverseProxyList.reserve(proxyList.size());
448 for (QString proxy : proxyList)
450 if (!proxy.contains(u'/'))
452 const QAbstractSocket::NetworkLayerProtocol protocol = QHostAddress(proxy).protocol();
453 if (protocol == QAbstractSocket::IPv4Protocol)
455 proxy.append(u"/32");
457 else if (protocol == QAbstractSocket::IPv6Protocol)
459 proxy.append(u"/128");
463 const std::optional<Utils::Net::Subnet> subnet = Utils::Net::parseSubnet(proxy);
464 if (subnet)
465 m_trustedReverseProxyList.push_back(subnet.value());
468 if (m_trustedReverseProxyList.isEmpty())
469 m_isReverseProxySupportEnabled = false;
473 void WebApplication::declarePublicAPI(const QString &apiPath)
475 m_publicAPIs << apiPath;
478 void WebApplication::sendFile(const Path &path)
480 const QDateTime lastModified = Utils::Fs::lastModified(path);
482 // find translated file in cache
483 if (!m_isAltUIUsed)
485 if (const auto it = m_translatedFiles.constFind(path);
486 (it != m_translatedFiles.constEnd()) && (lastModified <= it->lastModified))
488 print(it->data, it->mimeType);
489 setHeader({Http::HEADER_CACHE_CONTROL, getCachingInterval(it->mimeType)});
490 return;
494 QFile file {path.data()};
495 if (!file.open(QIODevice::ReadOnly))
497 qDebug("File %s was not found!", qUtf8Printable(path.toString()));
498 throw NotFoundHTTPError();
501 if (file.size() > MAX_ALLOWED_FILESIZE)
503 qWarning("%s: exceeded the maximum allowed file size!", qUtf8Printable(path.toString()));
504 throw InternalServerErrorHTTPError(tr("Exceeded the maximum allowed file size (%1)!")
505 .arg(Utils::Misc::friendlyUnit(MAX_ALLOWED_FILESIZE)));
508 QByteArray data {file.readAll()};
509 file.close();
511 const QMimeType mimeType = QMimeDatabase().mimeTypeForFileNameAndData(path.data(), data);
512 const bool isTranslatable = !m_isAltUIUsed && mimeType.inherits(u"text/plain"_qs);
514 if (isTranslatable)
516 auto dataStr = QString::fromUtf8(data);
517 // Translate the file
518 translateDocument(dataStr);
520 // Add the language options
521 if (path == (m_rootFolder / Path(PRIVATE_FOLDER) / Path(u"views/preferences.html"_qs)))
522 dataStr.replace(u"${LANGUAGE_OPTIONS}"_qs, createLanguagesOptionsHtml());
524 data = dataStr.toUtf8();
525 m_translatedFiles[path] = {data, mimeType.name(), lastModified}; // caching translated file
528 print(data, mimeType.name());
529 setHeader({Http::HEADER_CACHE_CONTROL, getCachingInterval(mimeType.name())});
532 Http::Response WebApplication::processRequest(const Http::Request &request, const Http::Environment &env)
534 m_currentSession = nullptr;
535 m_request = request;
536 m_env = env;
537 m_params.clear();
539 if (m_request.method == Http::METHOD_GET)
541 for (auto iter = m_request.query.cbegin(); iter != m_request.query.cend(); ++iter)
542 m_params[iter.key()] = QString::fromUtf8(iter.value());
544 else
546 m_params = m_request.posts;
549 // clear response
550 clear();
554 // block suspicious requests
555 if ((m_isCSRFProtectionEnabled && isCrossSiteRequest(m_request))
556 || (m_isHostHeaderValidationEnabled && !validateHostHeader(m_domainList)))
558 throw UnauthorizedHTTPError();
561 // reverse proxy resolve client address
562 m_clientAddress = resolveClientAddress();
564 sessionInitialize();
565 doProcessRequest();
567 catch (const HTTPError &error)
569 status(error.statusCode(), error.statusText());
570 print((!error.message().isEmpty() ? error.message() : error.statusText()), Http::CONTENT_TYPE_TXT);
573 for (const Http::Header &prebuiltHeader : asConst(m_prebuiltHeaders))
574 setHeader(prebuiltHeader);
576 return response();
579 QString WebApplication::clientId() const
581 return m_clientAddress.toString();
584 void WebApplication::sessionInitialize()
586 Q_ASSERT(!m_currentSession);
588 const QString sessionId {parseCookie(m_request.headers.value(u"cookie"_qs)).value(m_sessionCookieName)};
590 // TODO: Additional session check
592 if (!sessionId.isEmpty())
594 m_currentSession = m_sessions.value(sessionId);
595 if (m_currentSession)
597 if (m_currentSession->hasExpired(m_sessionTimeout))
599 // session is outdated - removing it
600 delete m_sessions.take(sessionId);
601 m_currentSession = nullptr;
603 else
605 m_currentSession->updateTimestamp();
608 else
610 qDebug() << Q_FUNC_INFO << "session does not exist!";
614 if (!m_currentSession && !isAuthNeeded())
615 sessionStart();
618 QString WebApplication::generateSid() const
620 QString sid;
624 const quint32 tmp[] =
625 {Utils::Random::rand(), Utils::Random::rand(), Utils::Random::rand()
626 , Utils::Random::rand(), Utils::Random::rand(), Utils::Random::rand()};
627 sid = QString::fromLatin1(QByteArray::fromRawData(reinterpret_cast<const char *>(tmp), sizeof(tmp)).toBase64());
629 while (m_sessions.contains(sid));
631 return sid;
634 bool WebApplication::isAuthNeeded()
636 if (!m_isLocalAuthEnabled && Utils::Net::isLoopbackAddress(m_clientAddress))
637 return false;
638 if (m_isAuthSubnetWhitelistEnabled && Utils::Net::isIPInSubnets(m_clientAddress, m_authSubnetWhitelist))
639 return false;
640 return true;
643 bool WebApplication::isPublicAPI(const QString &scope, const QString &action) const
645 return m_publicAPIs.contains(u"%1/%2"_qs.arg(scope, action));
648 void WebApplication::sessionStart()
650 Q_ASSERT(!m_currentSession);
652 // remove outdated sessions
653 Algorithm::removeIf(m_sessions, [this](const QString &, const WebSession *session)
655 if (session->hasExpired(m_sessionTimeout))
657 delete session;
658 return true;
661 return false;
664 m_currentSession = new WebSession(generateSid(), app());
665 m_currentSession->registerAPIController<AppController>(u"app"_qs);
666 m_currentSession->registerAPIController<LogController>(u"log"_qs);
667 m_currentSession->registerAPIController<RSSController>(u"rss"_qs);
668 m_currentSession->registerAPIController<SearchController>(u"search"_qs);
669 m_currentSession->registerAPIController<SyncController>(u"sync"_qs);
670 m_currentSession->registerAPIController<TorrentsController>(u"torrents"_qs);
671 m_currentSession->registerAPIController<TransferController>(u"transfer"_qs);
672 m_sessions[m_currentSession->id()] = m_currentSession;
674 QNetworkCookie cookie {m_sessionCookieName.toLatin1(), m_currentSession->id().toUtf8()};
675 cookie.setHttpOnly(true);
676 cookie.setSecure(m_isSecureCookieEnabled && m_isHttpsEnabled);
677 cookie.setPath(u"/"_qs);
678 QByteArray cookieRawForm = cookie.toRawForm();
679 if (m_isCSRFProtectionEnabled)
680 cookieRawForm.append("; SameSite=Strict");
681 setHeader({Http::HEADER_SET_COOKIE, QString::fromLatin1(cookieRawForm)});
684 void WebApplication::sessionEnd()
686 Q_ASSERT(m_currentSession);
688 QNetworkCookie cookie {m_sessionCookieName.toLatin1()};
689 cookie.setPath(u"/"_qs);
690 cookie.setExpirationDate(QDateTime::currentDateTime().addDays(-1));
692 delete m_sessions.take(m_currentSession->id());
693 m_currentSession = nullptr;
695 setHeader({Http::HEADER_SET_COOKIE, QString::fromLatin1(cookie.toRawForm())});
698 bool WebApplication::isCrossSiteRequest(const Http::Request &request) const
700 // https://www.owasp.org/index.php/Cross-Site_Request_Forgery_(CSRF)_Prevention_Cheat_Sheet#Verifying_Same_Origin_with_Standard_Headers
702 const auto isSameOrigin = [](const QUrl &left, const QUrl &right) -> bool
704 // [rfc6454] 5. Comparing Origins
705 return ((left.port() == right.port())
706 // && (left.scheme() == right.scheme()) // not present in this context
707 && (left.host() == right.host()));
710 const QString targetOrigin = request.headers.value(Http::HEADER_X_FORWARDED_HOST, request.headers.value(Http::HEADER_HOST));
711 const QString originValue = request.headers.value(Http::HEADER_ORIGIN);
712 const QString refererValue = request.headers.value(Http::HEADER_REFERER);
714 if (originValue.isEmpty() && refererValue.isEmpty())
716 // owasp.org recommends to block this request, but doing so will inevitably lead Web API users to spoof headers
717 // so lets be permissive here
718 return false;
721 // sent with CORS requests, as well as with POST requests
722 if (!originValue.isEmpty())
724 const bool isInvalid = !isSameOrigin(urlFromHostHeader(targetOrigin), originValue);
725 if (isInvalid)
726 LogMsg(tr("WebUI: Origin header & Target origin mismatch! Source IP: '%1'. Origin header: '%2'. Target origin: '%3'")
727 .arg(m_env.clientAddress.toString(), originValue, targetOrigin)
728 , Log::WARNING);
729 return isInvalid;
732 if (!refererValue.isEmpty())
734 const bool isInvalid = !isSameOrigin(urlFromHostHeader(targetOrigin), refererValue);
735 if (isInvalid)
736 LogMsg(tr("WebUI: Referer header & Target origin mismatch! Source IP: '%1'. Referer header: '%2'. Target origin: '%3'")
737 .arg(m_env.clientAddress.toString(), refererValue, targetOrigin)
738 , Log::WARNING);
739 return isInvalid;
742 return true;
745 bool WebApplication::validateHostHeader(const QStringList &domains) const
747 const QUrl hostHeader = urlFromHostHeader(m_request.headers[Http::HEADER_HOST]);
748 const QString requestHost = hostHeader.host();
750 // (if present) try matching host header's port with local port
751 const int requestPort = hostHeader.port();
752 if ((requestPort != -1) && (m_env.localPort != requestPort))
754 LogMsg(tr("WebUI: Invalid Host header, port mismatch. Request source IP: '%1'. Server port: '%2'. Received Host header: '%3'")
755 .arg(m_env.clientAddress.toString()).arg(m_env.localPort)
756 .arg(m_request.headers[Http::HEADER_HOST])
757 , Log::WARNING);
758 return false;
761 // try matching host header with local address
762 const bool sameAddr = m_env.localAddress.isEqual(QHostAddress(requestHost));
764 if (sameAddr)
765 return true;
767 // try matching host header with domain list
768 for (const auto &domain : domains)
770 const QRegularExpression domainRegex {Utils::String::wildcardToRegexPattern(domain), QRegularExpression::CaseInsensitiveOption};
771 if (requestHost.contains(domainRegex))
772 return true;
775 LogMsg(tr("WebUI: Invalid Host header. Request source IP: '%1'. Received Host header: '%2'")
776 .arg(m_env.clientAddress.toString(), m_request.headers[Http::HEADER_HOST])
777 , Log::WARNING);
778 return false;
781 QHostAddress WebApplication::resolveClientAddress() const
783 if (!m_isReverseProxySupportEnabled)
784 return m_env.clientAddress;
786 // Only reverse proxy can overwrite client address
787 if (!Utils::Net::isIPInSubnets(m_env.clientAddress, m_trustedReverseProxyList))
788 return m_env.clientAddress;
790 const QString forwardedFor = m_request.headers.value(Http::HEADER_X_FORWARDED_FOR);
792 if (!forwardedFor.isEmpty())
794 // client address is the 1st global IP in X-Forwarded-For or, if none available, the 1st IP in the list
795 const QStringList remoteIpList = forwardedFor.split(u',', Qt::SkipEmptyParts);
797 if (!remoteIpList.isEmpty())
799 QHostAddress clientAddress;
801 for (const QString &remoteIp : remoteIpList)
803 if (clientAddress.setAddress(remoteIp) && clientAddress.isGlobal())
804 return clientAddress;
807 if (clientAddress.setAddress(remoteIpList[0]))
808 return clientAddress;
812 return m_env.clientAddress;
815 // WebSession
817 WebSession::WebSession(const QString &sid, IApplication *app)
818 : ApplicationComponent(app)
819 , m_sid {sid}
821 updateTimestamp();
824 QString WebSession::id() const
826 return m_sid;
829 bool WebSession::hasExpired(const qint64 seconds) const
831 if (seconds <= 0)
832 return false;
833 return m_timer.hasExpired(seconds * 1000);
836 void WebSession::updateTimestamp()
838 m_timer.start();
841 APIController *WebSession::getAPIController(const QString &scope) const
843 return m_apiControllers.value(scope);