#!/usr/bin/env node
// Vault values are attacker-controlled text, and Qt renders text as HTML the
// moment it looks like markup. These tests pin both halves of the defence:
// the neutralizer used for the shared kit controls, and the `textFormat`
// every Text in the plugin's own QML must declare.
//
// node tests/rich-text.test.js
const fs = require("fs")
const path = require("path")
const Model = {}
new Function("exports", fs.readFileSync(path.join(__dirname, "..", "BitwardenModel.js"), "utf8")
.replace(/^\.pragma library\s*$/m, "") + `
exports.plainLabel = plainLabel
exports.clipLabel = clipLabel
`)(Model)
let pass = 0
const failures = []
const check = (l, ok, d) => ok ? pass++ : failures.push(`${l}\n ${d}`)
// --- plainLabel ---
// Ordinary names cannot trip Qt's sniffer, so they must survive byte for byte:
// this runs on labels a user reads next to their credentials.
for (const name of ["Work", "Personal Vault", "e-mail (old)", "日本語", "", "a > b"]) {
check(`plainLabel leaves ${JSON.stringify(name)} untouched`,
Model.plainLabel(name) === name, JSON.stringify(Model.plainLabel(name)))
}
check("plainLabel maps null and undefined to an empty label",
Model.plainLabel(null) === "" && Model.plainLabel(undefined) === "",
JSON.stringify([Model.plainLabel(null), Model.plainLabel(undefined)]))
// Nothing that reaches the control may still read as a tag.
const markup = Model.plainLabel("
")
check("plainLabel escapes a tag out of existence",
markup === '<img src=x onerror=alert(1)>', markup)
check("plainLabel escapes bold markup",
Model.plainLabel("Work").indexOf("") < 0, Model.plainLabel("Work"))
// Escaping alone is not enough: without the wrapper Qt may decide the escaped
// string is plain text and show the entities raw. The wrapper forces the
// rich-text path so "&" survives as "&".
const amp = Model.plainLabel("AT&T ")
check("plainLabel escapes ampersands and forces the rich-text path",
amp === 'AT&T <holdings>', amp)
check("plainLabel neutralizes a value that is already entity-encoded",
Model.plainLabel("<script>") === '<script>',
Model.plainLabel("<script>"))
check("plainLabel is idempotent in the sense that re-running it cannot inject",
Model.plainLabel(Model.plainLabel("x")).indexOf("") < 0,
Model.plainLabel(Model.plainLabel("x")))
// --- the QML side ---
// Text defaults to Text.AutoText. Vault names, usernames, URIs, notes and Send
// names all land in one of these, so every one of them has to say otherwise --
// including the ones that only render a constant today.
for (const file of ["Panel.qml", "SshAgentSettings.qml", "SshApprovalScreen.qml", "FormPickerRow.qml", "StatusNotice.qml", "DetailField.qml", "WheelScroll.qml"]) {
const src = fs.readFileSync(path.join(__dirname, "..", file), "utf8").split("\n")
const bare = []
src.forEach((line, i) => {
if (!/(? fs.readFileSync(path.join(__dirname, "..", file), "utf8"))
.join("\n")
for (const binding of ["formFolderLabel()", "formOrgLabel()", "Model.clipLabel(value, 20)",
'name + " filter (" + shortcut + "): " + value']) {
const line = panel.split("\n").find(l => l.includes(binding) && /^\s*(text|tooltipText):/.test(l))
check(`the button label built from ${binding} goes through plainLabel`,
Boolean(line) && line.includes("Model.plainLabel("), String(line))
}
// Order matters, and only one order is safe. plainLabel may return a
// wrapper, so clipping its output could cut a tag in half and hand the control
// the markup the wrapper exists to prevent. Clip the raw value, then neutralize.
const clipLine = panel.split("\n").find(l => l.includes("Model.clipLabel("))
check("the vault value is clipped before it is neutralized, never after",
Boolean(clipLine)
&& clipLine.indexOf("Model.plainLabel(") >= 0
&& clipLine.indexOf("Model.plainLabel(") < clipLine.indexOf("Model.clipLabel(")
&& !/Model\.clipLabel\(\s*Model\.plainLabel\(/.test(clipLine),
String(clipLine))
check("the suggestion tooltip neutralizes the window title it quotes",
/tooltipText: Model\.plainLabel\(\(pinned/.test(panel), "expected Model.plainLabel around the tooltip")
// --- clipping vault text to a width the panel can hold ---
// Ui.Button has no elide, so a folder name decides how wide a button is. The
// clip is what keeps that decision ours; the ellipsis lives inside the budget,
// so `max` is a real ceiling and not a suggestion.
check("a value already within the budget is returned untouched",
Model.clipLabel("Work", 20) === "Work", Model.clipLabel("Work", 20))
check("a value exactly at the budget is not clipped",
Model.clipLabel("12345678901234567890", 20) === "12345678901234567890",
Model.clipLabel("12345678901234567890", 20))
check("a longer value is cut to the budget, ellipsis included",
Model.clipLabel("123456789012345678901", 20) === "12345678901234567...",
Model.clipLabel("123456789012345678901", 20))
for (const [value, max] of [["Client Projects 2026", 20], ["x".repeat(400), 20],
["short", 4], ["abc", 2], ["abcd", 3]]) {
check(`clipLabel(${JSON.stringify(value).slice(0, 24)}, ${max}) never exceeds its budget`,
Model.clipLabel(value, max).length <= max, Model.clipLabel(value, max))
}
check("a missing or unusable value clips to the empty string, never to \"null\"",
Model.clipLabel(null, 20) === "" && Model.clipLabel(undefined, 20) === "",
JSON.stringify([Model.clipLabel(null, 20), Model.clipLabel(undefined, 20)]))
check("a nonsense budget still returns something drawable",
Model.clipLabel("Work", 0).length > 0 && Model.clipLabel("Work", -5).length > 0,
JSON.stringify([Model.clipLabel("Work", 0), Model.clipLabel("Work", -5)]))
// The clip runs on raw vault text, so it must not be what introduces markup.
check("clipping cannot manufacture markup that plainLabel then has to catch",
Model.plainLabel(Model.clipLabel("
", 20)).indexOf("
", 20)))
console.log(`${pass} passed, ${failures.length} failed`)
if (failures.length) { console.error("\nFAILURES:\n " + failures.join("\n ")); process.exit(1) }