#!/bin/bash # A QML Text element with no textFormat uses Text.AutoText. Qt then runs # mightBeRichText() over the string and promotes it to Text.RichText when it # looks like markup, and RichText fetches through # QQuickPixmap. Any string that reaches such an element from outside the shell # — a notification summary, an MPRIS track title, a window title, an SSID, a # Bluetooth device name, clipboard content, a weather API response — can # therefore make the shell issue an unauthenticated outbound GET with no user # interaction. # # The promotion needs only that the attacker contribute the first `<` in the # string, on the first line. A fixed label in front of the value does not # protect it, and neither does .toUpperCase(), because the parser lowercases # the tag before looking it up. # # So require an explicit textFormat on every Text whose text: binding is not a # bare string literal. A literal carries no external data, so AutoText has # nothing to promote; this test is what catches the edit that later turns such # a literal into an expression. # # The scan itself lives in qml-text-format-scan.py. It is run twice: over the # real tree, and over the fixtures below, which are the forms that have already # slipped past it once. A guard nothing can fail is a guard nobody should trust, # and every one of those fixtures passed silently before it was written down. set -euo pipefail source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" require_command python3 SCAN="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/qml-text-format-scan.py" violations=$(python3 "$SCAN" "$ROOT") if [[ -n $violations ]]; then count=$(printf '%s\n' "$violations" | wc -l) fail "every Text with a dynamic text binding declares textFormat" \ "$violations $count Text element(s) rely on Text.AutoText for a non-literal binding. Add an explicit textFormat. Text.PlainText is right for anything that renders data from outside the shell; use Text.StyledText only where markup is a deliberate, documented feature, and strip before it reaches the renderer." fi pass "every Text with a dynamic text binding declares textFormat" # The scanner's own tests. Each fixture is a Text that renders external data # with no textFormat, written in a form that once passed. `caught` asserts the # scan reports something; `clean` asserts it does not, so the fixtures prove the # scanner can fail rather than that it fails at everything. fixture_root=$(mktemp -d) trap 'chmod -R u+rwX "$fixture_root" 2>/dev/null; rm -rf "$fixture_root"' EXIT function scan_fixture { local name=$1 local dir="$fixture_root/$name" mkdir -p "$dir/shell/Ui" cat > "$dir/shell/Ui/Fixture.qml" python3 "$SCAN" "$dir" 2>&1 } function caught { local name=$1 description=$2 output output=$(scan_fixture "$name" || true) if [[ -z $output ]]; then fail "$description" "the scan reported nothing for fixture $name" fi pass "$description" } function clean { local name=$1 description=$2 output output=$(scan_fixture "$name" || true) if [[ -n $output ]]; then fail "$description" "the scan reported: $output" fi pass "$description" } caught plain "the scan reports a plain dynamic binding with no textFormat" <<'QML' import QtQuick Item { property string external: "x" Text { text: external } } QML clean literal "the scan leaves a string literal alone" <<'QML' import QtQuick Item { Text { text: "a literal" } } QML clean declared "the scan leaves a declared textFormat alone" <<'QML' import QtQuick Item { property string external: "x" Text { textFormat: Text.PlainText text: external } } QML # strip_noise() knew `//` and not `/* */`, so a block comment between the type # name and its brace hid the whole element from every rule. caught block-comment "the scan reads a Text whose brace a block comment hides" <<'QML' import QtQuick Item { property string external: "x" Text /* explanation */ { text: external } } QML caught block-comment-multiline "the scan reads past a block comment spanning lines" <<'QML' import QtQuick Item { property string external: "x" /* * Text { text: "not this one" } */ Text { text: external } } QML # `import QtQuick as QQ` makes the element `QQ.Text`, which compared unequal to # `Text` and was skipped outright. caught namespaced "the scan reads a Text reached through a namespaced import" <<'QML' import QtQuick as QQ QQ.Item { property string external: "x" QQ.Text { text: external } } QML # textFormat was matched as a substring, so any property whose name merely # started that way exempted the element. caught namespaced-inline "the scan reads a one-line namespaced Text block" <<'QML' import QtQuick as QQ QQ.Item { property string external: "x" QQ.Text { text: external } } QML caught namespaced-unscannable "the scan rejects an unreadable namespaced Text block" <<'QML' import QtQuick as QQ QQ.Item { property string external: "x" QQ.Text { text: external color: "red" } } QML caught textformat-substring "the scan does not accept a lookalike property as textFormat" <<'QML' import QtQuick Item { property string external: "x" property bool textFormatEnabled: true Text { text: external; visible: textFormatEnabled } } QML # A component root takes its text from every caller, so the file it lives in # never binds it. The one-line form was covered; this one was not. caught component-next-line "the scan reads a component root whose Text sits on the next line" <<'QML' import QtQuick Item { component Info: Text { } } QML caught component-one-line "the scan reads a component root written on one line" <<'QML' import QtQuick Item { component Info: Text { color: "red" } } QML # Forms the scanner cannot read are reported rather than passed, which is the # whole reason it can be a line scanner at all. caught brace-next-line "the scan rejects a Text whose opening brace is on the next line" <<'QML' import QtQuick Item { property string external: "x" Text { text: external } } QML caught trailing-binding "the scan rejects a Text with a binding after the opening brace" <<'QML' import QtQuick Item { property string external: "x" Text { text: external color: "red" } } QML # A wrapped binding is judged whole: a literal first line says nothing about # what is concatenated onto it below. caught wrapped-binding "the scan follows a wrapped binding past its literal first line" <<'QML' import QtQuick Item { property string external: "x" Text { text: "prefix" + external } } QML clean wrapped-literals "the scan leaves a wrapped concatenation of literals alone" <<'QML' import QtQuick Item { Text { text: "one" + "two" } } QML # A nested child's textFormat says nothing about its parent. caught nested-child "the scan does not let a nested child's textFormat cover its parent" <<'QML' import QtQuick Text { text: external.value Text { textFormat: Text.PlainText text: "literal" } } QML # A scan that reads less than the tree holds must not report success. Both of # these once did. empty_root=$(mktemp -d) mkdir -p "$empty_root/shell" if python3 "$SCAN" "$empty_root" > /dev/null 2>&1; then rm -rf "$empty_root" fail "the scan fails when it reads no files" "an empty shell/ tree exited 0" fi rm -rf "$empty_root" pass "the scan fails when it reads no files" blind_root="$fixture_root/blind" mkdir -p "$blind_root/shell/Ui/locked" printf 'import QtQuick\nItem {\n Text {\n textFormat: Text.PlainText\n text: "ok"\n }\n}\n' > "$blind_root/shell/Ui/Good.qml" printf 'import QtQuick\nItem {\n property string external: "x"\n Text {\n text: external\n }\n}\n' > "$blind_root/shell/Ui/locked/Bad.qml" chmod 000 "$blind_root/shell/Ui/locked" if python3 "$SCAN" "$blind_root" > /dev/null 2>&1; then chmod 755 "$blind_root/shell/Ui/locked" fail "the scan fails when a directory hides files from it" "an unreadable subdirectory exited 0" fi chmod 755 "$blind_root/shell/Ui/locked" pass "the scan fails when a directory hides files from it"