add hardening, zoom and useragent options

This commit is contained in:
2026-10-17 10:35:14 +02:00
parent ead23646b2
commit 495a0696ba
15 changed files with 724 additions and 70 deletions
+12 -3
View File
@@ -33,8 +33,14 @@ The only tests are the Rust crate's (`nix develop -c sh -c 'cd adblock && cargo
3. Insert mode passes keys through to the page. 3. Insert mode passes keys through to the page.
4. Hint mode swallows every key. 4. Hint mode swallows every key.
5. Normal mode goes to `handleNormalModeKey`. Bindings are `if (text == "x")` chains, and two-key sequences (`gg`) use `m_pendingKeys`. 5. Normal mode goes to `handleNormalModeKey`. Bindings are `if (text == "x")` chains, and two-key sequences (`gg`) use `m_pendingKeys`.
- **Never change the page's size from UI chrome.** Every view resize makes the page re-layout, which on YouTube visibly jumps the player.
- `CompletionView` is not in the layout. It floats over the bottom of the view (`placeCompletion`, re-run on the view's `Resize`).
- `StatusBar` and `CommandLine` share one fixed height.
- Open the command line only through `openCommandLine`, which hides the status bar *before* showing the command line. Showing a widget re-runs the layout at once, so the other order shrinks and regrows the page.
- **`setMode` is where entering and leaving a mode takes effect.** It updates the status line and focus. Leaving `Hint` also runs the hint-clear script. - **`setMode` is where entering and leaving a mode takes effect.** It updates the status line and focus. Leaving `Hint` also runs the hint-clear script.
- **Page interaction goes through `page()->runJavaScript`.** Scrolling, for example, is done with JS calls rather than Qt APIs. - **Page interaction mostly goes through `page()->runJavaScript`**, except for input that should feel native:
- Scrolling (`scrollBy`, used by `j/k/h/l`, `Ctrl-d/u/f/b` and `gg/G`) sends a pixel-delta `QWheelEvent` to `m_view->focusProxy()` at the pointer, which is what a touchpad sends. Chromium then scrolls whatever is under the pointer. `window.scrollBy` only moves the document, so it did nothing on sites that scroll an inner element.
- Hint clicks are real mouse events (see below).
- **Hint mode** (`src/hints.cpp`): - **Hint mode** (`src/hints.cpp`):
- The state lives in JS (`window.__attHints`), injected into `QWebEngineScript::ApplicationWorld`. That world shares the DOM with the page but keeps its JS globals isolated from the page's scripts. - The state lives in JS (`window.__attHints`), injected into `QWebEngineScript::ApplicationWorld`. That world shares the DOM with the page but keeps its JS globals isolated from the page's scripts.
- C++ sends one call per key (`keyScript` / `backspaceScript` / `clearScript`). JS returns a `{state: ...}` map, and `handleHintResult` acts on it. That map is the protocol, and it is documented in `hints.h`. - C++ sends one call per key (`keyScript` / `backspaceScript` / `clearScript`). JS returns a `{state: ...}` map, and `handleHintResult` acts on it. That map is the protocol, and it is documented in `hints.h`.
@@ -51,10 +57,13 @@ The only tests are the Rust crate's (`nix develop -c sh -c 'cd adblock && cargo
- **History** (`History`, `src/history.cpp`) is SQLite through QtSql, run synchronously on the GUI thread with WAL. `visits` is an append-only log; `urls` (one row per URL, upserted) is what completion searches. Visits are recorded on `loadFinished(true)`, not `urlChanged`. URLs are stored as `toDisplayString()` with the fragment removed, so a `titleChanged` after an in-page jump still updates the right row. - **History** (`History`, `src/history.cpp`) is SQLite through QtSql, run synchronously on the GUI thread with WAL. `visits` is an append-only log; `urls` (one row per URL, upserted) is what completion searches. Visits are recorded on `loadFinished(true)`, not `urlChanged`. URLs are stored as `toDisplayString()` with the fragment removed, so a `titleChanged` after an in-page jump still updates the right row.
- **`:open` completion:** `BrowserWindow::updateCompletion` runs on the command line's `textEdited` and on entering command mode. It parses `:open`/`:o` and shows `History::search` results in `CompletionView`, which never takes focus. `CommandLine::event` intercepts Tab/Backtab, because `QWidget::event` would otherwise turn them into focus changes. Picking an entry uses `setCommandText`, which doesn't emit `textEdited`, so it doesn't re-filter. - **`:open` completion:** `BrowserWindow::updateCompletion` runs on the command line's `textEdited` and on entering command mode. It parses `:open`/`:o` and shows `History::search` results in `CompletionView`, which never takes focus. `CommandLine::event` intercepts Tab/Backtab, because `QWidget::event` would otherwise turn them into focus changes. Picking an entry uses `setCommandText`, which doesn't emit `textEdited`, so it doesn't re-filter.
- **Greasemonkey** (`Greasemonkey`, `src/greasemonkey.cpp`, part of the `Session`) inserts each `greasemonkey/*.js` into `profile->scripts()` as a `QWebEngineScript` named `att-greasemonkey:<file>`, in `MainWorld`. - **Greasemonkey** (`Greasemonkey`, `src/greasemonkey.cpp`, part of the `Session`) inserts each `greasemonkey/*.js` into `profile->scripts()` as a `QWebEngineScript` named `att-greasemonkey:<file>`, in `MainWorld`.
- URL matching is left to QtWebEngine, which parses the `==UserScript==` block during `setSourceCode` and checks `@include/@exclude/@match` in Chromium. So the block must stay in the source, and `setInjectionPoint` must come *after* `setSourceCode`. - URL matching is left to QtWebEngine, which parses the `==UserScript==` block during `setSourceCode` and checks `@include/@exclude/@match` in Chromium. So the block must stay in the source. `setSourceCode` also overwrites the script's name (with `@name`) and injection point, so `setName` and `setInjectionPoint` must come *after* it. `install()` finds the old scripts by the `att-greasemonkey:` name prefix to remove them; a lost prefix makes scripts run twice after a reload.
- att's own `parseMetadata` is for run-at, noframes, `@require` (cached by SHA-1 of the URL) and `GM_info`. - att's own `parseMetadata` is for run-at, noframes, `@require` and `GM_info`. A `@require` is cached by the SHA-1 of its URL. It must be `https`, and an optional `#sha256=` pin is checked against the cached file on every install (`requireProblem`).
- Scripts are wrapped in an IIFE (`kWrapperHead`/`kWrapperTail`) that defines the GM API as locals. - Scripts are wrapped in an IIFE (`kWrapperHead`/`kWrapperTail`) that defines the GM API as locals.
- At `document-start`, `document.documentElement` is still null, which the GM helpers must tolerate. - At `document-start`, `document.documentElement` is still null, which the GM helpers must tolerate.
- **Hardening** (`src/hardening.cpp`): `applyToProfile` sets the restrictive `QWebEngineSettings` and the third-party cookie filter. The filter runs on the network thread and must stay a pure function. `main.cpp` applies it right after creating the profile, and warns in the first window if `sandboxDisabledReason` finds the sandbox off. `StatusBar::setUrl` takes a `QUrl` so it can mark plain-HTTP pages (`insecure` property).
- **User agent** (`src/useragent.cpp`): `useragent::apply` sets the profile's UA string *and* its `QWebEngineClientHints`, so the two don't contradict each other. Chromium sends the low-entropy hints (brands, platform, mobile) even with `setAllClientHintsEnabled(false)`, so every preset sets a platform; the brand ("Google Chrome", "Microsoft Edge") goes in through `setFullVersionList`. Chrome-based presets use the engine's own major version. `:useragent` completion reuses `CompletionView` with name / string columns.
- **Settings** live as constants in `src/config.h` (`config::kZoomDefault`, `config::kZoomLevels`, `kBlockThirdPartyCookies`, `kPdfViewer`, `kUserAgent`), named after qutebrowser's settings, until there's a config file. Zoom is per window: `setZoom` / `zoomStep` in `BrowserWindow`, applied before the first load. QtWebEngine keeps a view's zoom across navigations, so it doesn't need re-applying.
- The UI stylesheet is the `kStyleSheet` string in `src/main.cpp`. Widgets are styled by `objectName` and by dynamic properties: `StatusBar` toggles `error` and re-polishes the label to pick up the change. - The UI stylesheet is the `kStyleSheet` string in `src/main.cpp`. Widgets are styled by `objectName` and by dynamic properties: `StatusBar` toggles `error` and re-polishes the label to pick up the change.
**Windows, not tabs.** att deliberately has no tab bar, because the user's window manager does the tabbing. Map qutebrowser's tab features onto top-level windows: `d` closes the window, `O` / `:open -w` (or `-t`) opens a new one. Each `BrowserWindow` is one page, with `WA_DeleteOnClose`. All windows share a `Session` (profile, `AdBlocker`, `History`) that `main()` owns. After `app.exec()`, `main()` deletes any window still alive before the history and profile go away. `:quit` closes all windows; `:close` closes one. **Windows, not tabs.** att deliberately has no tab bar, because the user's window manager does the tabbing. Map qutebrowser's tab features onto top-level windows: `d` closes the window, `O` / `:open -w` (or `-t`) opens a new one. Each `BrowserWindow` is one page, with `WA_DeleteOnClose`. All windows share a `Session` (profile, `AdBlocker`, `History`) that `main()` owns. After `app.exec()`, `main()` deletes any window still alive before the history and profile go away. `:quit` closes all windows; `:close` closes one.
+5
View File
@@ -36,8 +36,11 @@ qt_add_executable(att
src/commandline.h src/commandline.h
src/completionview.cpp src/completionview.cpp
src/completionview.h src/completionview.h
src/config.h
src/greasemonkey.cpp src/greasemonkey.cpp
src/greasemonkey.h src/greasemonkey.h
src/hardening.cpp
src/hardening.h
src/hints.cpp src/hints.cpp
src/hints.h src/hints.h
src/history.cpp src/history.cpp
@@ -46,6 +49,8 @@ qt_add_executable(att
src/statusbar.h src/statusbar.h
src/urlutil.cpp src/urlutil.cpp
src/urlutil.h src/urlutil.h
src/useragent.cpp
src/useragent.h
) )
target_include_directories(att PRIVATE src adblock) target_include_directories(att PRIVATE src adblock)
+72 -4
View File
@@ -69,7 +69,7 @@ is hidden while a page is fullscreen.
| `H` / `L` | back / forward in history | | `H` / `L` | back / forward in history |
| `r` / `R` | reload / reload bypassing cache | | `r` / `R` | reload / reload bypassing cache |
| `y` | yank the current URL to the clipboard | | `y` | yank the current URL to the clipboard |
| `+` `-` `=` | zoom in / out / reset | | `+` `-` `=` | zoom in / out a level, back to default |
The command line keeps a history (`Up`/`Down`) and aborts on `Escape`, `Ctrl-c`, The command line keeps a history (`Up`/`Down`) and aborts on `Escape`, `Ctrl-c`,
or if you delete the leading `:`. or if you delete the leading `:`.
@@ -77,7 +77,8 @@ or if you delete the leading `:`.
While typing `:open` (or `:o`), a completion list of previously visited pages While typing `:open` (or `:o`), a completion list of previously visited pages
appears above it, most recent first. Every word you type must appear in the URL appears above it, most recent first. Every word you type must appear in the URL
or the title. `Tab` / `Shift-Tab` cycle through the list and put the selected URL or the title. `Tab` / `Shift-Tab` cycle through the list and put the selected URL
on the command line; `Enter` opens it. on the command line; `Enter` opens it. `:useragent` completes preset names the
same way.
## Hint mode ## Hint mode
@@ -134,17 +135,82 @@ the page. Like qutebrowser, att:
`GM_setClipboard`, `GM_log`, `GM_notification` and the promise-based `GM.*` `GM_setClipboard`, `GM_log`, `GM_notification` and the promise-based `GM.*`
versions. versions.
`@require` must be `https://`; see *Security* for pinning with `#sha256=`.
Limits: stored values live in the site's `localStorage`, so they are per site Limits: stored values live in the site's `localStorage`, so they are per site
rather than shared across sites. `GM_xmlhttpRequest` is a plain XHR, so it can't rather than shared across sites. `GM_xmlhttpRequest` is a plain XHR, so it can't
make cross-origin requests the server doesn't allow (CORS). `@resource` isn't make cross-origin requests the server doesn't allow (CORS). `@resource` isn't
supported. `GM_openInTab` does nothing until new-window requests are handled. supported. `GM_openInTab` does nothing until new-window requests are handled.
## Security
On top of Chromium's own protections, att:
- blocks third-party cookies, which stops most cross-site tracking. A few embedded
logins (e.g. some comment widgets) may stop working.
- doesn't let local HTML files (`file://`) read other local files. QtWebEngine
allows that by default, so opening a malicious downloaded page could leak e.g.
`~/.ssh`.
- keeps WebRTC from revealing your LAN address, and doesn't let scripts open
pop-up windows.
- pins the safe defaults for mixed content, clipboard access and plugins.
- shows plain `http://` URLs in orange in the status bar.
- only accepts Greasemonkey `@require` files over HTTPS. A `@require` URL may pin
its content as in Tampermonkey (`…#sha256=<hex or base64>` or `…#sha256-<base64>`),
and the script is skipped if the file doesn't match.
- warns at startup if Chromium's sandbox has been disabled
(`QTWEBENGINE_DISABLE_SANDBOX`, `--no-sandbox`).
The engine's security fixes come from QtWebEngine, so keep it current with
`nix flake update`. `:version` shows the Chromium version and the security patch
level it carries.
## User agent
`:useragent <preset>` (alias `:ua`) changes the browser identity that every
window sends; with no name it shows the current one. Requests use the new one at
once, and `navigator.userAgent` from each page's next load. The client hints
(`Sec-CH-UA*`, `navigator.userAgentData`) are set to match the preset's brand
and platform.
| Preset | Claims to be |
| ----------------- | ------------------------------------------------- |
| `chrome-linux` | Chrome on Linux (the default) |
| `chrome-windows` | Chrome on Windows |
| `chrome-mac` | Chrome on macOS |
| `chrome-android` | Chrome on Android (sites may serve mobile pages) |
| `edge-windows` | Edge on Windows |
| `firefox-linux`, `firefox-windows`, `firefox-mac` | Firefox |
| `safari-mac`, `safari-iphone` | Safari |
| `qtwebengine` | QtWebEngine's own string, which names it |
Chrome and Edge claim the engine's real Chromium major version (as qutebrowser
does), so sites don't use features the engine lacks. The Firefox and Safari
versions are pinned in `src/useragent.cpp`. Those presets only change what att
says, not how it behaves: Chromium's low-entropy client hints still name
Chromium, and feature detection still sees Chromium.
## Settings
att has no config file yet. The settings live in `src/config.h`, named after
their qutebrowser equivalents; change them there and rebuild:
| Setting | Default | Meaning |
| -------------- | ------------------------------- | ---------------------------------------- |
| `zoom.default` | 125% | zoom of new windows; `=` returns to it |
| `zoom.levels` | 25%, 33%, 50%, … 125%, 150%, … 500% | the steps `+` / `-` move through |
| `content.cookies.accept` | no third-party | `kBlockThirdPartyCookies` |
| `content.pdfjs` | on | `kPdfViewer`: Chromium's PDF viewer |
| `content.headers.user_agent` | `chrome-linux` | `kUserAgent`: the preset att starts with |
## Commands ## Commands
`:open [-w] <url|query>` (alias `:o`; `-w`, or qutebrowser's `-t`, opens a new `:open [-w] <url|query>` (alias `:o`; `-w`, or qutebrowser's `-t`, opens a new
window), `:close` (alias `:tab-close`, closes this window), `:quit` (alias `:q`, window), `:close` (alias `:tab-close`, closes this window), `:quit` (alias `:q`,
closes all windows), `:reload`, `:back`, `:forward`, `:stop`, `:history-clear`, closes all windows), `:reload`, `:back`, `:forward`, `:stop`, `:history-clear`,
`:greasemonkey-reload`, `:adblock-update`, `:adblock-toggle`. `:greasemonkey-reload`, `:adblock-update`, `:adblock-toggle`, `:zoom [level]`
(`150` or `150%`; no level means the default), `:zoom-in`, `:zoom-out`,
`:useragent [preset]` (alias `:ua`), `:version`.
`:open` accepts a full URL, a bare host (`example.com` → `https://example.com`), `:open` accepts a full URL, a bare host (`example.com` → `https://example.com`),
an existing local path, or free text, which goes to DuckDuckGo. an existing local path, or free text, which goes to DuckDuckGo.
@@ -159,13 +225,15 @@ an existing local path, or free text, which goes to DuckDuckGo.
| `src/browserwindow` | the window, mode state machine, key dispatch, commands | | `src/browserwindow` | the window, mode state machine, key dispatch, commands |
| `src/commandline` | the `:` prompt | | `src/commandline` | the `:` prompt |
| `src/greasemonkey` | userscript loading and the GM_* API wrapper | | `src/greasemonkey` | userscript loading and the GM_* API wrapper |
| `src/hardening` | profile security settings, sandbox check |
| `src/history` | SQLite browsing history | | `src/history` | SQLite browsing history |
| `src/completionview`| the `:open` completion list | | `src/completionview`| the `:open` / `:useragent` completion list |
| `src/statusbar` | mode / URL / progress line | | `src/statusbar` | mode / URL / progress line |
| `src/adblock` | request interceptor around the adblock engine | | `src/adblock` | request interceptor around the adblock engine |
| `adblock/` | Rust crate: C API over adblock-rust, built by Corrosion | | `adblock/` | Rust crate: C API over adblock-rust, built by Corrosion |
| `src/hints` | the injected hint-mode script | | `src/hints` | the injected hint-mode script |
| `src/urlutil` | "what the user typed" → `QUrl` | | `src/urlutil` | "what the user typed" → `QUrl` |
| `src/useragent` | user-agent presets and matching client hints |
Key events are intercepted with an **application-wide event filter** Key events are intercepted with an **application-wide event filter**
(`BrowserWindow::eventFilter`) rather than `QWidget::keyPressEvent`, because (`BrowserWindow::eventFilter`) rather than `QWidget::keyPressEvent`, because
+186 -32
View File
@@ -2,33 +2,40 @@
#include "adblock.h" #include "adblock.h"
#include "commandline.h" #include "commandline.h"
#include "config.h"
#include "completionview.h" #include "completionview.h"
#include "greasemonkey.h" #include "greasemonkey.h"
#include "hints.h" #include "hints.h"
#include "history.h" #include "history.h"
#include "statusbar.h" #include "statusbar.h"
#include "urlutil.h" #include "urlutil.h"
#include "useragent.h"
#include <QApplication> #include <QApplication>
#include <QClipboard> #include <QClipboard>
#include <QCursor>
#include <QEvent> #include <QEvent>
#include <QKeyEvent> #include <QKeyEvent>
#include <QMouseEvent> #include <QMouseEvent>
#include <QRegularExpression> #include <QRegularExpression>
#include <QVBoxLayout> #include <QVBoxLayout>
#include <QWebEngineFullScreenRequest> #include <QWebEngineFullScreenRequest>
#include <QtWebEngineCore/qtwebenginecoreglobal.h>
#include <QWebEnginePage> #include <QWebEnginePage>
#include <QWebEngineProfile> #include <QWebEngineProfile>
#include <QWebEngineScript> #include <QWebEngineScript>
#include <QWebEngineSettings> #include <QWebEngineSettings>
#include <QWebEngineView> #include <QWebEngineView>
#include <QWheelEvent>
#include <QWidget> #include <QWidget>
namespace { namespace {
constexpr int kScrollStep = 60; constexpr int kScrollStep = 60;
constexpr double kZoomStep = 0.1;
constexpr int kCompletionLimit = 100; constexpr int kCompletionLimit = 100;
// What QtWebEngine accepts.
constexpr double kZoomMin = 0.25;
constexpr double kZoomMax = 5.0;
// ":open [-w|-t] <query>" or ":o ..."; captures the prefix, flag included, // ":open [-w|-t] <query>" or ":o ..."; captures the prefix, flag included,
// and the query. // and the query.
@@ -38,6 +45,13 @@ const QRegularExpression &openCommand()
return re; return re;
} }
// ":useragent <preset>" or ":ua ..."; captures the prefix and the name.
const QRegularExpression &userAgentCommand()
{
static const QRegularExpression re(QStringLiteral("^(:(?:useragent|ua) +)(.*)$"));
return re;
}
} // namespace } // namespace
BrowserWindow::BrowserWindow(const Session &session, const QUrl &startUrl, QWidget *parent) BrowserWindow::BrowserWindow(const Session &session, const QUrl &startUrl, QWidget *parent)
@@ -67,6 +81,7 @@ BrowserWindow::BrowserWindow(const Session &session, const QUrl &startUrl, QWidg
qApp->installEventFilter(this); qApp->installEventFilter(this);
setMode(Mode::Normal); setMode(Mode::Normal);
setZoom(config::kZoomDefault, false);
m_view->setUrl(startUrl); m_view->setUrl(startUrl);
focusView(); focusView();
} }
@@ -81,18 +96,29 @@ void BrowserWindow::setupUi()
layout->setContentsMargins(0, 0, 0, 0); layout->setContentsMargins(0, 0, 0, 0);
layout->setSpacing(0); layout->setSpacing(0);
layout->addWidget(m_view, 1); layout->addWidget(m_view, 1);
layout->addWidget(m_completion);
layout->addWidget(m_status); layout->addWidget(m_status);
layout->addWidget(m_commandLine); layout->addWidget(m_commandLine);
setCentralWidget(central); setCentralWidget(central);
// Nothing that comes and goes may change the page's size: every resize
// makes the page re-layout, which on YouTube means the player visibly
// jumps. So the completion floats over the bottom of the page instead of
// taking space in the layout (see placeCompletion), and the status bar and
// the command line, which replace each other, get the same fixed height.
m_completion->setParent(central);
m_status->ensurePolished();
m_commandLine->ensurePolished();
const int barHeight = qMax(m_status->sizeHint().height(), m_commandLine->sizeHint().height());
m_status->setFixedHeight(barHeight);
m_commandLine->setFixedHeight(barHeight);
m_commandLine->hide(); m_commandLine->hide();
} }
void BrowserWindow::connectView() void BrowserWindow::connectView()
{ {
connect(m_view, &QWebEngineView::urlChanged, this, [this](const QUrl &url) { connect(m_view, &QWebEngineView::urlChanged, this, [this](const QUrl &url) {
m_status->setUrl(url.toDisplayString()); m_status->setUrl(url);
// The hints belong to the page that is going away. // The hints belong to the page that is going away.
if (m_mode == Mode::Hint) if (m_mode == Mode::Hint)
setMode(Mode::Normal); setMode(Mode::Normal);
@@ -124,6 +150,11 @@ void BrowserWindow::connectView()
}); });
} }
void BrowserWindow::showMessage(const QString &text, bool isError)
{
m_status->showMessage(text, isError);
}
void BrowserWindow::setMode(Mode mode) void BrowserWindow::setMode(Mode mode)
{ {
if (m_mode == Mode::Hint && mode != Mode::Hint) if (m_mode == Mode::Hint && mode != Mode::Hint)
@@ -181,6 +212,9 @@ void BrowserWindow::updateStatusVisibility()
bool BrowserWindow::eventFilter(QObject *watched, QEvent *event) bool BrowserWindow::eventFilter(QObject *watched, QEvent *event)
{ {
if (watched == m_view && event->type() == QEvent::Resize && m_completion->isVisible())
placeCompletion();
if (event->type() != QEvent::KeyPress || !isAncestorOf(qobject_cast<QWidget *>(watched))) if (event->type() != QEvent::KeyPress || !isAncestorOf(qobject_cast<QWidget *>(watched)))
return QMainWindow::eventFilter(watched, event); return QMainWindow::eventFilter(watched, event);
@@ -256,10 +290,9 @@ bool BrowserWindow::handleNormalModeKey(QKeyEvent *event)
} }
if (sequence == QLatin1String("go") || sequence == QLatin1String("gO")) { if (sequence == QLatin1String("go") || sequence == QLatin1String("gO")) {
// Edit the current URL, to open it here or in a new window. // Edit the current URL, to open it here or in a new window.
m_commandLine->openWith((sequence == QLatin1String("go") ? QStringLiteral(":open ") openCommandLine((sequence == QLatin1String("go") ? QStringLiteral(":open ")
: QStringLiteral(":open -w ")) : QStringLiteral(":open -w "))
+ m_view->url().toString()); + m_view->url().toString());
setMode(Mode::Command);
return true; return true;
} }
return true; // Swallow the unknown sequence rather than leaking it. return true; // Swallow the unknown sequence rather than leaking it.
@@ -271,13 +304,11 @@ bool BrowserWindow::handleNormalModeKey(QKeyEvent *event)
} }
if (text == QLatin1String("o")) { if (text == QLatin1String("o")) {
m_commandLine->openWith(QStringLiteral(":open ")); openCommandLine(QStringLiteral(":open "));
setMode(Mode::Command);
return true; return true;
} }
if (text == QLatin1String("O")) { if (text == QLatin1String("O")) {
m_commandLine->openWith(QStringLiteral(":open -w ")); openCommandLine(QStringLiteral(":open -w "));
setMode(Mode::Command);
return true; return true;
} }
if (text == QLatin1String("d")) { if (text == QLatin1String("d")) {
@@ -285,8 +316,7 @@ bool BrowserWindow::handleNormalModeKey(QKeyEvent *event)
return true; return true;
} }
if (text == QLatin1String(":")) { if (text == QLatin1String(":")) {
m_commandLine->openWith(QStringLiteral(":")); openCommandLine(QStringLiteral(":"));
setMode(Mode::Command);
return true; return true;
} }
if (text == QLatin1String("i")) { if (text == QLatin1String("i")) {
@@ -340,15 +370,15 @@ bool BrowserWindow::handleNormalModeKey(QKeyEvent *event)
return true; return true;
} }
if (text == QLatin1String("+")) { if (text == QLatin1String("+")) {
m_view->setZoomFactor(m_view->zoomFactor() + kZoomStep); zoomStep(1);
return true; return true;
} }
if (text == QLatin1String("-")) { if (text == QLatin1String("-")) {
m_view->setZoomFactor(qMax(0.25, m_view->zoomFactor() - kZoomStep)); zoomStep(-1);
return true; return true;
} }
if (text == QLatin1String("=")) { if (text == QLatin1String("=")) {
m_view->setZoomFactor(1.0); setZoom(config::kZoomDefault);
return true; return true;
} }
@@ -467,6 +497,46 @@ void BrowserWindow::runCommand(const QString &command)
m_view->forward(); m_view->forward();
} else if (name == QLatin1String("stop")) { } else if (name == QLatin1String("stop")) {
m_view->stop(); m_view->stop();
} else if (name == QLatin1String("version")) {
// The Chromium security patch level is what says how far behind
// Chrome's fixes this build is; "nix flake update" moves it forward.
m_status->showMessage(QStringLiteral("att %1, Qt %2, Chromium %3, security patches %4")
.arg(QCoreApplication::applicationVersion(),
QString::fromLatin1(qVersion()),
QString::fromLatin1(qWebEngineChromiumVersion()),
QString::fromLatin1(qWebEngineChromiumSecurityPatchVersion())));
} else if (name == QLatin1String("zoom")) {
if (args.isEmpty()) {
setZoom(config::kZoomDefault);
} else {
// "150" or "150%", like qutebrowser.
QString level = args;
if (level.endsWith(QLatin1Char('%')))
level.chop(1);
bool ok = false;
const double percent = level.toDouble(&ok);
if (ok && percent >= kZoomMin * 100 && percent <= kZoomMax * 100)
setZoom(percent / 100);
else
m_status->showMessage(QStringLiteral("Zoom level must be %1% to %2%: %3")
.arg(kZoomMin * 100).arg(kZoomMax * 100).arg(args),
true);
}
} else if (name == QLatin1String("zoom-in")) {
zoomStep(1);
} else if (name == QLatin1String("zoom-out")) {
zoomStep(-1);
} else if (name == QLatin1String("useragent") || name == QLatin1String("ua")) {
if (args.isEmpty()) {
m_status->showMessage(QStringLiteral("User agent: %1").arg(useragent::current()));
} else if (useragent::apply(m_session.profile, args)) {
// Requests use it at once; navigator.userAgent changes with the
// next load, so say so rather than leave the page inconsistent.
m_status->showMessage(
QStringLiteral("User agent: %1 (applies from the next page load)").arg(args));
} else {
m_status->showMessage(QStringLiteral("Unknown user agent: %1").arg(args), true);
}
} else if (name == QLatin1String("history-clear")) { } else if (name == QLatin1String("history-clear")) {
if (m_session.history->clear()) { if (m_session.history->clear()) {
// Also forget Chromium's own record, which colours visited links. // Also forget Chromium's own record, which colours visited links.
@@ -488,28 +558,63 @@ void BrowserWindow::runCommand(const QString &command)
} }
} }
void BrowserWindow::openCommandLine(const QString &text)
{
// Hide the status bar before the command line appears: showing a widget
// re-runs the layout immediately, and with both bars visible for a moment
// the page would shrink and grow back (two resizes) every time.
m_status->hide();
m_commandLine->openWith(text);
setMode(Mode::Command);
}
void BrowserWindow::updateCompletion() void BrowserWindow::updateCompletion()
{ {
const QRegularExpressionMatch match = openCommand().match(m_commandLine->text()); const QString text = m_commandLine->text();
if (!match.hasMatch()) { if (const QRegularExpressionMatch match = openCommand().match(text); match.hasMatch()) {
const QStringList words = match.captured(2).split(QLatin1Char(' '), Qt::SkipEmptyParts);
m_completion->setEntries(m_session.history->search(words, kCompletionLimit));
} else if (const QRegularExpressionMatch match = userAgentCommand().match(text);
match.hasMatch()) {
// Preset name in the first column, the string it sends in the second.
const QString query = match.captured(2).trimmed();
QList<History::Entry> entries;
for (const useragent::Preset &preset : useragent::presets()) {
if (preset.name.contains(query, Qt::CaseInsensitive))
entries.append({preset.name, preset.userAgent});
}
m_completion->setEntries(entries);
} else {
m_completion->setEntries({}); m_completion->setEntries({});
return; return;
} }
const QStringList words = match.captured(2).split(QLatin1Char(' '), Qt::SkipEmptyParts); placeCompletion();
m_completion->setEntries(m_session.history->search(words, kCompletionLimit)); }
void BrowserWindow::placeCompletion()
{
// Bottom-aligned over the page, just above the command line.
const QRect page = m_view->geometry();
const int height = qMin(m_completion->height(), page.height());
m_completion->setGeometry(page.left(), page.bottom() + 1 - height, page.width(), height);
m_completion->raise();
} }
void BrowserWindow::cycleCompletion(int step) void BrowserWindow::cycleCompletion(int step)
{ {
if (!m_completion->isVisible()) if (!m_completion->isVisible())
return; return;
const QString url = m_completion->cycle(step); const QString choice = m_completion->cycle(step);
if (url.isEmpty()) if (choice.isEmpty())
return; return;
// Keep whichever of ":o " / ":open " the user typed. // Keep the command as the user typed it (":o " or ":open ", ":ua " or
const QRegularExpressionMatch match = openCommand().match(m_commandLine->text()); // ":useragent ").
const QString text = m_commandLine->text();
QRegularExpressionMatch match = userAgentCommand().match(text);
if (!match.hasMatch())
match = openCommand().match(text);
const QString prefix = match.hasMatch() ? match.captured(1) : QStringLiteral(":open "); const QString prefix = match.hasMatch() ? match.captured(1) : QStringLiteral(":open ");
m_commandLine->setCommandText(prefix + url); m_commandLine->setCommandText(prefix + choice);
} }
void BrowserWindow::openUrl(const QString &input, bool newWindow) void BrowserWindow::openUrl(const QString &input, bool newWindow)
@@ -529,21 +634,70 @@ void BrowserWindow::openUrl(const QString &input, bool newWindow)
void BrowserWindow::scrollBy(int dx, int dy) void BrowserWindow::scrollBy(int dx, int dy)
{ {
m_view->page()->runJavaScript( // A pixel-precise wheel event, which is what a touchpad sends, rather than
QStringLiteral("window.scrollBy(%1, %2);").arg(dx).arg(dy)); // window.scrollBy(): Chromium then scrolls whatever is under the pointer,
// the way the touchpad does. window.scrollBy() only ever moves the
// document, so on sites that scroll an inner element instead (most web
// apps) it did nothing at all.
QWidget *target = m_view->focusProxy();
if (!target)
return;
QPointF pos = target->mapFromGlobal(QCursor::pos());
if (!target->rect().contains(pos.toPoint()))
pos = QRectF(target->rect()).center();
// Qt's wheel deltas point the way the content moves, the opposite of the
// scroll direction.
const QPoint pixels(-dx, -dy);
QWheelEvent event(pos, target->mapToGlobal(pos), pixels, pixels * 2, Qt::NoButton,
Qt::NoModifier, Qt::NoScrollPhase, false);
QCoreApplication::sendEvent(target, &event);
} }
void BrowserWindow::scrollPages(double factor) void BrowserWindow::scrollPages(double factor)
{ {
m_view->page()->runJavaScript( scrollBy(0, qRound(m_view->height() * factor));
QStringLiteral("window.scrollBy(0, window.innerHeight * %1);").arg(factor));
} }
void BrowserWindow::scrollToEnd(bool bottom) void BrowserWindow::scrollToEnd(bool bottom)
{ {
m_view->page()->runJavaScript( // Far more than any page is tall; Chromium stops at the end.
bottom ? QStringLiteral("window.scrollTo(0, document.body.scrollHeight);") constexpr int kFar = 10'000'000;
: QStringLiteral("window.scrollTo(0, 0);")); scrollBy(0, bottom ? kFar : -kFar);
}
void BrowserWindow::setZoom(double factor, bool announce)
{
const double zoom = qBound(kZoomMin, factor, kZoomMax);
m_view->setZoomFactor(zoom);
if (announce)
m_status->showMessage(QStringLiteral("Zoom level: %1%").arg(qRound(zoom * 100)));
}
void BrowserWindow::zoomStep(int direction)
{
// The next level strictly beyond the current zoom, so a zoom set with
// ":zoom 130" still steps to 150 / 125. The epsilon absorbs 0.33 vs 1/3.
constexpr double kEpsilon = 0.001;
const double current = m_view->zoomFactor();
const auto &levels = config::kZoomLevels;
if (direction > 0) {
for (double level : levels) {
if (level > current + kEpsilon) {
setZoom(level);
return;
}
}
} else {
for (auto it = levels.rbegin(); it != levels.rend(); ++it) {
if (*it < current - kEpsilon) {
setZoom(*it);
return;
}
}
}
m_status->showMessage(QStringLiteral("Zoom level: %1% (limit)").arg(qRound(current * 100)));
} }
void BrowserWindow::focusView() void BrowserWindow::focusView()
+5
View File
@@ -40,6 +40,7 @@ public:
BrowserWindow(const Session &session, const QUrl &startUrl, QWidget *parent = nullptr); BrowserWindow(const Session &session, const QUrl &startUrl, QWidget *parent = nullptr);
void setMode(Mode mode); void setMode(Mode mode);
void showMessage(const QString &text, bool isError = false);
protected: protected:
// Key handling lives in an application-wide filter because QtWebEngine // Key handling lives in an application-wide filter because QtWebEngine
@@ -59,13 +60,17 @@ private:
void handleHintResult(const QVariantMap &result); void handleHintResult(const QVariantMap &result);
void clickAt(const QPointF &pos); void clickAt(const QPointF &pos);
void runCommand(const QString &command); void runCommand(const QString &command);
void openCommandLine(const QString &text);
void updateCompletion(); void updateCompletion();
void cycleCompletion(int step); void cycleCompletion(int step);
void placeCompletion();
void openUrl(const QString &input, bool newWindow); void openUrl(const QString &input, bool newWindow);
void scrollBy(int dx, int dy); void scrollBy(int dx, int dy);
void scrollPages(double factor); void scrollPages(double factor);
void scrollToEnd(bool bottom); void scrollToEnd(bool bottom);
void focusView(); void focusView();
void setZoom(double factor, bool announce = true);
void zoomStep(int direction);
void setPageFullScreen(bool on); void setPageFullScreen(bool on);
void updateStatusVisibility(); void updateStatusVisibility();
+6 -4
View File
@@ -4,9 +4,10 @@
#include <QTreeWidget> #include <QTreeWidget>
// The list above the command line offering history entries for ":open", with // The list above the command line, like qutebrowser's completion widget: history
// URL and title columns, like qutebrowser's completion widget. It never takes // for ":open" (URL and title columns) or presets for ":useragent" (name and
// focus: keys stay with the CommandLine, which drives it via cycle(). // string). It never takes focus: keys stay with the CommandLine, which drives
// it via cycle().
class CompletionView : public QTreeWidget class CompletionView : public QTreeWidget
{ {
Q_OBJECT Q_OBJECT
@@ -17,6 +18,7 @@ public:
// Shows the entries with nothing selected, or hides the view if empty. // Shows the entries with nothing selected, or hides the view if empty.
void setEntries(const QList<History::Entry> &entries); void setEntries(const QList<History::Entry> &entries);
// Moves the selection step rows (wrapping) and returns the selected URL. // Moves the selection step rows (wrapping) and returns the first
// column of the selected row.
QString cycle(int step); QString cycle(int step);
}; };
+35
View File
@@ -0,0 +1,35 @@
#pragma once
#include <array>
// User-facing settings, named after their qutebrowser equivalents. att has no
// config file yet; until it does, change them here and rebuild.
namespace config {
// zoom.default: the zoom every new window starts at, and what "=" and a bare
// ":zoom" return to. 1.0 is 100%.
inline constexpr double kZoomDefault = 1.25;
// zoom.levels: the steps "+" / "-" (":zoom-in" / ":zoom-out") move through.
// qutebrowser's defaults; QtWebEngine accepts 25% to 500%.
inline constexpr std::array kZoomLevels = {
0.25, 0.33, 0.50, 0.67, 0.75, 0.90, 1.00, 1.10,
1.25, 1.50, 1.75, 2.00, 2.50, 3.00, 4.00, 5.00,
};
// content.cookies.accept = no-3rdparty: refuse cookies a page's third-party
// requests try to set or send. Blocks most cross-site tracking; a few embedded
// logins (e.g. some comment widgets) stop working.
inline constexpr bool kBlockThirdPartyCookies = true;
// content.pdfjs (roughly): Chromium's built-in PDF viewer. PDFium is a classic
// source of exploitable bugs, but att can't download files, so without it a PDF
// can't be opened at all.
inline constexpr bool kPdfViewer = true;
// content.headers.user_agent: the preset (see ":useragent" and useragent.cpp)
// att starts with. Chrome on Linux is the most common UA that matches the
// engine, so sites treat att as the browser it actually is.
inline constexpr auto kUserAgent = "chrome-linux";
} // namespace config
+88 -14
View File
@@ -253,6 +253,64 @@ QWebEngineScript::InjectionPoint injectionPoint(const QString &runAt)
return QWebEngineScript::DocumentReady; // Greasemonkey's default. return QWebEngineScript::DocumentReady; // Greasemonkey's default.
} }
// @require'd code runs with the script's full power on every page it matches,
// so a tampered download is as bad as a malicious script. Only HTTPS is
// allowed (downloads also refuse redirects away from HTTPS), and a URL may pin
// its content in Tampermonkey's format: "#sha256=<hex or base64>", or the SRI
// form "#sha256-<base64>", optionally among other comma/semicolon-separated
// hashes, which are ignored.
struct RequirePin {
QByteArray sha256; // empty: not pinned
QString error; // the pin is malformed
};
RequirePin requirePin(const QUrl &url)
{
static const QRegularExpression part(QStringLiteral("^sha256[=-](.+)$"),
QRegularExpression::CaseInsensitiveOption);
RequirePin pin;
const QStringList parts = url.fragment(QUrl::FullyDecoded)
.split(QRegularExpression(QStringLiteral("[,;]")), Qt::SkipEmptyParts);
for (const QString &p : parts) {
const QRegularExpressionMatch m = part.match(p.trimmed());
if (!m.hasMatch())
continue;
const QByteArray value = m.captured(1).toLatin1();
static const QRegularExpression hex(QStringLiteral("^[0-9a-fA-F]{64}$"));
QByteArray digest;
if (hex.match(QString::fromLatin1(value)).hasMatch()) {
digest = QByteArray::fromHex(value);
} else {
const auto decoded = QByteArray::fromBase64Encoding(
value, QByteArray::Base64Encoding | QByteArray::AbortOnBase64DecodingErrors);
const auto decodedUrl = QByteArray::fromBase64Encoding(
value, QByteArray::Base64UrlEncoding | QByteArray::AbortOnBase64DecodingErrors);
digest = decoded ? *decoded : decodedUrl ? *decodedUrl : QByteArray();
}
if (digest.size() != 32) {
pin.error = QStringLiteral("malformed sha256 pin");
return pin;
}
pin.sha256 = digest;
}
return pin;
}
// Why the @require at url can't be used, or an empty string if it can.
QString requireProblem(const QUrl &url, const QByteArray &content)
{
if (url.scheme() != QLatin1String("https"))
return QStringLiteral("@require is not https: %1").arg(url.toDisplayString());
const RequirePin pin = requirePin(url);
if (!pin.error.isEmpty())
return QStringLiteral("%1 in %2").arg(pin.error, url.toDisplayString());
if (!pin.sha256.isEmpty()
&& QCryptographicHash::hash(content, QCryptographicHash::Sha256) != pin.sha256)
return QStringLiteral("@require does not match its sha256 pin: %1")
.arg(url.toDisplayString(QUrl::RemoveFragment));
return {};
}
} // namespace } // namespace
Greasemonkey::Metadata Greasemonkey::parseMetadata(const QString &source) Greasemonkey::Metadata Greasemonkey::parseMetadata(const QString &source)
@@ -340,7 +398,9 @@ void Greasemonkey::reload(bool announce)
if (script.meta.name.isEmpty()) if (script.meta.name.isEmpty())
script.meta.name = fileName; script.meta.name = fileName;
for (const QString &url : std::as_const(script.meta.requireUrls)) { for (const QString &url : std::as_const(script.meta.requireUrls)) {
if (!QFile::exists(requirePath(url)) && !missing.contains(url)) // install() reports the non-https ones; they are never fetched.
if (QUrl(url).scheme() == QLatin1String("https") && !QFile::exists(requirePath(url))
&& !missing.contains(url))
missing << url; missing << url;
} }
scripts << script; scripts << script;
@@ -356,7 +416,7 @@ void Greasemonkey::reload(bool announce)
auto remaining = std::make_shared<int>(int(missing.size())); auto remaining = std::make_shared<int>(int(missing.size()));
auto failed = std::make_shared<QStringList>(); auto failed = std::make_shared<QStringList>();
for (const QString &url : std::as_const(missing)) { for (const QString &url : std::as_const(missing)) {
QNetworkRequest request{QUrl(url)}; QNetworkRequest request{QUrl(url).adjusted(QUrl::RemoveFragment)};
request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, request.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
QNetworkRequest::NoLessSafeRedirectPolicy); QNetworkRequest::NoLessSafeRedirectPolicy);
QNetworkReply *reply = m_network->get(request); QNetworkReply *reply = m_network->get(request);
@@ -387,28 +447,43 @@ void Greasemonkey::install(const QList<Script> &scripts, const QStringList &fail
QStringList skipped; QStringList skipped;
for (const Script &script : scripts) { for (const Script &script : scripts) {
QString requiredCode; QString requiredCode;
bool complete = true; QString problem;
for (const QString &url : script.meta.requireUrls) { for (const QString &url : script.meta.requireUrls) {
QFile file(requirePath(url)); if (failedRequires.contains(url)) {
if (failedRequires.contains(url) || !file.open(QIODevice::ReadOnly)) { problem = QStringLiteral("@require failed to download: %1").arg(url);
complete = false;
break; break;
} }
requiredCode += QString::fromUtf8(file.readAll()) + QLatin1Char('\n'); QFile file(requirePath(url));
const QByteArray content = file.open(QIODevice::ReadOnly) ? file.readAll() : QByteArray();
problem = requireProblem(QUrl(url), content);
if (problem.isEmpty() && !file.isOpen())
problem = QStringLiteral("@require missing: %1").arg(url);
if (!problem.isEmpty()) {
// Checked on every install, so a cache file changed on disk is
// caught too. Drop a mismatching one so a fixed upstream file
// is fetched again on the next reload.
if (file.isOpen())
file.remove();
break;
}
requiredCode += QString::fromUtf8(content) + QLatin1Char('\n');
} }
// Running a script without the library it needs would just throw. // Running a script without the code it requires would just throw, or
if (!complete) { // worse, run with code that isn't what its author pinned.
skipped << script.meta.name; if (!problem.isEmpty()) {
skipped << QStringLiteral("%1 (%2)").arg(script.meta.name, problem);
continue; continue;
} }
QWebEngineScript web; QWebEngineScript web;
web.setName(QString::fromLatin1(kScriptNamePrefix) + script.fileName);
web.setWorldId(QWebEngineScript::MainWorld); web.setWorldId(QWebEngineScript::MainWorld);
// setSourceCode first: QtWebEngine parses the ==UserScript== block here, // setSourceCode first: QtWebEngine parses the ==UserScript== block here,
// keeping @include/@exclude/@match for Chromium to check per page, and // keeping @include/@exclude/@match for Chromium to check per page, and
// would overwrite an injection point set earlier. // overwrites the name (with @name) and injection point set earlier. The
// name must keep our prefix, or the next reload can't find and remove
// this script and it would run twice.
web.setSourceCode(wrap(script.meta, requiredCode, script.source)); web.setSourceCode(wrap(script.meta, requiredCode, script.source));
web.setName(QString::fromLatin1(kScriptNamePrefix) + script.fileName);
web.setInjectionPoint(injectionPoint(script.meta.runAt)); web.setInjectionPoint(injectionPoint(script.meta.runAt));
web.setRunsOnSubFrames(!script.meta.noFrames); web.setRunsOnSubFrames(!script.meta.noFrames);
collection->insert(web); collection->insert(web);
@@ -416,8 +491,7 @@ void Greasemonkey::install(const QList<Script> &scripts, const QStringList &fail
} }
if (!skipped.isEmpty()) if (!skipped.isEmpty())
emit message(QStringLiteral("Greasemonkey: skipped %1 (an @require failed to download)") emit message(QStringLiteral("Greasemonkey: skipped %1").arg(skipped.join(QLatin1String("; "))),
.arg(skipped.join(QLatin1String(", "))),
true); true);
else if (announce) else if (announce)
emit message(QStringLiteral("Greasemonkey: loaded %1 script(s); reload pages to apply").arg(installed), emit message(QStringLiteral("Greasemonkey: loaded %1 script(s); reload pages to apply").arg(installed),
+52
View File
@@ -0,0 +1,52 @@
#include "hardening.h"
#include "config.h"
#include <QByteArray>
#include <QLatin1String>
#include <QWebEngineCookieStore>
#include <QWebEngineProfile>
#include <QWebEngineSettings>
namespace hardening {
void applyToProfile(QWebEngineProfile *profile)
{
QWebEngineSettings *s = profile->settings();
// On by default: a local HTML file (say, a download opened with
// ":open ~/Downloads/x.html") could read any other local file, ~/.ssh
// included, and send it out.
s->setAttribute(QWebEngineSettings::LocalContentCanAccessFileUrls, false);
// Keep WebRTC from revealing LAN addresses to sites.
s->setAttribute(QWebEngineSettings::WebRTCPublicInterfacesOnly, true);
// No pop-ups from script. (Links with target=_blank are a separate path.)
s->setAttribute(QWebEngineSettings::JavascriptCanOpenWindows, false);
s->setAttribute(QWebEngineSettings::PdfViewerEnabled, config::kPdfViewer);
// Already the defaults; pinned here so a Qt default change can't relax them.
s->setAttribute(QWebEngineSettings::AllowRunningInsecureContent, false);
s->setAttribute(QWebEngineSettings::JavascriptCanAccessClipboard, false);
s->setAttribute(QWebEngineSettings::PluginsEnabled, false);
if (config::kBlockThirdPartyCookies) {
// Runs on the network thread; must stay a pure function of its argument.
profile->cookieStore()->setCookieFilter(
[](const QWebEngineCookieStore::FilterRequest &request) { return !request.thirdParty; });
}
}
QString sandboxDisabledReason(int argc, char **argv)
{
if (!qEnvironmentVariableIsEmpty("QTWEBENGINE_DISABLE_SANDBOX"))
return QStringLiteral("QTWEBENGINE_DISABLE_SANDBOX is set");
if (qgetenv("QTWEBENGINE_CHROMIUM_FLAGS").contains("--no-sandbox"))
return QStringLiteral("QTWEBENGINE_CHROMIUM_FLAGS contains --no-sandbox");
for (int i = 1; i < argc; ++i) {
if (QLatin1String(argv[i]) == QLatin1String("--no-sandbox"))
return QStringLiteral("att was started with --no-sandbox");
}
return {};
}
} // namespace hardening
+18
View File
@@ -0,0 +1,18 @@
#pragma once
#include <QString>
class QWebEngineProfile;
// Security settings QtWebEngine leaves permissive by default. See the README's
// "Security" section for what each one guards against.
namespace hardening {
// Per-profile settings and the third-party cookie filter.
void applyToProfile(QWebEngineProfile *profile);
// Why Chromium's sandbox is off, or an empty string if nothing disables it.
// The sandbox is what keeps a compromised renderer from reaching your files.
QString sandboxDisabledReason(int argc, char **argv);
} // namespace hardening
+15 -1
View File
@@ -1,8 +1,11 @@
#include "adblock.h" #include "adblock.h"
#include "browserwindow.h" #include "browserwindow.h"
#include "config.h"
#include "greasemonkey.h" #include "greasemonkey.h"
#include "hardening.h"
#include "history.h" #include "history.h"
#include "urlutil.h" #include "urlutil.h"
#include "useragent.h"
#include <QApplication> #include <QApplication>
#include <QCommandLineParser> #include <QCommandLineParser>
@@ -25,6 +28,9 @@ constexpr auto kStyleSheet = R"(
QLabel#statusUrl[error="true"] { QLabel#statusUrl[error="true"] {
color: #ff6b6b; color: #ff6b6b;
} }
QLabel#statusUrl[insecure="true"] {
color: #e5a050;
}
QLabel#statusProgress { QLabel#statusProgress {
color: #8a8a8a; color: #8a8a8a;
} }
@@ -73,6 +79,8 @@ int main(int argc, char *argv[])
// Like qutebrowser's content.cookies.store: keep session cookies too, so // Like qutebrowser's content.cookies.store: keep session cookies too, so
// sites that log in with one don't log you out on every restart. // sites that log in with one don't log you out on every restart.
profile->setPersistentCookiesPolicy(QWebEngineProfile::ForcePersistentCookies); profile->setPersistentCookiesPolicy(QWebEngineProfile::ForcePersistentCookies);
hardening::applyToProfile(profile);
useragent::apply(profile, QString::fromLatin1(config::kUserAgent));
// Created after the profile so it is also destroyed after it: the profile // Created after the profile so it is also destroyed after it: the profile
// does not own its interceptor and must not outlive it. // does not own its interceptor and must not outlive it.
@@ -85,7 +93,13 @@ int main(int argc, char *argv[])
const Session session{profile, adBlocker, greasemonkey, &history}; const Session session{profile, adBlocker, greasemonkey, &history};
// Windows delete themselves on close; the app quits with the last one. // Windows delete themselves on close; the app quits with the last one.
(new BrowserWindow(session, startUrl))->show(); auto *firstWindow = new BrowserWindow(session, startUrl);
firstWindow->show();
const QString sandboxOff = hardening::sandboxDisabledReason(argc, argv);
if (!sandboxOff.isEmpty()) {
qWarning("att: Chromium's sandbox is disabled: %s", qPrintable(sandboxOff));
firstWindow->showMessage(QStringLiteral("Sandbox disabled: %1").arg(sandboxOff), true);
}
// After the window exists, so its status bar shows their messages. The // After the window exists, so its status bar shows their messages. The
// scripts are in place before the event loop starts the first page load. // scripts are in place before the event loop starts the first page load.
adBlocker->start(); adBlocker->start();
+16 -11
View File
@@ -34,10 +34,12 @@ void StatusBar::setMode(const QString &mode)
m_mode->setVisible(!mode.isEmpty()); m_mode->setVisible(!mode.isEmpty());
} }
void StatusBar::setUrl(const QString &url) void StatusBar::setUrl(const QUrl &url)
{ {
m_urlText = url; m_urlText = url.toDisplayString();
m_url->setText(url); m_insecure = url.scheme() == QLatin1String("http") || url.scheme() == QLatin1String("ws");
setUrlLabelState(false, m_insecure);
m_url->setText(m_urlText);
} }
void StatusBar::setProgress(int percent) void StatusBar::setProgress(int percent)
@@ -49,20 +51,23 @@ void StatusBar::setProgress(int percent)
void StatusBar::showMessage(const QString &message, bool isError) void StatusBar::showMessage(const QString &message, bool isError)
{ {
m_url->setProperty("error", isError); setUrlLabelState(isError, false);
m_url->setText(message); m_url->setText(message);
// Re-polish so the stylesheet picks up the changed property.
m_url->style()->unpolish(m_url);
m_url->style()->polish(m_url);
QTimer::singleShot(4000, this, &StatusBar::clearMessage); QTimer::singleShot(4000, this, &StatusBar::clearMessage);
} }
void StatusBar::clearMessage() void StatusBar::clearMessage()
{ {
m_url->setProperty("error", false); setUrlLabelState(false, m_insecure);
m_url->style()->unpolish(m_url);
m_url->style()->polish(m_url);
m_url->setText(m_urlText); m_url->setText(m_urlText);
} }
void StatusBar::setUrlLabelState(bool error, bool insecure)
{
m_url->setProperty("error", error);
m_url->setProperty("insecure", insecure);
// Re-polish so the stylesheet picks up the changed properties.
m_url->style()->unpolish(m_url);
m_url->style()->polish(m_url);
}
+7 -1
View File
@@ -1,5 +1,6 @@
#pragma once #pragma once
#include <QUrl>
#include <QWidget> #include <QWidget>
class QLabel; class QLabel;
@@ -14,15 +15,20 @@ public:
explicit StatusBar(QWidget *parent = nullptr); explicit StatusBar(QWidget *parent = nullptr);
void setMode(const QString &mode); void setMode(const QString &mode);
void setUrl(const QString &url); // Plain-HTTP URLs are shown in a warning colour: anyone on the network
// can read and change those pages.
void setUrl(const QUrl &url);
void setProgress(int percent); void setProgress(int percent);
// Transient feedback: errors, unknown commands, yanked URLs. // Transient feedback: errors, unknown commands, yanked URLs.
void showMessage(const QString &message, bool isError = false); void showMessage(const QString &message, bool isError = false);
void clearMessage(); void clearMessage();
private: private:
void setUrlLabelState(bool error, bool insecure);
QLabel *m_mode; QLabel *m_mode;
QLabel *m_url; QLabel *m_url;
QLabel *m_progress; QLabel *m_progress;
QString m_urlText; QString m_urlText;
bool m_insecure = false;
}; };
+179
View File
@@ -0,0 +1,179 @@
#include "useragent.h"
#include <QWebEngineClientHints>
#include <QWebEngineProfile>
#include <QtWebEngineCore/qtwebenginecoreglobal.h>
namespace useragent {
namespace {
// Gecko and WebKit versions can't come from the engine, so they are pinned
// here; bump them now and then so att doesn't look like an outdated browser.
constexpr auto kFirefoxVersion = "155.0";
constexpr auto kSafariVersion = "27.0";
// Chrome-based presets claim the engine's own Chromium major version, as
// qutebrowser does: a newer claim would make sites use features the engine
// lacks. Chrome's reduced UA string zeroes the minor parts.
QString chromeVersion()
{
return QString::fromLatin1(qWebEngineChromiumVersion()).section(QLatin1Char('.'), 0, 0)
+ QStringLiteral(".0.0.0");
}
// What Sec-CH-UA-* (and navigator.userAgentData) should say, so they don't
// contradict the User-Agent string. Chromium always sends the low-entropy ones
// (brands, platform, mobile), even with client hints disabled, so every preset
// sets a matching platform. Firefox and Safari send no hints at all, so for
// them the high-entropy ones are disabled.
struct Hints {
bool enabled;
QString brand; // "Google Chrome" etc., next to Chromium's own brands
QString platform;
QString platformVersion;
QString arch;
QString bitness;
bool mobile;
};
// The full version list for a brand: Chrome sends its own brand next to
// Chromium's (and a GREASE "Not A Brand"); an empty brand gives the engine's.
QVariantMap brands(const QString &brand)
{
const QString full = QString::fromLatin1(qWebEngineChromiumVersion());
QVariantMap list{{QStringLiteral("Chromium"), full},
{QStringLiteral("Not=A?Brand"), QStringLiteral("24.0.0.0")}};
if (!brand.isEmpty())
list.insert(brand, full);
return list;
}
struct Entry {
Preset preset;
Hints hints;
};
QList<Entry> buildEntries()
{
const QString chrome = chromeVersion();
const QString firefox = QString::fromLatin1(kFirefoxVersion);
const QString safari = QString::fromLatin1(kSafariVersion);
const auto chromeOn = [&](const QString &platform) {
return QStringLiteral("Mozilla/5.0 (%1) AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/%2 Safari/537.36")
.arg(platform, chrome);
};
const auto firefoxOn = [&](const QString &platform) {
return QStringLiteral("Mozilla/5.0 (%1; rv:%2) Gecko/20100101 Firefox/%2")
.arg(platform, firefox);
};
return {
{{QStringLiteral("chrome-linux"), chromeOn(QStringLiteral("X11; Linux x86_64"))},
{true, QStringLiteral("Google Chrome"), QStringLiteral("Linux"), QString(),
QStringLiteral("x86"), QStringLiteral("64"), false}},
{{QStringLiteral("chrome-windows"),
chromeOn(QStringLiteral("Windows NT 10.0; Win64; x64"))},
{true, QStringLiteral("Google Chrome"), QStringLiteral("Windows"),
QStringLiteral("19.0.0"), QStringLiteral("x86"), QStringLiteral("64"), false}},
{{QStringLiteral("chrome-mac"),
chromeOn(QStringLiteral("Macintosh; Intel Mac OS X 10_15_7"))},
{true, QStringLiteral("Google Chrome"), QStringLiteral("macOS"),
QStringLiteral("15.0.0"), QStringLiteral("arm"), QStringLiteral("64"), false}},
{{QStringLiteral("chrome-android"),
QStringLiteral("Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 "
"(KHTML, like Gecko) "
"Chrome/%1 Mobile Safari/537.36")
.arg(chrome)},
{true, QStringLiteral("Google Chrome"), QStringLiteral("Android"),
QStringLiteral("15.0.0"), QString(), QString(), true}},
{{QStringLiteral("edge-windows"),
chromeOn(QStringLiteral("Windows NT 10.0; Win64; x64"))
+ QStringLiteral(" Edg/%1").arg(chrome)},
{true, QStringLiteral("Microsoft Edge"), QStringLiteral("Windows"),
QStringLiteral("19.0.0"), QStringLiteral("x86"), QStringLiteral("64"), false}},
{{QStringLiteral("firefox-linux"), firefoxOn(QStringLiteral("X11; Linux x86_64"))},
{false, {}, QStringLiteral("Linux"), {}, {}, {}, false}},
{{QStringLiteral("firefox-windows"),
firefoxOn(QStringLiteral("Windows NT 10.0; Win64; x64"))},
{false, {}, QStringLiteral("Windows"), {}, {}, {}, false}},
{{QStringLiteral("firefox-mac"),
firefoxOn(QStringLiteral("Macintosh; Intel Mac OS X 10.15"))},
{false, {}, QStringLiteral("macOS"), {}, {}, {}, false}},
{{QStringLiteral("safari-mac"),
QStringLiteral("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/605.1.15 "
"(KHTML, like Gecko) Version/%1 Safari/605.1.15")
.arg(safari)},
{false, {}, QStringLiteral("macOS"), {}, {}, {}, false}},
{{QStringLiteral("safari-iphone"),
QStringLiteral("Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) "
"AppleWebKit/605.1.15 "
"(KHTML, like Gecko) Version/%1 Mobile/15E148 Safari/604.1")
.arg(safari)},
{false, {}, QStringLiteral("iOS"), {}, {}, {}, true}},
// QtWebEngine's own string, which names it and gets some sites to
// refuse or degrade service.
{{QStringLiteral("qtwebengine"),
QStringLiteral("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) "
"QtWebEngine/%1 Chrome/%2 Safari/537.36")
.arg(QString::fromLatin1(qVersion()),
QString::fromLatin1(qWebEngineChromiumVersion()))},
{true, {}, QStringLiteral("Linux"), QString(), QStringLiteral("x86"),
QStringLiteral("64"), false}},
};
}
const QList<Entry> &entries()
{
static const QList<Entry> all = buildEntries();
return all;
}
QString &currentName()
{
static QString name;
return name;
}
} // namespace
const QList<Preset> &presets()
{
static const QList<Preset> all = [] {
QList<Preset> list;
for (const Entry &entry : entries())
list.append(entry.preset);
return list;
}();
return all;
}
bool apply(QWebEngineProfile *profile, const QString &name)
{
for (const Entry &entry : entries()) {
if (entry.preset.name != name)
continue;
profile->setHttpUserAgent(entry.preset.userAgent);
QWebEngineClientHints *hints = profile->clientHints();
hints->setAllClientHintsEnabled(entry.hints.enabled);
hints->setPlatform(entry.hints.platform);
hints->setPlatformVersion(entry.hints.platformVersion);
hints->setArch(entry.hints.arch);
hints->setBitness(entry.hints.bitness);
hints->setIsMobile(entry.hints.mobile);
hints->setFullVersionList(brands(entry.hints.brand));
currentName() = name;
return true;
}
return false;
}
QString current()
{
return currentName();
}
} // namespace useragent
+28
View File
@@ -0,0 +1,28 @@
#pragma once
#include <QList>
#include <QString>
class QWebEngineProfile;
// Named User-Agent presets for ":useragent", like a picker over qutebrowser's
// content.headers.user_agent. The choice is profile-wide, so it applies to
// every window, from each page's next load on.
namespace useragent {
struct Preset {
QString name; // what ":useragent" takes, e.g. "chrome-linux"
QString userAgent; // the header and navigator.userAgent
};
// Every preset, in the order completion shows them.
const QList<Preset> &presets();
// Switches the profile to the named preset. Returns false, changing nothing,
// for an unknown name.
bool apply(QWebEngineProfile *profile, const QString &name);
// The name last applied, or empty before the first apply().
QString current();
} // namespace useragent