- hypr/apps.lua - hypr/autostart.lua - hypr/envs.lua - hypr/hyprland.lua - hypr/hyprsunset.conf - hypr/input.lua - hypr/looknfeel.lua - hypr/omasettings.lua - hypr/xdph.conf - omarchy/branding/about.txt - omarchy/branding/screensaver.txt - omarchy/extensions/omarchy-menu.jsonc - omarchy/hooks/battery-low.d/play-warning-sound.sample - omarchy/hooks/font-set.d/show-font-notification.sample - omarchy/hooks/post-boot.d/weather.sample - omarchy/hooks/post-update.d/install-voxtype.hook - omarchy/hooks/post-update.d/setup-agent.hook - omarchy/hooks/post-update.d/setup-fingerprint.hook - omarchy/hooks/post-update.d/show-update-notification.sample - omarchy/hooks/pre-refresh-pacman.d/add-custom-repo.sample - omarchy/hooks/theme-set.d/show-theme-notification.sample - omarchy/shell.json - omarchy/shell.toml - omarchy/theme.name - omarchy/themes/azure-glow/README.md - omarchy/themes/azure-glow/alacritty.toml - omarchy/themes/azure-glow/btop.theme - omarchy/themes/azure-glow/hyprland.conf - omarchy/themes/azure-glow/hyprlock.conf - omarchy/themes/azure-glow/icons.theme - … 269 more
494 lines
25 KiB
JavaScript
494 lines
25 KiB
JavaScript
#!/usr/bin/env node
|
|
// Attachment metadata rides along with `bw list items`, so the panel lists an
|
|
// item's files without a second CLI call; only the bytes are fetched, and only
|
|
// on demand. Two things are worth pinning down here: that the metadata really
|
|
// does survive both parse paths, and that a file name out of the vault -- which
|
|
// is attacker-controlled text about to become part of a path we create --
|
|
// cannot escape the download directory.
|
|
//
|
|
// node tests/attachments.test.js
|
|
|
|
const fs = require("fs")
|
|
const path = require("path")
|
|
const panelSrc = fs.readFileSync(path.join(__dirname, "..", "Panel.qml"), "utf8")
|
|
const bodyOf = (name) => {
|
|
const start = panelSrc.indexOf(`function ${name}(`)
|
|
if (start === -1) return ""
|
|
let depth = 0
|
|
for (let i = panelSrc.indexOf("{", start); i < panelSrc.length; i++) {
|
|
if (panelSrc[i] === "{") depth++
|
|
else if (panelSrc[i] === "}" && --depth === 0) return panelSrc.slice(start, i + 1)
|
|
}
|
|
return ""
|
|
}
|
|
const Model = {}
|
|
new Function("exports", fs.readFileSync(path.join(__dirname, "..", "BitwardenModel.js"), "utf8")
|
|
.replace(/^\.pragma library\s*$/m, "") + `
|
|
exports.parseItems = parseItems
|
|
exports.parseItemDetail = parseItemDetail
|
|
exports.itemDetailFromObject = itemDetailFromObject
|
|
exports.parseAttachments = parseAttachments
|
|
exports.formatAttachmentSize = formatAttachmentSize
|
|
exports.safeAttachmentFileName = safeAttachmentFileName
|
|
exports.attachmentDownloadCommand = attachmentDownloadCommand
|
|
exports.editItemCommand = editItemCommand
|
|
exports.deleteSendCommand = deleteSendCommand
|
|
exports.deleteItemCommand = deleteItemCommand
|
|
exports.getTotpCommand = getTotpCommand
|
|
exports.getItemCommand = getItemCommand
|
|
exports.parentDirectory = parentDirectory
|
|
exports.baseName = baseName
|
|
exports.filterItems = filterItems
|
|
exports.findContextualMatches = findContextualMatches
|
|
exports.itemDomains = itemDomains
|
|
`)(Model)
|
|
|
|
let pass = 0
|
|
const failures = []
|
|
const check = (l, ok, d) => ok ? pass++ : failures.push(`${l}\n ${d}`)
|
|
|
|
const withFiles = {
|
|
object: "item", id: "11111111-1111-1111-1111-111111111111",
|
|
organizationId: null, folderId: null, type: 2, name: "Recovery Codes",
|
|
notes: "", favorite: false, secureNote: { type: 0 },
|
|
attachments: [
|
|
{ object: "attachment", id: "a1", fileName: "codes.txt", size: "412", sizeName: "412 B" },
|
|
{ object: "attachment", id: "a2", fileName: "key.pem", size: "3204", sizeName: "3.13 KB" }
|
|
]
|
|
}
|
|
|
|
const withoutFiles = {
|
|
object: "item", id: "22222222-2222-2222-2222-222222222222",
|
|
type: 1, name: "GitHub", notes: "", favorite: false,
|
|
login: { username: "octocat", password: "s3cr3t", uris: [] }
|
|
}
|
|
|
|
// --- the metadata survives every path an item can arrive by ------------------
|
|
|
|
const detail = Model.itemDetailFromObject(withFiles)
|
|
check("the detail view sees both attachments",
|
|
detail.hasAttachments && detail.attachments.length === 2,
|
|
JSON.stringify(detail.attachments))
|
|
check("with the name and size bw reported",
|
|
detail.attachments[0].fileName === "codes.txt" && detail.attachments[0].sizeName === "412 B",
|
|
JSON.stringify(detail.attachments[0]))
|
|
|
|
check("the list-built detail matches the get-item-built detail",
|
|
JSON.stringify(Model.itemDetailFromObject(withFiles))
|
|
=== JSON.stringify(Model.parseItemDetail(JSON.stringify(withFiles))),
|
|
JSON.stringify(Model.itemDetailFromObject(withFiles).attachments))
|
|
|
|
const listed = Model.parseItems(JSON.stringify([withFiles, withoutFiles]))
|
|
const listedWith = listed.find(i => i.id === withFiles.id)
|
|
const listedWithout = listed.find(i => i.id === withoutFiles.id)
|
|
check("the list row knows the item has files, which is what draws the paperclip",
|
|
listedWith.hasAttachments === true, String(listedWith.hasAttachments))
|
|
check("and knows when it has none",
|
|
listedWithout.hasAttachments === false && listedWithout.attachments.length === 0,
|
|
JSON.stringify(listedWithout.attachments))
|
|
check("opening a listed item still yields its attachments without a second call",
|
|
Model.itemDetailFromObject(listedWith.rawObject).attachments.length === 2,
|
|
JSON.stringify(Model.itemDetailFromObject(listedWith.rawObject).attachments))
|
|
|
|
// --- malformed metadata is dropped, never rendered ---------------------------
|
|
|
|
check("a missing attachments array is an empty list, not a crash",
|
|
Model.parseAttachments(undefined).length === 0, "threw or returned non-empty")
|
|
check("a non-array is too", Model.parseAttachments("nope").length === 0, "non-empty")
|
|
check("an entry with no id is dropped -- there is nothing to fetch it by",
|
|
Model.parseAttachments([{ fileName: "orphan.txt" }]).length === 0, "kept")
|
|
check("an entry with no file name still gets a label",
|
|
Model.parseAttachments([{ id: "x" }])[0].fileName === "attachment",
|
|
Model.parseAttachments([{ id: "x" }])[0].fileName)
|
|
|
|
// --- the size fallback, for entries that arrive without sizeName -------------
|
|
|
|
const sized = Model.parseAttachments([{ id: "x", fileName: "f", size: "2048" }])
|
|
check("a byte count with no sizeName is formatted", sized[0].sizeName === "2 KB", sized[0].sizeName)
|
|
for (const [bytes, want] of [[0, "0 B"], [512, "512 B"], [1024, "1 KB"],
|
|
[1536, "1.5 KB"], [1048576, "1 MB"], [5242880, "5 MB"]]) {
|
|
check(`${bytes} bytes reads as ${want}`, Model.formatAttachmentSize(bytes) === want,
|
|
Model.formatAttachmentSize(bytes))
|
|
}
|
|
check("a missing size yields no size text rather than NaN",
|
|
Model.formatAttachmentSize(undefined) === "", Model.formatAttachmentSize(undefined))
|
|
check("so does junk", Model.formatAttachmentSize("banana") === "", Model.formatAttachmentSize("banana"))
|
|
|
|
// --- a vault file name cannot escape the download directory ------------------
|
|
//
|
|
// This is the one that matters: the name is decrypted vault content, and the
|
|
// download path is built from it.
|
|
|
|
const traversals = [
|
|
"../../.bashrc",
|
|
"/etc/passwd",
|
|
"..\\..\\windows\\system32\\evil.dll",
|
|
"sub/dir/../../../../root/.ssh/authorized_keys",
|
|
"....//....//etc/shadow"
|
|
]
|
|
for (const raw of traversals) {
|
|
const safe = Model.safeAttachmentFileName(raw)
|
|
check(`"${raw}" cannot traverse`,
|
|
safe.indexOf("/") === -1 && safe.indexOf("\\") === -1 && safe !== ".." && safe !== ".",
|
|
safe)
|
|
check(`"${raw}" cannot start a path or a flag`,
|
|
safe[0] !== "." && safe[0] !== "-" && safe[0] !== "/", safe)
|
|
}
|
|
|
|
check("a NUL is neutralised", Model.safeAttachmentFileName("a\u0000b").indexOf("\u0000") === -1,
|
|
JSON.stringify(Model.safeAttachmentFileName("a\u0000b")))
|
|
check("so is a newline, which no shell quoting would have caught on its own",
|
|
Model.safeAttachmentFileName("a\nrm -rf ~\n").indexOf("\n") === -1,
|
|
JSON.stringify(Model.safeAttachmentFileName("a\nrm -rf ~\n")))
|
|
check("an empty name still yields something openable",
|
|
Model.safeAttachmentFileName("") === "attachment", Model.safeAttachmentFileName(""))
|
|
check("a name of nothing but dots does too",
|
|
Model.safeAttachmentFileName("...") === "attachment", Model.safeAttachmentFileName("..."))
|
|
check("an ordinary name is left alone",
|
|
Model.safeAttachmentFileName("Scan 2026-08-21.pdf") === "Scan 2026-08-21.pdf",
|
|
Model.safeAttachmentFileName("Scan 2026-08-21.pdf"))
|
|
check("spaces and unicode survive",
|
|
Model.safeAttachmentFileName("résumé final.pdf") === "résumé final.pdf",
|
|
Model.safeAttachmentFileName("résumé final.pdf"))
|
|
|
|
const long = Model.safeAttachmentFileName("x".repeat(400) + ".pdf")
|
|
check("an absurdly long name is truncated but keeps its extension",
|
|
long.length <= 128 && long.slice(-4) === ".pdf", `${long.length} ${long.slice(-8)}`)
|
|
|
|
// --- the download command ----------------------------------------------------
|
|
|
|
const cmd = Model.attachmentDownloadCommand("a1", "item-id", "codes.txt")
|
|
check("it runs through bash, because the download directory is resolved at run time",
|
|
cmd[0] === "bash" && cmd[1] === "-c", JSON.stringify(cmd.slice(0, 2)))
|
|
|
|
const script = cmd[2]
|
|
check("the attachment id and item id are quoted, never interpolated bare",
|
|
script.indexOf("bw get attachment --itemid 'item-id'") !== -1
|
|
&& script.indexOf("-- 'a1'") !== -1, script)
|
|
check("the file name is quoted too", script.indexOf("name='codes.txt'") !== -1, script)
|
|
check("it prints where the file landed, which is how the panel learns the path",
|
|
/printf %s "\$out"/.test(script), script)
|
|
check("it claims the name with link(), which is the existence test and the creation at once",
|
|
script.indexOf('ln -- "$tmp" "$cand"') !== -1, script)
|
|
check("it no longer decides the name with a test a symlink can answer for",
|
|
script.indexOf('while [ -e "$out" ]') === -1, script)
|
|
check("the bytes are staged in a private directory before they are placed",
|
|
script.indexOf('mktemp -d -- "$dir/.qsbw-XXXXXXXX"') !== -1
|
|
&& script.indexOf('--output "$tmp"') !== -1, script)
|
|
check("the staging directory is removed however the script leaves",
|
|
script.indexOf(`trap 'rm -rf -- "$work"' EXIT`) !== -1, script)
|
|
check("a decrypted attachment is not left readable by anyone else",
|
|
script.split("\n").indexOf("umask 077") !== -1, script)
|
|
check("locking and panel dismissal cancel an attachment that is still decrypting",
|
|
/cancelAttachmentDownloads\(\)/.test(bodyOf("dropVaultState"))
|
|
&& /cancelAttachmentDownloads\(\)/.test(bodyOf("close"))
|
|
&& /if\s*\(attachmentProc\.running\)\s*attachmentProc\.running\s*=\s*false/.test(bodyOf("cancelAttachmentDownloads"))
|
|
&& /invalidateEpochOperation\("attachment"\)/.test(bodyOf("cancelAttachmentDownloads")),
|
|
bodyOf("close") + "\n" + bodyOf("dropVaultState") + "\n" + bodyOf("cancelAttachmentDownloads"))
|
|
check("cancellation reaches the complete attachment process group",
|
|
script.includes("set -m") && script.includes('kill -TERM -- "-$__auth_job"'), script)
|
|
|
|
// --- the ceilings ------------------------------------------------------------
|
|
check("the transfer is bounded in bytes by RLIMIT_FSIZE",
|
|
/ulimit -f \d+/.test(script), script)
|
|
check("the transfer is bounded in time",
|
|
/timeout \d+s bw get attachment/.test(script)
|
|
&& !script.includes("command -v timeout"), script)
|
|
check("the disk is not filled to the last byte",
|
|
script.indexOf("df -Pk") !== -1, script)
|
|
check("an unknown declared size reserves room for the full bounded transfer",
|
|
script.indexOf('[ "$avail" -lt 589824 ]') !== -1, script)
|
|
check("the size the vault declares buys an early refusal",
|
|
script.indexOf('if [ "$want" -gt "$max" ]') !== -1, script)
|
|
check("the size on disk is checked even where the kernel limit was not applied",
|
|
script.indexOf('wc -c < "$tmp"') !== -1, script)
|
|
|
|
const withSize = Model.attachmentDownloadCommand("a1", "item-id", "codes.txt", "4096")
|
|
check("the declared size reaches the script", withSize[2].indexOf("want=4096") !== -1, withSize[2])
|
|
check("a missing or nonsense declared size is treated as unknown, not as a refusal",
|
|
Model.attachmentDownloadCommand("a1", "i", "f.txt")[2].indexOf("want=0") !== -1
|
|
&& Model.attachmentDownloadCommand("a1", "i", "f.txt", "nope")[2].indexOf("want=0") !== -1,
|
|
Model.attachmentDownloadCommand("a1", "i", "f.txt", "nope")[2])
|
|
check("the no-hardlink fallback still refuses to replace a raced destination",
|
|
script.indexOf('mv -n -- "$tmp" "$cand"') !== -1, script)
|
|
check("it refuses to treat $HOME as the download directory",
|
|
script.indexOf("\"$dir\" = \"$HOME\"") !== -1, script)
|
|
check("it stops at the first failure rather than printing a path for a file it did not write",
|
|
/set -e/.test(script), script.split("\n")[0])
|
|
|
|
// A hostile name reaches the script already defanged, and quoted on top.
|
|
const hostile = Model.attachmentDownloadCommand("a'1", "i'd", "../../.bashrc")
|
|
check("a quote in the attachment id cannot break out of its quoting",
|
|
hostile[2].indexOf("bw get attachment --itemid 'i'\\''d'") !== -1
|
|
&& hostile[2].indexOf("-- 'a'\\''1'") !== -1, hostile[2])
|
|
check("a traversing name is sanitised before it is ever quoted",
|
|
hostile[2].indexOf("name='.bashrc'") === -1 && hostile[2].indexOf("name='bashrc'") !== -1,
|
|
hostile[2].split("\n")[1])
|
|
|
|
// --- path helpers the detail view uses ---------------------------------------
|
|
|
|
check("parentDirectory finds the folder to reveal",
|
|
Model.parentDirectory("/home/u/Downloads/f.pdf") === "/home/u/Downloads",
|
|
Model.parentDirectory("/home/u/Downloads/f.pdf"))
|
|
check("a root-level file reveals /", Model.parentDirectory("/f.pdf") === "/",
|
|
Model.parentDirectory("/f.pdf"))
|
|
check("a bare name has no folder to reveal", Model.parentDirectory("f.pdf") === "",
|
|
Model.parentDirectory("f.pdf"))
|
|
check("baseName names the saved file for the flash message",
|
|
Model.baseName("/home/u/Downloads/f.pdf") === "f.pdf",
|
|
Model.baseName("/home/u/Downloads/f.pdf"))
|
|
|
|
// --- the shape QML hands back ------------------------------------------------
|
|
//
|
|
// This is the one that would have caught the bug that shipped. A cipher parsed
|
|
// from `bw` JSON holds real arrays, and every check in the model said
|
|
// Array.isArray(). But the parsed cipher is stored in a QML `var` property and
|
|
// read back through a ListView delegate, and Qt converts the nested arrays on
|
|
// that trip into array-like objects: typeof "object", correct .length,
|
|
// indexing works, Array.isArray() false. So the detail view built from that
|
|
// object found no attachments on an item the list had just drawn a paperclip
|
|
// on -- and no error anywhere, because an empty list is a valid answer.
|
|
//
|
|
// Node always gives real arrays, which is exactly why the first round of tests
|
|
// passed while the panel was broken. So fake the conversion here.
|
|
|
|
const qmlish = (arr) => {
|
|
// Array-like, deliberately not an Array -- what Qt hands back.
|
|
const o = { length: arr.length }
|
|
arr.forEach((v, i) => { o[i] = v && typeof v === "object" ? qmlish_obj(v) : v })
|
|
return o
|
|
}
|
|
const qmlish_obj = (obj) => {
|
|
if (Array.isArray(obj)) return qmlish(obj)
|
|
const out = {}
|
|
for (const k of Object.keys(obj)) {
|
|
const v = obj[k]
|
|
out[k] = (v && typeof v === "object") ? qmlish_obj(v) : v
|
|
}
|
|
return out
|
|
}
|
|
|
|
const roundTripped = qmlish_obj(withFiles)
|
|
check("the round-tripped attachments really are not an Array, or this test proves nothing",
|
|
!Array.isArray(roundTripped.attachments) && roundTripped.attachments.length === 2,
|
|
`${Array.isArray(roundTripped.attachments)} len=${roundTripped.attachments.length}`)
|
|
|
|
const rtDetail = Model.itemDetailFromObject(roundTripped)
|
|
check("attachments survive the QML round trip",
|
|
rtDetail.hasAttachments && rtDetail.attachments.length === 2,
|
|
JSON.stringify(rtDetail.attachments))
|
|
check("with their names intact",
|
|
rtDetail.attachments.length > 0 && rtDetail.attachments[0].fileName === "codes.txt",
|
|
JSON.stringify(rtDetail.attachments))
|
|
|
|
// The same conversion silently emptied these two long before attachments
|
|
// existed: the detail view's WEBSITE section and its custom fields.
|
|
const login = {
|
|
object: "item", id: "33333333-3333-3333-3333-333333333333", type: 1,
|
|
name: "GitHub", notes: "", favorite: false,
|
|
login: { username: "octocat", password: "p", uris: [{ match: null, uri: "https://github.com" }] },
|
|
fields: [{ name: "recovery", value: "abcd", type: 1 }]
|
|
}
|
|
const rtLogin = Model.itemDetailFromObject(qmlish_obj(login))
|
|
check("URIs survive it too -- the WEBSITE section was empty for every login",
|
|
rtLogin.uris.length === 1 && rtLogin.uris[0] === "https://github.com",
|
|
JSON.stringify(rtLogin.uris))
|
|
check("and so do custom fields",
|
|
rtLogin.fields.length === 1 && rtLogin.fields[0].name === "recovery",
|
|
JSON.stringify(rtLogin.fields))
|
|
|
|
// itemDetailFromObject was taught the lesson; the two readers that run over
|
|
// root.items on every keystroke and every panel open were not. Searching by
|
|
// URL and matching an item to the focused site both read login.uris straight
|
|
// off a round-tripped cipher.
|
|
const rtListItem = Model.parseItems(JSON.stringify([login])).map(qmlish_obj)
|
|
check("searching by URL still finds the item after the round trip",
|
|
Model.filterItems(rtListItem, "github.com", "all", "all", "all").length === 1,
|
|
`matched ${Model.filterItems(rtListItem, "github.com", "all", "all", "all").length} items`)
|
|
check("and the site's own domain still identifies it",
|
|
Model.itemDomains(rtListItem[0]).length === 1
|
|
&& Model.itemDomains(rtListItem[0])[0].baseDomain === "github.com",
|
|
JSON.stringify(Model.itemDomains(rtListItem[0])))
|
|
check("so the focused tab still suggests it",
|
|
Model.findContextualMatches(rtListItem,
|
|
{ class: "chromium", title: "Pulls - github.com - Chromium", mapped: true })
|
|
.matches.length === 1,
|
|
"expected the item back on a domain match")
|
|
|
|
// The list parser held the same ceiling as the detail parser everywhere except
|
|
// here, where the URI array is real and was copied out entry for entry. A
|
|
// single item is free to carry as many as the byte cap allows.
|
|
const floodUris = []
|
|
for (let i = 0; i < 5000; i++) floodUris.push({ uri: "https://example" + i + ".com" })
|
|
check("a flooded URI list is held to the ceiling on the list path too",
|
|
Model.parseItems(JSON.stringify([{ id: "i", type: 1, login: { uris: floodUris } }]))[0].uris.length === 4096,
|
|
String(Model.parseItems(JSON.stringify([{ id: "i", type: 1, login: { uris: floodUris } }]))[0].uris.length))
|
|
|
|
// toList must not mistake a string, or anything else with a length, for a list.
|
|
check("a string is not a list of characters",
|
|
Model.parseAttachments("nope").length === 0, "treated a string as a list")
|
|
check("an object with a junk length is not a list",
|
|
Model.parseAttachments({ length: -1 }).length === 0
|
|
&& Model.parseAttachments({ length: 1.5 }).length === 0
|
|
&& Model.parseAttachments({ length: "2" }).length === 0, "accepted a junk length")
|
|
|
|
// Duck-typing takes the server's word for how long a list is, and the word is
|
|
// free to be a lie: {"length": 200000000} is forty bytes that asked for a
|
|
// two-hundred-million-element array. The byte cap on the item list cannot see
|
|
// it coming. So the length is a ceiling, not an instruction.
|
|
const manyUris = { length: 5000 }
|
|
for (let i = 0; i < 5000; i++) manyUris[i] = { uri: "https://example" + i + ".com" }
|
|
check("a list longer than any real item stops at the ceiling",
|
|
Model.itemDetailFromObject({ id: "i", type: 1, login: { uris: manyUris } }).uris.length === 4096,
|
|
String(Model.itemDetailFromObject({ id: "i", type: 1, login: { uris: manyUris } }).uris.length))
|
|
|
|
const manyReal = []
|
|
for (let i = 0; i < 5000; i++) manyReal.push({ id: "a" + i, fileName: "f" + i, size: "1" })
|
|
check("and a real array is held to the same ceiling",
|
|
Model.parseAttachments(manyReal).length === 4096, String(Model.parseAttachments(manyReal).length))
|
|
|
|
// Run in a child with a small heap: with the ceiling this finishes instantly,
|
|
// and without it the parse takes the whole process down with it -- which, in
|
|
// the panel, is the shell.
|
|
const lengthLie = `
|
|
const fs = require("fs")
|
|
const M = {}
|
|
new Function("exports", fs.readFileSync(${JSON.stringify(path.join(__dirname, "..", "BitwardenModel.js"))}, "utf8")
|
|
.replace(/^\\.pragma library\\s*$/m, "") + "\\nexports.parseItems = parseItems")(M)
|
|
M.parseItems(JSON.stringify([{
|
|
object: "item", id: "x", type: 1, name: "n",
|
|
attachments: { length: 200000000 },
|
|
login: { uris: { length: 200000000 } },
|
|
fields: { length: 200000000 }
|
|
}]))
|
|
`
|
|
let survived = true
|
|
try {
|
|
require("child_process").execFileSync(process.execPath,
|
|
["--max-old-space-size=256", "-e", lengthLie],
|
|
{ stdio: ["ignore", "pipe", "pipe"], timeout: 30000 })
|
|
} catch (e) {
|
|
survived = false
|
|
}
|
|
check("a declared length of two hundred million does not take the shell process with it",
|
|
survived, "the parse exhausted the heap")
|
|
|
|
// --- and the script actually run ---------------------------------------------
|
|
//
|
|
// String-matching the script only says what we wrote. What matters is what
|
|
// bash does with it, so it is run for real here against a stub `bw` and a
|
|
// throwaway HOME: the traversal has to end up inside the download directory,
|
|
// and a name collision must never overwrite what is already there.
|
|
|
|
const os = require("os")
|
|
const { execFileSync } = require("child_process")
|
|
|
|
const home = fs.mkdtempSync(path.join(os.tmpdir(), "qsbw-attachments-"))
|
|
const bin = path.join(home, "bin")
|
|
fs.mkdirSync(bin)
|
|
fs.writeFileSync(path.join(bin, "bw"), `#!/usr/bin/env bash
|
|
# Stands in for the CLI: writes whatever --output names, or fails on demand.
|
|
out=""
|
|
while [ $# -gt 0 ]; do if [ "$1" = "--output" ]; then out="$2"; fi; shift; done
|
|
if [ -n "$QSBW_TEST_FAIL" ]; then echo "Not found." >&2; exit 1; fi
|
|
printf 'bytes\\n' > "$out"
|
|
echo "Saved $out"
|
|
`)
|
|
fs.chmodSync(path.join(bin, "bw"), 0o755)
|
|
|
|
const env = { PATH: bin + ":/usr/bin:/bin", HOME: home }
|
|
const run = (fileName, extra, declaredSize) => {
|
|
const cmd = Model.attachmentDownloadCommand("att-id", "item-id", fileName, declaredSize)
|
|
return execFileSync(cmd[0], cmd.slice(1), {
|
|
env: Object.assign({}, env, extra || {}),
|
|
encoding: "utf8",
|
|
stdio: ["ignore", "pipe", "pipe"]
|
|
})
|
|
}
|
|
|
|
const downloads = path.join(home, "Downloads")
|
|
const firstSave = run("codes.txt")
|
|
check("the file lands in the download directory",
|
|
firstSave === path.join(downloads, "codes.txt"), firstSave)
|
|
check("a second file of the same name does not overwrite the first",
|
|
run("codes.txt") === path.join(downloads, "codes (1).txt"), "overwrote")
|
|
check("and a third keeps counting",
|
|
run("codes.txt") === path.join(downloads, "codes (2).txt"), "overwrote")
|
|
check("a name with no extension still gets a free slot",
|
|
run("notes") === path.join(downloads, "notes")
|
|
&& run("notes") === path.join(downloads, "notes (1)"), "collided")
|
|
check("a quote in the file name is data, not syntax",
|
|
run("it's here.txt") === path.join(downloads, "it's here.txt"), "broke out")
|
|
|
|
const traversed = run("../../.bashrc")
|
|
check("a traversing name cannot write outside the download directory",
|
|
path.dirname(traversed) === downloads && !fs.existsSync(path.join(home, ".bashrc")),
|
|
traversed)
|
|
|
|
let failed = null
|
|
try { run("codes.txt", { QSBW_TEST_FAIL: "1" }) } catch (e) { failed = e }
|
|
check("a failing bw exits non-zero rather than reporting a path for a file it never wrote",
|
|
failed !== null && String(failed.stdout || "") === "", String(failed && failed.stdout))
|
|
|
|
// --- the size the server declares ---------------------------------------------
|
|
//
|
|
// The size is the one value out of the vault that reaches the script as a bare
|
|
// word, and a big enough number is spelled "1e+30" in JavaScript. Bash reads
|
|
// that as a non-integer, so `[ "$want" -gt "$max" ]` and the free-space test
|
|
// both failed as errors rather than answering, and a failing test inside an
|
|
// `if` is simply skipped -- the download then ran with neither ceiling and
|
|
// said nothing about it. A hostile server picks this number, so it is checked
|
|
// by running the script, not by reading it.
|
|
|
|
for (const absurd of ["1e21", "1e30", "1e40", "999999999999999999999999"]) {
|
|
let refused = null
|
|
try { run(`huge-${absurd}.bin`, {}, absurd) } catch (e) { refused = e }
|
|
check(`a declared size of ${absurd} is refused instead of skipping both ceilings`,
|
|
refused !== null && String(refused.stderr || "").indexOf("download limit") !== -1,
|
|
refused ? String(refused.stderr) : "the download went ahead")
|
|
check(`and nothing lands in the download folder for ${absurd}`,
|
|
!fs.existsSync(path.join(downloads, `huge-${absurd}.bin`)), "a file was written")
|
|
check(`and no raw bash error is what the panel would show for ${absurd}`,
|
|
refused !== null && String(refused.stderr || "").indexOf("integer expected") === -1,
|
|
refused ? String(refused.stderr) : "")
|
|
}
|
|
|
|
check("a size within the limit still downloads",
|
|
run("sized.txt", {}, "4096") === path.join(downloads, "sized.txt"), "refused a legitimate size")
|
|
|
|
// Whatever the server says, nothing exponential may be written into the script.
|
|
for (const absurd of ["1e21", "1e30", "1e40", String(Number.MAX_SAFE_INTEGER * 512)]) {
|
|
const generated = Model.attachmentDownloadCommand("a", "i", "f.bin", absurd)[2]
|
|
check(`every number in the script stays a plain integer for ${absurd}`,
|
|
!/[0-9]e[+-][0-9]/.test(generated), generated.split("\n").filter(l => /e[+-][0-9]/.test(l)).join(" | "))
|
|
}
|
|
|
|
fs.rmSync(home, { recursive: true, force: true })
|
|
|
|
// --- a server-chosen id cannot become an option to bw ------------------------
|
|
//
|
|
// Quoting defends against the shell, not against bw's own parser: `bw get item
|
|
// --help` prints help rather than looking anything up, and every id here is the
|
|
// server's to choose. `--` ends the options, and our own flags go before it.
|
|
|
|
const optionish = "--help"
|
|
const guarded = [
|
|
["getItemCommand", Model.getItemCommand(optionish), "bw get item -- --help"],
|
|
["getTotpCommand", Model.getTotpCommand(optionish), "bw get totp --raw -- --help"],
|
|
["deleteItemCommand", Model.deleteItemCommand(optionish), "bw delete item -- --help"],
|
|
["deleteSendCommand", Model.deleteSendCommand(optionish), "bw send delete -- --help"],
|
|
]
|
|
for (const [name, cmd, want] of guarded) {
|
|
check(`${name} ends the options before the id`, cmd[2].indexOf(want) !== -1, cmd[2])
|
|
}
|
|
check("editItemCommand ends the options before the id",
|
|
Model.editItemCommand(optionish)[2].indexOf("bw edit item -- '--help'") !== -1,
|
|
Model.editItemCommand(optionish)[2])
|
|
check("the attachment id goes last, after our own flags and the separator",
|
|
Model.attachmentDownloadCommand(optionish, "i", "f.txt")[2]
|
|
.indexOf(`--output "$tmp" -- '--help'`) !== -1,
|
|
Model.attachmentDownloadCommand(optionish, "i", "f.txt")[2])
|
|
|
|
console.log(`${pass} passed, ${failures.length} failed`)
|
|
if (failures.length) { console.error("\nFAILURES:\n " + failures.join("\n ")); process.exit(1) }
|