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"
38 #include <QJsonDocument>
39 #include <QMimeDatabase>
41 #include <QNetworkCookie>
42 #include <QRegularExpression>
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
;
73 QStringMap
parseCookie(const QStringView cookieStr
)
75 // [rfc6265] 4.2.1. Syntax
77 const QList
<QStringView
> cookies
= cookieStr
.split(u
';', Qt::SkipEmptyParts
);
79 for (const auto &cookie
: cookies
)
81 const int idx
= cookie
.indexOf(u
'=');
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
);
92 QUrl
urlFromHostHeader(const QString
&hostHeader
)
94 if (!hostHeader
.contains(u
"://"))
95 return {u
"http://"_qs
+ 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))
137 const QRegularExpression invalidNameRegex
{u
"[^a-zA-Z0-9_\\-]"_qs
};
138 if (invalidNameRegex
.match(cookieName
).hasMatch())
145 WebApplication::WebApplication(IApplication
*app
, 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
);
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
"/")
184 Path localPath
= m_rootFolder
185 / Path(session() ? PRIVATE_FOLDER
: PUBLIC_FOLDER
)
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
);
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());
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
);
219 while (i
< data
.size() && found
)
221 QRegularExpressionMatch regexMatch
;
222 i
= data
.indexOf(regex
, i
, ®exMatch
);
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())
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
"'"_qs
);
237 translation
.replace(u
'\"', u
"""_qs
);
239 data
.replace(i
, regexMatch
.capturedLength(), translation
);
240 i
+= translation
.length();
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
262 const Http::Environment
&WebApplication::env() const
267 void WebApplication::doProcessRequest()
269 const QRegularExpressionMatch match
= m_apiPathPattern
.match(request().path
);
270 if (!match
.hasMatch())
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();
284 APIController
*controller
= nullptr;
286 controller
= session()->getAPIController(scope
);
289 if (scope
== u
"auth")
290 controller
= m_authController
;
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();
305 if (*allowedMethodIter
!= m_request
.method
)
306 throw MethodNotAllowedHTTPError();
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
);
321 case QMetaType::QByteArray
:
322 print(result
.toByteArray(), Http::CONTENT_TYPE_TXT
);
324 case QMetaType::QString
:
326 print(result
.toString(), Http::CONTENT_TYPE_TXT
);
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());
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();
363 LogMsg(tr("Using built-in Web UI."));
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.")
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
});
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
=
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
':');
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
);
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
);
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
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
)});
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()};
511 const QMimeType mimeType
= QMimeDatabase().mimeTypeForFileNameAndData(path
.data(), data
);
512 const bool isTranslatable
= !m_isAltUIUsed
&& mimeType
.inherits(u
"text/plain"_qs
);
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;
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());
546 m_params
= m_request
.posts
;
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();
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
);
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;
605 m_currentSession
->updateTimestamp();
610 qDebug() << Q_FUNC_INFO
<< "session does not exist!";
614 if (!m_currentSession
&& !isAuthNeeded())
618 QString
WebApplication::generateSid() const
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
));
634 bool WebApplication::isAuthNeeded()
636 if (!m_isLocalAuthEnabled
&& Utils::Net::isLoopbackAddress(m_clientAddress
))
638 if (m_isAuthSubnetWhitelistEnabled
&& Utils::Net::isIPInSubnets(m_clientAddress
, m_authSubnetWhitelist
))
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
))
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
721 // sent with CORS requests, as well as with POST requests
722 if (!originValue
.isEmpty())
724 const bool isInvalid
= !isSameOrigin(urlFromHostHeader(targetOrigin
), originValue
);
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
)
732 if (!refererValue
.isEmpty())
734 const bool isInvalid
= !isSameOrigin(urlFromHostHeader(targetOrigin
), refererValue
);
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
)
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
])
761 // try matching host header with local address
762 const bool sameAddr
= m_env
.localAddress
.isEqual(QHostAddress(requestHost
));
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
))
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
])
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
;
817 WebSession::WebSession(const QString
&sid
, IApplication
*app
)
818 : ApplicationComponent(app
)
824 QString
WebSession::id() const
829 bool WebSession::hasExpired(const qint64 seconds
) const
833 return m_timer
.hasExpired(seconds
* 1000);
836 void WebSession::updateTimestamp()
841 APIController
*WebSession::getAPIController(const QString
&scope
) const
843 return m_apiControllers
.value(scope
);