summaryrefslogtreecommitdiffstats
path: root/src/webview.cpp
blob: 1e4a3a4d9e2f68fb6166f8fba7855e8e1714ac3d (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
#include "webview.h"

#include "global.h"

#include <QAction>
#include <QMessageBox>
#include <QTimer>
#include <QUrlQuery>
#include <QCryptographicHash>
#include <QCursor>
#include <QRegularExpression>
#include <QDateTime>
#include <QDebug>
#include <QMenu>
#include <QContextMenuEvent>

#include <QWebEngineHistory>
#include <QWebEngineSettings>
#include <QWebEngineProfile>
#include <QWebEngineCookieStore>
#include <QWebEngineDownloadItem>
#include <QWebEngineUrlRequestInterceptor>
#include <QWebEngineScriptCollection>

// Add: custom page to catch console messages from JS
class ActivityPage : public QWebEnginePage
{
public:
    explicit ActivityPage(QWebEngineProfile *profile, QObject *parent, std::function<void()> onActivity)
        : QWebEnginePage(profile, parent), _onActivity(std::move(onActivity)) {}

protected:
    void javaScriptConsoleMessage(JavaScriptConsoleMessageLevel level,
                                  const QString &message, int lineNumber,
                                  const QString &sourceID) override
    {
        Q_UNUSED(level);
        Q_UNUSED(lineNumber);
        Q_UNUSED(sourceID);
        // Recognize our activity marker
        if (message == QLatin1String("LOG_USER_ACTIVITY")) {
            qDebug() << "User activity detected";
            if (_onActivity) _onActivity();
        }
        // fall through to default behavior
        QWebEnginePage::javaScriptConsoleMessage(level, message, lineNumber, sourceID);
    }

private:
    std::function<void()> _onActivity;
};

static QRegularExpression urlListToRegExp(const QStringList &list);

// URL interceptor for black-/whitelist
class SlxUrlRequestInterceptor : public QWebEngineUrlRequestInterceptor
{
public:
    SlxUrlRequestInterceptor(const QRegularExpression& blackList, const QRegularExpression& whiteList)
        : _black(blackList), _white(whiteList) {}

    void interceptRequest(QWebEngineUrlRequestInfo &info) override {
        const QString u = info.requestUrl().toString();
        if (_white.isValid() && !_white.match(u).hasMatch()) {
            info.block(true);
            return;
        }
        if (_black.isValid() && _black.match(u).hasMatch()) {
            info.block(true);
            return;
        }
    }
private:
    QRegularExpression _black, _white;
};

WebView::WebView(QWidget* parent)
    : QWebEngineView(parent),
      _timerAbortMessage(new QTimer(this)),
      _abortedDownload(false),
      _inErrorState(false),
      _timerReset(new QTimer(this)),
      _firstLoad(false),
      _profile(new QWebEngineProfile(this))
{
    // Configure profile, no persistent cookies or cache
    _profile->setPersistentCookiesPolicy(QWebEngineProfile::NoPersistentCookies);
    _profile->setHttpCacheType(QWebEngineProfile::MemoryHttpCache);

    {
        // Make sure we get mobile pages if applicable, since the window is rather small
        QString ua = _profile->httpUserAgent();
        ua.replace(QRegularExpression("(\\S+)$"), "Mobile \\1");
        _profile->setHttpUserAgent(ua);
    }

    {
        // Any url filtering, since many pages allow you to escape through different links
        auto bl = Global::urlBlacklist();
        auto wl = Global::urlWhitelist();
        _profile->setRequestInterceptor(new SlxUrlRequestInterceptor(urlListToRegExp(bl), urlListToRegExp(wl)));
    }

    // Use our custom page to receive activity signals from injected JS
    auto *p = new ActivityPage(_profile, this, [this]() {
        this->resetTimeout();
    });
    setPage(p);

    // Settings
    page()->settings()->setAttribute(QWebEngineSettings::LocalStorageEnabled, true);

    // Also reset on scrolls (native signal)
    connect(page(), &QWebEnginePage::scrollPositionChanged, this, [this](const QPointF &) {
        this->resetTimeout();
    });

    // Block downloads
    connect(_profile, &QWebEngineProfile::downloadRequested, this, [this](QWebEngineDownloadItem *item) {
        Q_UNUSED(item);
        _abortedDownload = true;
        _timerAbortMessage->start(1);
        item->cancel();
    });

    // Handle window close requests, simulate back navigation
    connect(page(), &QWebEnginePage::windowCloseRequested, this, &WebView::windowCloseRequested);

    _timerAbortMessage->setSingleShot(true);
    _timerReset->setSingleShot(true);

    connect(_timerAbortMessage, &QTimer::timeout, this, &WebView::downloadDeniedMessage);
    connect(_timerReset, &QTimer::timeout, this, [this]() {
        this->resetBrowserAndTimeout();
        emit triggerReset(tr("Inactivity Timeout"));
    });
    connect(this, &QWebEngineView::loadFinished, this, &WebView::onLoadFinished);

    // JS-Injection
    installJsInjectionScript();
}

void WebView::resetBrowserAndTimeout()
{
    _timerReset->stop();
    _timerAbortMessage->stop();
    this->stop();
    this->setHtml(QString());
}

void WebView::windowCloseRequested()
{
    if (_urls.empty())
        return;
    const QUrl url = _urls.pop();
    this->setUrl(url);
}

QWebEngineView* WebView::createWindow(QWebEnginePage::WebWindowType)
{
    // Open in same window and push old URL to stack, so we can navigate back on close requests
    _urls.push(this->url());
    return this;
}

void WebView::mousePressEvent(QMouseEvent* ev)
{
    QWebEngineView::mousePressEvent(ev);
    resetTimeout();
}

void WebView::keyPressEvent(QKeyEvent* ev)
{
    QWebEngineView::keyPressEvent(ev);
    resetTimeout();
}

void WebView::wheelEvent(QWheelEvent* ev)
{
    QWebEngineView::wheelEvent(ev);
    resetTimeout();
}

void WebView::resetTimeout() const
{
    if (!_inErrorState) {
        _timerReset->start(60000);
    }
}

void WebView::downloadDeniedMessage()
{
    QMessageBox::warning(this->parentWidget(), QString::fromUtf8("Denied"),
            QString::fromUtf8("The requested action triggered a download, which is not allowed.\n\n"
                              "Diese Aktion löst einen Download aus, was nicht erlaubt ist."));
}

void WebView::onLoadFinished(bool ok)
{
    if (_abortedDownload || !ok) {
        _abortedDownload = false;
        _inErrorState = true;
        _timerReset->start(10000);
        return;
    }
    _inErrorState = false;

    // Check if this is the result of the auth process
    evaluateAuthDom();

    if (_firstLoad) {
        _firstLoad = false;
        if (this->history())
            this->history()->clear();
    }
}

void WebView::reset(const QString& baseUrl)
{
    QUrl url(baseUrl);
    QUrlQuery q(url.query());
    q.addQueryItem("action", "browser");
    QByteArray input;
    input.append((const char*)this, sizeof(*this));
    input.append(QString::asprintf("%d %d", QCursor::pos().x(), QCursor::pos().y()).toUtf8());
    input.append(QString::number(QDateTime::currentMSecsSinceEpoch()).toUtf8());
    _token = QCryptographicHash::hash(input, QCryptographicHash::Md5).left(8).toHex();
    q.addQueryItem("token", _token);
    url.setQuery(q);
    _urls.clear();

    // Clear all cookies
    if (_profile && _profile->cookieStore())
        _profile->cookieStore()->deleteAllCookies();

    this->setUrl(url);
    _firstLoad = true;
    _timerAbortMessage->stop();
    _timerReset->stop();
}

static QRegularExpression urlListToRegExp(const QStringList &list)
{
    if (list.isEmpty())
        return QRegularExpression("(["); // Return an invalid regex, we use .isValid to check if the list is to be used.
    // We search in the escaped string, so actually look for \*\* and \*
    // Capture char before that because it must not be another backslash, as that
    // means the star was already escaped in the list.
    static const QRegularExpression STARSTAR(R"((^|[^\\])\\\*\\\*)");
    static const QRegularExpression STAR(R"((^|[^\\])\\\*)");
    static const QRegularExpression QUEST(R"((^|[^\\])\\\?)");
    static const QString STARSTAR_REP("\\1.*");
    static const QString STAR_REP("\\1[^/]*");
    static const QString QUEST_REP("\\1.?");
    QStringList regexes;
    for (const QString &str : list) {
        QString mangled;
        if (str.contains(QLatin1String("//"))) {
            mangled = str;
        } else if (str.contains(QLatin1Char('/')) || str.contains(QLatin1String("**"))) {
            mangled = "*//" + str;
        } else {
            mangled = "*//" + str + "/**";
        }
        mangled = QRegularExpression::escape(mangled);
        mangled = mangled.replace(STARSTAR, STARSTAR_REP).replace(STAR, STAR_REP)
                         .replace(QUEST, QUEST_REP);
        regexes << mangled;
    }
    qDebug() << regexes;
    return QRegularExpression("^(" + regexes.join('|') + ")$");
}

void WebView::installJsInjectionScript()
{
    // If we should filter the list of allowed IdPs, inject the list into
    // the page as JavaScript
    QString str = Global::getCombinedIdpWhitelist().replace(
        QRegularExpression("[^\\w. /:-]", QRegularExpression::UseUnicodePropertiesOption),
        QString());
    QWebEngineScript script;
    script.setName(QStringLiteral("slxIdpFilterInjector"));
    script.setInjectionPoint(QWebEngineScript::DocumentCreation);
    script.setWorldId(QWebEngineScript::MainWorld);
    script.setRunsOnSubFrames(true);
    script.setSourceCode(QStringLiteral("var slxIdpFilter ='") + str + QStringLiteral("';"));
    page()->scripts().insert(script);

    // Inject activity listeners to signal user interaction back to C++
    QWebEngineScript activityScript;
    activityScript.setName(QStringLiteral("slxUserActivity"));
    activityScript.setInjectionPoint(QWebEngineScript::DocumentCreation);
    activityScript.setWorldId(QWebEngineScript::MainWorld);
    activityScript.setRunsOnSubFrames(true);
    activityScript.setSourceCode(QStringLiteral(
        "(function(){\n"
        "  var last=0; function ping(){\n"
        "    var now=Date.now(); if(now-last<2000) return; last=now;\n"
        "    try{console.debug('LOG_USER_ACTIVITY');}catch(e){}\n"
        "  }\n"
        "  var evts=['mousedown','mouseup','click','keydown','keyup','wheel','touchstart','touchend','scroll'];\n"
        "  evts.forEach(function(ev){\n"
        "    window.addEventListener(ev, ping, {passive:true, capture:true});\n"
        "  });\n"
        "})();\n"
    ));
    page()->scripts().insert(activityScript);
}

void WebView::contextMenuEvent(QContextMenuEvent* ev)
{
    // Build the default menu
    QMenu* menu = page()->createStandardContextMenu();

    // Find and remove/hide the "View source" action
    QAction* viewSource = page()->action(QWebEnginePage::ViewSource);
    if (viewSource) {
        viewSource->setVisible(false);         // or: menu->removeAction(viewSource);
        viewSource->setEnabled(false);
    }

    // Show the customized menu
    menu->exec(ev->globalPos());
    menu->deleteLater();
    ev->accept();
}

void WebView::evaluateAuthDom()
{
    // See if expected auth fields are in the document. If so, we want to trigger the
    // authentication process using those fields.
    const QString js = QStringLiteral(
        "(function(){"
        "  function t(sel){var e=document.querySelector(sel);return e?e.textContent:'';}"
        "  return {"
        "    user: t('#bwlp-username'),"
        "    pass: t('#bwlp-password'),"
        "    err:  t('#bwlp-error'),"
        "    hash: t('#bwlp-hash'),"
        "    adminToken: t('#bwlp-cow-token')"
        "  };"
        "})();"
    );

    page()->runJavaScript(js, [this](const QVariant &v){
        const auto map = v.toMap();
        const QString user = map.value(QStringLiteral("user")).toString().trimmed();
        const QString pass = map.value(QStringLiteral("pass")).toString();
        const QString err  = map.value(QStringLiteral("err")).toString();
        const QString hash = map.value(QStringLiteral("hash")).toString();
        const QString adminToken = map.value(QStringLiteral("adminToken")).toString();

        if (!user.isEmpty() && !pass.isEmpty() && !hash.isEmpty()) {
            if (hash != QCryptographicHash::hash(_token.toLatin1(), QCryptographicHash::Md5).toHex()) {
                qDebug() << " *** Invalid security hash ***";
                emit triggerReset(QStringLiteral("Invalid Hash"));
                return;
            }
            if (Global::isValidShibCreds(user, pass)) {
                if (!adminToken.isEmpty()) {
                    Global::writeCowToken(user, adminToken);
                }
                emit startAuthentication(user, QStringLiteral("shib=") + _token + pass);
            } else {
                emit triggerReset(QStringLiteral("Invalid user or passhash format"));
            }
        } else if (!err.isEmpty()) {
            this->stop();
            this->setHtml(QString());
            emit triggerReset(err);
        } else {
            _timerReset->start(60000);
        }
    });
}