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>
277 lines
8.0 KiB
Bash
Executable File
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"
|