6 #include "csrfprotection_p.h"
8 #include <Cutelyst/Application>
9 #include <Cutelyst/Engine>
10 #include <Cutelyst/Context>
11 #include <Cutelyst/Request>
12 #include <Cutelyst/Response>
13 #include <Cutelyst/Plugins/Session/Session>
14 #include <Cutelyst/Headers>
15 #include <Cutelyst/Action>
16 #include <Cutelyst/Dispatcher>
17 #include <Cutelyst/Controller>
18 #include <Cutelyst/Upload>
20 #include <QLoggingCategory>
27 #define DEFAULT_COOKIE_AGE Q_INT64_C(31449600)
28 #define DEFAULT_COOKIE_NAME "csrftoken"
29 #define DEFAULT_COOKIE_PATH "/"
30 #define DEFAULT_COOKIE_SAMESITE "strict"
31 #define DEFAULT_HEADER_NAME "X_CSRFTOKEN"
32 #define DEFAULT_FORM_INPUT_NAME "csrfprotectiontoken"
33 #define CSRF_SECRET_LENGTH 32
34 #define CSRF_TOKEN_LENGTH 2 * CSRF_SECRET_LENGTH
35 #define CSRF_ALLOWED_CHARS "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_"
36 #define CSRF_SESSION_KEY "_csrftoken"
37 #define CONTEXT_CSRF_COOKIE QStringLiteral("_c_csrfcookie")
38 #define CONTEXT_CSRF_COOKIE_USED QStringLiteral("_c_csrfcookieused")
39 #define CONTEXT_CSRF_COOKIE_NEEDS_RESET QStringLiteral("_c_csrfcookieneedsreset")
40 #define CONTEXT_CSRF_PROCESSING_DONE QStringLiteral("_c_csrfprocessingdone")
41 #define CONTEXT_CSRF_COOKIE_SET QStringLiteral("_c_csrfcookieset")
42 #define CONTEXT_CSRF_CHECK_PASSED QStringLiteral("_c_csrfcheckpassed")
44 Q_LOGGING_CATEGORY(C_CSRFPROTECTION,
"cutelyst.plugin.csrfprotection", QtWarningMsg)
49 const QRegularExpression CSRFProtectionPrivate::sanitizeRe = QRegularExpression(QStringLiteral(
"[^a-zA-Z0-9\\-_]"));
51 const QStringList CSRFProtectionPrivate::secureMethods = QStringList({QStringLiteral(
"GET"), QStringLiteral(
"HEAD"), QStringLiteral(
"OPTIONS"), QStringLiteral(
"TRACE")});
54 , d_ptr(new CSRFProtectionPrivate)
70 const QVariantMap config = app->
engine()->
config(QStringLiteral(
"Cutelyst_CSRFProtection_Plugin"));
72 d->cookieAge = config.value(QStringLiteral(
"cookie_age"), DEFAULT_COOKIE_AGE).value<qint64>();
73 if (d->cookieAge <= 0) {
74 d->cookieAge = DEFAULT_COOKIE_AGE;
76 d->cookieDomain = config.value(QStringLiteral(
"cookie_domain")).toString();
77 if (d->cookieName.isEmpty()) {
78 d->cookieName = QStringLiteral(DEFAULT_COOKIE_NAME);
80 d->cookiePath = QStringLiteral(DEFAULT_COOKIE_PATH);
81 d->cookieSecure = config.value(QStringLiteral(
"cookie_secure"),
false).toBool();
82 if (d->headerName.isEmpty()) {
83 d->headerName = QStringLiteral(DEFAULT_HEADER_NAME);
85 const QString _sameSite = config.value(QLatin1String(
"cookie_same_site"), QStringLiteral(
"strict")).toString();
86 #if QT_VERSION >= QT_VERSION_CHECK(6, 1, 0)
87 if (_sameSite.compare(u
"default", Qt::CaseInsensitive) == 0) {
88 d->cookieSameSite = QNetworkCookie::SameSite::Default;
89 }
else if (_sameSite.compare(u
"none", Qt::CaseInsensitive) == 0) {
90 d->cookieSameSite = QNetworkCookie::SameSite::None;
91 }
else if (_sameSite.compare(u
"lax", Qt::CaseInsensitive) == 0) {
92 d->cookieSameSite = QNetworkCookie::SameSite::Lax;
94 d->cookieSameSite = QNetworkCookie::SameSite::Strict;
97 if (_sameSite.compare(u
"default", Qt::CaseInsensitive) == 0) {
99 }
else if (_sameSite.compare(u
"none", Qt::CaseInsensitive) == 0) {
101 }
else if (_sameSite.compare(u
"lax", Qt::CaseInsensitive) == 0) {
108 d->trustedOrigins = config.value(QStringLiteral(
"trusted_origins")).toString().split(u
',', Qt::SkipEmptyParts);
109 if (d->formInputName.isEmpty()) {
110 d->formInputName = QStringLiteral(DEFAULT_FORM_INPUT_NAME);
112 d->logFailedIp = config.value(QStringLiteral(
"log_failed_ip"),
false).toBool();
113 if (d->errorMsgStashKey.isEmpty()) {
114 d->errorMsgStashKey = QStringLiteral(
"error_msg");
122 d->beforeDispatch(c);
131 d->defaultDetachTo = actionNameOrPath;
137 if (!fieldName.isEmpty()) {
138 d->formInputName = fieldName;
140 d->formInputName = QStringLiteral(DEFAULT_FORM_INPUT_NAME);
147 if (!keyName.isEmpty()) {
148 d->errorMsgStashKey = keyName;
150 d->errorMsgStashKey = QStringLiteral(
"error_msg");
157 d->ignoredNamespaces = namespaces;
163 d->useSessions = useSessions;
169 d->cookieHttpOnly = httpOnly;
175 d->cookieName = cookieName;
181 d->headerName = headerName;
187 d->genericErrorMessage = message;
193 d->genericContentType = type;
200 const QByteArray contextCookie = c->
stash(CONTEXT_CSRF_COOKIE).toByteArray();
202 if (contextCookie.isEmpty()) {
203 secret = CSRFProtectionPrivate::getNewCsrfString();
204 token = CSRFProtectionPrivate::saltCipherSecret(secret);
205 c->
setStash(CONTEXT_CSRF_COOKIE, token);
207 secret = CSRFProtectionPrivate::unsaltCipherToken(contextCookie);
208 token = CSRFProtectionPrivate::saltCipherSecret(secret);
211 c->
setStash(CONTEXT_CSRF_COOKIE_USED,
true);
221 qCCritical(C_CSRFPROTECTION) <<
"CSRFProtection plugin not registered";
225 form = QStringLiteral(
"<input type=\"hidden\" name=\"%1\" value=\"%2\" />").arg(csrf->d_ptr->formInputName, QString::fromLatin1(
CSRFProtection::getToken(c)));
232 if (CSRFProtectionPrivate::secureMethods.contains(c->req()->method())) {
235 return c->
stash(CONTEXT_CSRF_CHECK_PASSED).toBool();
250 QByteArray CSRFProtectionPrivate::getNewCsrfString()
252 QByteArray csrfString;
254 while (csrfString.size() < CSRF_SECRET_LENGTH) {
255 csrfString.append(QUuid::createUuid().toRfc4122().toBase64(QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals));
258 csrfString.resize(CSRF_SECRET_LENGTH);
268 QByteArray CSRFProtectionPrivate::saltCipherSecret(
const QByteArray &secret)
271 salted.reserve(CSRF_TOKEN_LENGTH);
273 const QByteArray salt = CSRFProtectionPrivate::getNewCsrfString();
274 const QByteArray chars = QByteArrayLiteral(CSRF_ALLOWED_CHARS);
275 std::vector<std::pair<int,int>> pairs;
276 pairs.reserve(std::min(secret.size(), salt.size()));
277 for (
int i = 0; i < std::min(secret.size(), salt.size()); ++i) {
278 pairs.push_back(std::make_pair(chars.indexOf(secret.at(i)), chars.indexOf(salt.at(i))));
282 cipher.reserve(CSRF_SECRET_LENGTH);
283 for (std::size_t i = 0; i < pairs.size(); ++i) {
284 const std::pair<int,int> p = pairs.at(i);
285 cipher.append(chars[(p.first + p.second) % chars.size()]);
288 salted = salt + cipher;
299 QByteArray CSRFProtectionPrivate::unsaltCipherToken(
const QByteArray &token)
302 secret.reserve(CSRF_SECRET_LENGTH);
304 const QByteArray salt = token.left(CSRF_SECRET_LENGTH);
305 const QByteArray _token = token.mid(CSRF_SECRET_LENGTH);
307 const QByteArray chars = QByteArrayLiteral(CSRF_ALLOWED_CHARS);
308 std::vector<std::pair<int,int>> pairs;
309 pairs.reserve(std::min(salt.size(), _token.size()));
310 for (
int i = 0; i < std::min(salt.size(), _token.size()); ++i) {
311 pairs.push_back(std::make_pair(chars.indexOf(_token.at(i)), chars.indexOf(salt.at(i))));
315 for (std::size_t i = 0; i < pairs.size(); ++i) {
316 const std::pair<int,int> p = pairs.at(i);
317 int idx = p.first - p.second;
319 idx = chars.size() + idx;
321 secret.append(chars.at(idx));
332 QByteArray CSRFProtectionPrivate::getNewCsrfToken()
334 return CSRFProtectionPrivate::saltCipherSecret(CSRFProtectionPrivate::getNewCsrfString());
342 QByteArray CSRFProtectionPrivate::sanitizeToken(
const QByteArray &token)
344 QByteArray sanitized;
346 const QString tokenString = QString::fromLatin1(token);
347 if (tokenString.contains(CSRFProtectionPrivate::sanitizeRe)) {
348 sanitized = CSRFProtectionPrivate::getNewCsrfToken();
349 }
else if (token.size() != CSRF_TOKEN_LENGTH) {
350 sanitized = CSRFProtectionPrivate::getNewCsrfToken();
362 QByteArray CSRFProtectionPrivate::getToken(
Context *c)
367 qCCritical(C_CSRFPROTECTION) <<
"CSRFProtection plugin not registered";
371 if (csrf->d_ptr->useSessions) {
372 token =
Session::value(c, QStringLiteral(CSRF_SESSION_KEY)).toByteArray();
374 QByteArray cookieToken = c->req()->
cookie(csrf->d_ptr->cookieName).toLatin1();
376 if (cookieToken.isEmpty()) {
380 token = CSRFProtectionPrivate::sanitizeToken(cookieToken);
381 if (token != cookieToken) {
382 c->
setStash(CONTEXT_CSRF_COOKIE_NEEDS_RESET,
true);
386 qCDebug(C_CSRFPROTECTION,
"Got token \"%s\" from %s.", token.constData(), csrf->d_ptr->useSessions ?
"session" :
"cookie");
395 void CSRFProtectionPrivate::setToken(
Context *c)
398 qCCritical(C_CSRFPROTECTION) <<
"CSRFProtection plugin not registered";
402 if (csrf->d_ptr->useSessions) {
405 #if (QT_VERSION >= QT_VERSION_CHECK(6, 1, 0))
406 QNetworkCookie cookie(csrf->d_ptr->cookieName.toLatin1(), c->
stash(CONTEXT_CSRF_COOKIE).toByteArray());
408 Cookie cookie(csrf->d_ptr->cookieName.toLatin1(), c->
stash(CONTEXT_CSRF_COOKIE).toByteArray());
410 if (!csrf->d_ptr->cookieDomain.isEmpty()) {
411 cookie.setDomain(csrf->d_ptr->cookieDomain);
413 cookie.setExpirationDate(QDateTime::currentDateTime().addSecs(csrf->d_ptr->cookieAge));
414 cookie.setHttpOnly(csrf->d_ptr->cookieHttpOnly);
415 cookie.setPath(csrf->d_ptr->cookiePath);
416 cookie.setSecure(csrf->d_ptr->cookieSecure);
417 cookie.setSameSitePolicy(csrf->d_ptr->cookieSameSite);
418 #if (QT_VERSION >= QT_VERSION_CHECK(6, 1, 0))
421 c->
res()->setCuteCookie(cookie);
426 qCDebug(C_CSRFPROTECTION,
"Set token \"%s\" to %s.", c->
stash(CONTEXT_CSRF_COOKIE).toByteArray().constData(), csrf->d_ptr->useSessions ?
"session" :
"cookie");
434 void CSRFProtectionPrivate::reject(
Context *c,
const QString &logReason,
const QString &displayReason)
436 c->
setStash(CONTEXT_CSRF_CHECK_PASSED,
false);
439 qCCritical(C_CSRFPROTECTION) <<
"CSRFProtection plugin not registered";
443 qCWarning(C_CSRFPROTECTION,
"Forbidden: (%s): /%s [%s]", qPrintable(logReason), qPrintable(c->req()->path()), csrf->d_ptr->logFailedIp ? qPrintable(c->req()->
addressString()) :
"IP logging disabled");
446 c->
setStash(csrf->d_ptr->errorMsgStashKey, displayReason);
448 QString detachToCsrf = c->action()->
attribute(QStringLiteral(
"CSRFDetachTo"));
449 if (detachToCsrf.isEmpty()) {
450 detachToCsrf = csrf->d_ptr->defaultDetachTo;
453 Action *detachToAction =
nullptr;
455 if (!detachToCsrf.isEmpty()) {
456 detachToAction = c->controller()->
actionFor(detachToCsrf);
457 if (!detachToAction) {
460 if (!detachToAction) {
461 qCWarning(C_CSRFPROTECTION,
"Can not find action for \"%s\" to detach to.", qPrintable(detachToCsrf));
465 if (detachToAction) {
466 c->
detach(detachToAction);
469 if (!csrf->d_ptr->genericErrorMessage.isEmpty()) {
470 c->
res()->
setBody(csrf->d_ptr->genericErrorMessage);
473 const QString title = c->
translate(
"Cutelyst::CSRFProtection",
"403 Forbidden - CSRF protection check failed");
474 c->
res()->
setBody(QStringLiteral(
"<!DOCTYPE html>\n"
475 "<html xmlns=\"http://www.w3.org/1999/xhtml\">\n"
477 " <title>") + title +
478 QStringLiteral(
"</title>\n"
482 QStringLiteral(
"</h1>\n"
483 " <p>") + displayReason +
484 QStringLiteral(
"</p>\n"
493 void CSRFProtectionPrivate::accept(
Context *c)
495 c->
setStash(CONTEXT_CSRF_CHECK_PASSED,
true);
496 c->
setStash(CONTEXT_CSRF_PROCESSING_DONE,
true);
503 bool CSRFProtectionPrivate::compareSaltedTokens(
const QByteArray &t1,
const QByteArray &t2)
505 const QByteArray _t1 = CSRFProtectionPrivate::unsaltCipherToken(t1);
506 const QByteArray _t2 = CSRFProtectionPrivate::unsaltCipherToken(t2);
509 int diff = _t1.size() ^ _t2.size();
510 for (
int i = 0; i < _t1.size() && i < _t2.size(); i++) {
511 diff |= _t1[i] ^ _t2[i];
520 void CSRFProtectionPrivate::beforeDispatch(
Context *c)
523 CSRFProtectionPrivate::reject(c, QStringLiteral(
"CSRFProtection plugin not registered"), c->
translate(
"Cutelyst::CSRFProtection",
"The CSRF protection plugin has not been registered."));
527 const QByteArray csrfToken = CSRFProtectionPrivate::getToken(c);
528 if (!csrfToken.isNull()) {
529 c->
setStash(CONTEXT_CSRF_COOKIE, csrfToken);
534 if (c->
stash(CONTEXT_CSRF_PROCESSING_DONE).toBool()) {
538 if (c->action()->
attributes().contains(QStringLiteral(
"CSRFIgnore"))) {
539 qCDebug(C_CSRFPROTECTION,
"Action \"%s::%s\" is ignored by the CSRF protection.", qPrintable(c->action()->
className()), qPrintable(c->action()->
reverse()));
543 if (csrf->d_ptr->ignoredNamespaces.contains(c->action()->
ns())) {
544 if (!c->action()->
attributes().contains(QStringLiteral(
"CSRFRequire"))) {
545 qCDebug(C_CSRFPROTECTION,
"Namespace \"%s\" is ignored by the CSRF protection.", qPrintable(c->action()->
ns()));
552 if (!CSRFProtectionPrivate::secureMethods.contains(c->req()->method())) {
567 if (c->req()->secure()) {
570 if (Q_UNLIKELY(referer.isEmpty())) {
571 CSRFProtectionPrivate::reject(c, QStringLiteral(
"Referer checking failed - no Referer"), c->
translate(
"Cutelyst::CSRFProtection",
"Referer checking failed - no Referer."));
574 const QUrl refererUrl(referer);
575 if (Q_UNLIKELY(!refererUrl.isValid())) {
576 CSRFProtectionPrivate::reject(c, QStringLiteral(
"Referer checking failed - Referer is malformed"), c->
translate(
"Cutelyst::CSRFProtection",
"Referer checking failed - Referer is malformed."));
579 if (Q_UNLIKELY(refererUrl.scheme() != QLatin1String(
"https"))) {
580 CSRFProtectionPrivate::reject(c, QStringLiteral(
"Referer checking failed - Referer is insecure while host is secure"), c->
translate(
"Cutelyst::CSRFProtection",
"Referer checking failed - Referer is insecure while host is secure."));
586 const QUrl uri = c->req()->uri();
588 if (!csrf->d_ptr->useSessions) {
589 goodReferer = csrf->d_ptr->cookieDomain;
591 if (goodReferer.isEmpty()) {
592 goodReferer = uri.host();
594 const int serverPort = uri.port(c->req()->secure() ? 443 : 80);
595 if ((serverPort != 80) && (serverPort != 443)) {
596 goodReferer += u
':' + QString::number(serverPort);
599 QStringList goodHosts = csrf->d_ptr->trustedOrigins;
600 goodHosts.append(goodReferer);
602 QString refererHost = refererUrl.host();
603 const int refererPort = refererUrl.port(refererUrl.scheme().compare(u
"https") == 0 ? 443 : 80);
604 if ((refererPort != 80) && (refererPort != 443)) {
605 refererHost += u
':' + QString::number(refererPort);
608 bool refererCheck =
false;
609 for (
int i = 0; i < goodHosts.size(); ++i) {
610 const QString host = goodHosts.at(i);
611 if ((host.startsWith(u
'.') && (refererHost.endsWith(host) || (refererHost == host.mid(1)))) || host == refererHost) {
617 if (Q_UNLIKELY(!refererCheck)) {
619 CSRFProtectionPrivate::reject(c, QStringLiteral(
"Referer checking failed - %1 does not match any trusted origins").arg(referer), c->
translate(
"Cutelyst::CSRFProtection",
"Referer checking failed - %1 does not match any trusted origins.").arg(referer));
627 if (Q_UNLIKELY(csrfToken.isEmpty())) {
628 CSRFProtectionPrivate::reject(c, QStringLiteral(
"CSRF cookie not set"), c->
translate(
"Cutelyst::CSRFProtection",
"CSRF cookie not set."));
632 QByteArray requestCsrfToken;
635 if (c->req()->contentType().compare(u
"multipart/form-data") == 0) {
637 Upload *upload = c->req()->
upload(csrf->d_ptr->formInputName);
638 if (upload && upload->
size() < 1024 ) {
639 requestCsrfToken = upload->readAll();
642 requestCsrfToken = c->req()->
bodyParam(csrf->d_ptr->formInputName).toLatin1();
645 if (requestCsrfToken.isEmpty()) {
646 requestCsrfToken = c->req()->
header(csrf->d_ptr->headerName).toLatin1();
647 if (Q_LIKELY(!requestCsrfToken.isEmpty())) {
648 qCDebug(C_CSRFPROTECTION,
"Got token \"%s\" from HTTP header %s.", requestCsrfToken.constData(), qPrintable(csrf->d_ptr->headerName));
650 qCDebug(C_CSRFPROTECTION,
"Can not get token from HTTP header or form field.");
653 qCDebug(C_CSRFPROTECTION,
"Got token \"%s\" from form field %s.", requestCsrfToken.constData(), qPrintable(csrf->d_ptr->formInputName));
656 requestCsrfToken = CSRFProtectionPrivate::sanitizeToken(requestCsrfToken);
658 if (Q_UNLIKELY(!CSRFProtectionPrivate::compareSaltedTokens(requestCsrfToken, csrfToken))) {
659 CSRFProtectionPrivate::reject(c, QStringLiteral(
"CSRF token missing or incorrect"), c->
translate(
"Cutelyst::CSRFProtection",
"CSRF token missing or incorrect."));
666 CSRFProtectionPrivate::accept(c);
673 if (!c->
stash(CONTEXT_CSRF_COOKIE_NEEDS_RESET).toBool()) {
674 if (c->
stash(CONTEXT_CSRF_COOKIE_SET).toBool()) {
679 if (!c->
stash(CONTEXT_CSRF_COOKIE_USED).toBool()) {
683 CSRFProtectionPrivate::setToken(c);
684 c->
setStash(CONTEXT_CSRF_COOKIE_SET,
true);
687 #include "moc_csrfprotection.cpp"
This class represents a Cutelyst Action.
QString ns() const noexcept
QString className() const
ParamsMultiMap attributes() const noexcept
QString attribute(const QString &name, const QString &defaultValue={}) const
The Cutelyst Application.
Engine * engine() const noexcept
void beforeDispatch(Cutelyst::Context *c)
T plugin()
Returns the registered plugin that casts to the template type T.
void loadTranslations(const QString &filename, const QString &directory=QString(), const QString &prefix=QString(), const QString &suffix=QString())
void postForked(Cutelyst::Application *app)
Protect input forms against Cross Site Request Forgery (CSRF/XSRF) attacks.
static bool checkPassed(Context *c)
void setUseSessions(bool useSessions)
void setIgnoredNamespaces(const QStringList &namespaces)
void setFormFieldName(const QString &fieldName)
void setDefaultDetachTo(const QString &actionNameOrPath)
void setErrorMsgStashKey(const QString &keyName)
void setCookieHttpOnly(bool httpOnly)
void setCookieName(const QString &cookieName)
void setGenericErrorContentTyp(const QString &type)
static QByteArray getToken(Context *c)
virtual ~CSRFProtection() override
void setGenericErrorMessage(const QString &message)
virtual bool setup(Application *app) override
static QString getTokenFormField(Context *c)
CSRFProtection(Application *parent)
void setHeaderName(const QString &headerName)
void stash(const QVariantHash &unite)
void detach(Action *action=nullptr)
Response * res() const noexcept
QString translate(const char *context, const char *sourceText, const char *disambiguation=nullptr, int n=-1) const
void setStash(const QString &key, const QVariant &value)
Dispatcher * dispatcher() const noexcept
Action * actionFor(const QString &name) const
Action * getActionByPath(const QString &path) const
QVariantMap config(const QString &entity) const
user configuration for the application
QString addressString() const
QString header(const QString &key) const
bool isDelete() const noexcept
Headers headers() const noexcept
QString cookie(const QString &name) const
QString bodyParam(const QString &key, const QString &defaultValue={}) const
Upload * upload(const QString &name) const
void setStatus(quint16 status) noexcept
Headers & headers() noexcept
void setBody(QIODevice *body)
void setCookie(const QNetworkCookie &cookie)
void setContentType(const QString &type)
static QVariant value(Context *c, const QString &key, const QVariant &defaultValue=QVariant())
static void setValue(Context *c, const QString &key, const QVariant &value)
Cutelyst Upload handles file upload request
virtual qint64 size() const override
The Cutelyst namespace holds all public Cutelyst API.