Files
omarchycn/test/shell.d/qml-text-format-test.sh
T
4be440b501 Close six ways the textFormat scan reported success without checking
Each of these is a Text rendering external data with no textFormat, written in
a form that passed silently. None exists in this tree, so they were holes in
the guard rather than live exposures — but a guard is only worth what it
catches, and every one of them is a single line someone could plausibly write.

  Text /* why */ {          strip_noise knew // and not /* */, so a block
                            comment between the type name and its brace hid
                            the element from every rule at once
  QQ.Text { ... }           a namespaced import made the name compare unequal
                            to `Text`, and the element was skipped outright
  visible: textFormatEnabled  textFormat was matched as a substring, so a
                            lookalike property exempted the whole block
  component Info:           a component root with its Text on the next line;
    Text {                  the one-line form was covered and this was not
  an unreadable subdirectory  rglob() swallows a directory it cannot enter, so
                            a locked subtree scanned as though it were empty

The scan moves out of the heredoc into qml-text-format-scan.py, taking its root
as an argument, because nothing could run it over anything but the real tree —
and a scanner whose only input always passes cannot be shown to fail. The test
now runs it over nineteen fixtures, one per form above and one per form the
scan already handled, so a later edit that loosens it fails here instead of
going unnoticed until something renders a remote image.

Two limits stay open and are written down in the module docstring rather than
papered over: text assigned from elsewhere (a Binding element, PropertyChanges,
an onCompleted assignment, a property alias onto a child) is invisible to a
scanner that reads each element's own declaration, and a regex literal holding
a brace throws off the brace depth. Neither shape exists in this tree and both
need a QML parser, not another regex.

Co-Authored-By: Codex XHigh <noreply@openai.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 17:28:13 +02:00

277 lines
8.0 KiB
Bash
Executable File

#!/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 <img src="http://..."> 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 <img> 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"