Compare commits

..

9 commits

Author SHA1 Message Date
f87ade7a67 Merge branch 'feature_sanitize_shell_commands'
Sanitize shell command construction by delegating to lib.js functions:
- validateClipboardCmd: whitelist-based input validation
- buildCopyCommand: safe clipboard command construction
- parseClipboardDetection: safe stdout parsing with fallback
- buildToggleCommand: pure toggle command builder
- parseStatusResult: safe JSON parsing with try/catch
- QML refactor: delegates all shell command building to lib.js
- Expanded clipboard detection to xclip and xsel
- Fixed clipboard detection script to use if/elif/else
2026-05-21 03:57:15 +00:00
2ece3cc103 fix: use if/elif/else in clipboard detection script
The previous &&/|| chain evaluated all commands, producing multi-line
output when multiple tools were installed. parseClipboardDetection
correctly rejected this as invalid, falling back to 'none'. The
if/elif/else structure ensures only the first match is echoed.
2026-05-21 02:36:40 +00:00
6ef933211c fix: expand clipboard detection to xclip and xsel
Add xclip and xsel to the whitelist and detection script so users
on systems without wl-copy or clipmanctl can still copy peer info.
2026-05-21 02:29:13 +00:00
93b257ba5d refactor: delegate shell command construction to lib.js functions
Replace inline shell command building in QML with calls to
parseClipboardDetection, parseStatusResult, buildToggleCommand, and
buildCopyCommand. This centralizes sanitization logic and removes
duplicated try/catch and ternary blocks from the UI layer.
2026-05-20 19:37:08 +00:00
b521af3364 feat: add parseStatusResult for safe JSON parsing of tailscale status
Wraps JSON.parse in try/catch, returns safe defaults on failure,
and delegates to existing findActiveExitNode and parsePeers.
2026-05-20 19:29:02 +00:00
ce8707c618 feat: add buildToggleCommand for safe toggle command construction
Replaces inline ternary in QML with a pure function that returns
the correct tailscale up/down command based on connection state.
2026-05-20 19:21:17 +00:00
dddd9218f6 feat: add parseClipboardDetection for safe clipboard tool parsing
Trims output and validates against whitelist, falling back to 'none'
for empty, multi-line, or unexpected values.
2026-05-20 10:16:02 +00:00
3c31209ff1 feat: add buildCopyCommand for safe clipboard command construction
Uses validateClipboardCmd to reject untrusted input and properly escapes
single quotes in text to prevent shell injection.
2026-05-20 10:11:57 +00:00
a86d5e279d feat: add validateClipboardCmd with whitelist-based input validation
Prevents shell injection by only allowing known-safe clipboard commands
(dms cl copy, wl-copy, clipmanctl copy, none).
2026-05-20 10:08:59 +00:00
3 changed files with 182 additions and 23 deletions

View file

@ -62,11 +62,11 @@ PluginComponent {
Process {
id: detectClipboard
command: ["sh", "-c", "which dms >/dev/null 2>&1 && echo 'dms cl copy' || which wl-copy >/dev/null 2>&1 && echo 'wl-copy' || which clipmanctl >/dev/null 2>&1 && echo 'clipmanctl copy' || echo none"]
command: ["sh", "-c", "if which dms >/dev/null 2>&1; then echo 'dms cl copy'; elif which wl-copy >/dev/null 2>&1; then echo 'wl-copy'; elif which clipmanctl >/dev/null 2>&1; then echo 'clipmanctl copy'; elif which xclip >/dev/null 2>&1; then echo 'xclip -selection clipboard'; elif which xsel >/dev/null 2>&1; then echo 'xsel --clipboard --input'; else echo none; fi"]
stdout: StdioCollector {
onStreamFinished: {
root.clipboardCmd = this.text.trim()
root.clipboardCmd = TailscaleLib.parseClipboardDetection(this.text)
}
}
@ -82,19 +82,11 @@ PluginComponent {
stdout: StdioCollector {
onStreamFinished: {
try {
const data = JSON.parse(this.text)
root.isConnected = data.BackendState === "Running"
root.tailscaleIP = (data.Self?.TailscaleIPs?.[0]) || ""
root.currentExitNode = TailscaleLib.findActiveExitNode(data.Peer || {})
root.peers = TailscaleLib.parsePeers(data.Peer || {})
} catch (e) {
root.isConnected = false
root.tailscaleIP = ""
root.currentExitNode = ""
root.peers = []
ToastService.showError("tailscalectl", "Failed to parse Tailscale status")
}
const state = TailscaleLib.parseStatusResult(this.text)
root.isConnected = state.isConnected
root.tailscaleIP = state.tailscaleIP
root.currentExitNode = state.currentExitNode
root.peers = state.peers
}
}
@ -119,11 +111,7 @@ PluginComponent {
ToastService.showError("tailscalectl", "Tailscale not available")
return
}
if (root.isConnected) {
toggleProcess.command = ["tailscale", "down"]
} else {
toggleProcess.command = ["tailscale", "up"]
}
toggleProcess.command = TailscaleLib.buildToggleCommand(root.isConnected)
toggleProcess.running = true
}
@ -141,7 +129,12 @@ PluginComponent {
ToastService.showError("tailscalectl", "No clipboard tool found")
return
}
copyProcess.command = ["sh", "-c", "printf '%s' '" + text.replace(/'/g, "'\\''") + "' | " + root.clipboardCmd]
var cmd = TailscaleLib.buildCopyCommand(text, root.clipboardCmd)
if (!cmd) {
ToastService.showError("tailscalectl", "Invalid clipboard command")
return
}
copyProcess.command = cmd
copyProcess.running = true
ToastService.showInfo("Copied " + text + " to clipboard")
}

View file

@ -26,6 +26,41 @@ function findActiveExitNode(peerMap) {
return ""
}
var safeClipboardCmds = ["dms cl copy", "wl-copy", "clipmanctl copy", "xclip -selection clipboard", "xsel --clipboard --input", "none"]
function validateClipboardCmd(cmd) {
return typeof cmd === "string" && safeClipboardCmds.includes(cmd)
}
function buildCopyCommand(text, clipboardCmd) {
if (!validateClipboardCmd(clipboardCmd)) return null
var escaped = text.replace(/'/g, "'\\''")
return ["sh", "-c", "printf '%s' '" + escaped + "' | " + clipboardCmd]
}
function parseStatusResult(jsonText) {
try {
const data = JSON.parse(jsonText)
return {
isConnected: data.BackendState === "Running",
tailscaleIP: (data.Self && data.Self.TailscaleIPs && data.Self.TailscaleIPs[0]) || "",
currentExitNode: findActiveExitNode(data.Peer || {}),
peers: parsePeers(data.Peer || {})
}
} catch (e) {
return { isConnected: false, tailscaleIP: "", currentExitNode: "", peers: [] }
}
}
function buildToggleCommand(isConnected) {
return isConnected ? ["tailscale", "down"] : ["tailscale", "up"]
}
function parseClipboardDetection(stdout) {
var trimmed = typeof stdout === "string" ? stdout.trim() : ""
return validateClipboardCmd(trimmed) ? trimmed : "none"
}
function errorMessage(cmd) {
var messages = {
"up": "Failed to connect to Tailscale",
@ -40,5 +75,5 @@ function errorMessage(cmd) {
// CommonJS export for Node.js tests (ignored by QML)
if (typeof module !== "undefined" && module.exports) {
module.exports = { parsePeers, makeExitNodeCommand, findActiveExitNode, errorMessage }
module.exports = { parsePeers, makeExitNodeCommand, findActiveExitNode, errorMessage, validateClipboardCmd, buildCopyCommand, parseClipboardDetection, buildToggleCommand, parseStatusResult }
}

View file

@ -1,7 +1,7 @@
import { test } from "node:test"
import assert from "node:assert"
import lib from "../tailscalectl/lib.js"
const { parsePeers, makeExitNodeCommand, findActiveExitNode, errorMessage } = lib
const { parsePeers, makeExitNodeCommand, findActiveExitNode, errorMessage, validateClipboardCmd, buildCopyCommand, parseClipboardDetection, buildToggleCommand, parseStatusResult } = lib
test("parsePeers extracts exitNode from ExitNodeOption", () => {
const peerMap = {
@ -74,3 +74,134 @@ test("errorMessage returns generic message for unknown command", () => {
const msg = errorMessage("unknown", 1)
assert.strictEqual(msg, "Tailscale command failed")
})
// --- validateClipboardCmd ---
test("validateClipboardCmd accepts whitelisted values", () => {
assert.strictEqual(validateClipboardCmd("dms cl copy"), true)
assert.strictEqual(validateClipboardCmd("wl-copy"), true)
assert.strictEqual(validateClipboardCmd("clipmanctl copy"), true)
assert.strictEqual(validateClipboardCmd("xclip -selection clipboard"), true)
assert.strictEqual(validateClipboardCmd("xsel --clipboard --input"), true)
assert.strictEqual(validateClipboardCmd("none"), true)
})
test("validateClipboardCmd rejects malicious inputs", () => {
assert.strictEqual(validateClipboardCmd("rm -rf /"), false)
assert.strictEqual(validateClipboardCmd("echo hi; malicious"), false)
assert.strictEqual(validateClipboardCmd("$(whoami)"), false)
assert.strictEqual(validateClipboardCmd("wl-copy || rm -rf /"), false)
})
test("validateClipboardCmd rejects empty and falsy inputs", () => {
assert.strictEqual(validateClipboardCmd(""), false)
assert.strictEqual(validateClipboardCmd(null), false)
assert.strictEqual(validateClipboardCmd(undefined), false)
})
// --- buildCopyCommand ---
test("buildCopyCommand returns safe command for whitelisted clipboard tool", () => {
const cmd = buildCopyCommand("hello", "wl-copy")
assert.ok(Array.isArray(cmd))
assert.strictEqual(cmd[0], "sh")
assert.strictEqual(cmd[1], "-c")
})
test("buildCopyCommand returns null for invalid clipboard command", () => {
assert.strictEqual(buildCopyCommand("hello", "rm -rf /"), null)
assert.strictEqual(buildCopyCommand("hello", ""), null)
assert.strictEqual(buildCopyCommand("hello", null), null)
})
test("buildCopyCommand safely handles text with special characters", () => {
const cmd = buildCopyCommand("it's a 'test' with \"quotes\" and\nnewlines", "wl-copy")
assert.ok(Array.isArray(cmd))
})
// --- parseClipboardDetection ---
test("parseClipboardDetection passes through expected output strings", () => {
assert.strictEqual(parseClipboardDetection("dms cl copy"), "dms cl copy")
assert.strictEqual(parseClipboardDetection("wl-copy"), "wl-copy")
assert.strictEqual(parseClipboardDetection("clipmanctl copy"), "clipmanctl copy")
assert.strictEqual(parseClipboardDetection("none"), "none")
})
test("parseClipboardDetection trims extra whitespace", () => {
assert.strictEqual(parseClipboardDetection(" wl-copy \n"), "wl-copy")
})
test("parseClipboardDetection falls back to none for multi-line output", () => {
assert.strictEqual(parseClipboardDetection("wl-copy\nrm -rf /"), "none")
})
test("parseClipboardDetection falls back to empty output", () => {
assert.strictEqual(parseClipboardDetection(""), "none")
assert.strictEqual(parseClipboardDetection(" "), "none")
})
test("parseClipboardDetection falls back to none for unexpected strings", () => {
assert.strictEqual(parseClipboardDetection("pbpaste"), "none")
assert.strictEqual(parseClipboardDetection("custom-tool"), "none")
})
// --- buildToggleCommand ---
test("buildToggleCommand returns down command when connected", () => {
assert.deepStrictEqual(buildToggleCommand(true), ["tailscale", "down"])
})
test("buildToggleCommand returns up command when disconnected", () => {
assert.deepStrictEqual(buildToggleCommand(false), ["tailscale", "up"])
})
test("buildToggleCommand treats null and undefined as disconnected", () => {
assert.deepStrictEqual(buildToggleCommand(null), ["tailscale", "up"])
assert.deepStrictEqual(buildToggleCommand(undefined), ["tailscale", "up"])
})
// --- parseStatusResult ---
test("parseStatusResult produces correct state from valid JSON", () => {
const json = JSON.stringify({
BackendState: "Running",
Self: { TailscaleIPs: ["100.64.0.5"] },
Peer: {
"key-1": { HostName: "router", TailscaleIPs: ["100.64.0.1"], Online: true, ExitNode: true, ExitNodeOption: true }
}
})
const state = parseStatusResult(json)
assert.strictEqual(state.isConnected, true)
assert.strictEqual(state.tailscaleIP, "100.64.0.5")
assert.strictEqual(state.currentExitNode, "router")
assert.strictEqual(state.peers.length, 1)
})
test("parseStatusResult returns safe defaults for invalid JSON", () => {
const state = parseStatusResult("not json at all")
assert.strictEqual(state.isConnected, false)
assert.strictEqual(state.tailscaleIP, "")
assert.strictEqual(state.currentExitNode, "")
assert.strictEqual(state.peers.length, 0)
})
test("parseStatusResult handles missing Self gracefully", () => {
const json = JSON.stringify({ BackendState: "Running", Peer: {} })
const state = parseStatusResult(json)
assert.strictEqual(state.isConnected, true)
assert.strictEqual(state.tailscaleIP, "")
})
test("parseStatusResult handles missing and empty Peer gracefully", () => {
const json = JSON.stringify({ BackendState: "Running", Self: { TailscaleIPs: ["100.64.0.5"] } })
const state = parseStatusResult(json)
assert.strictEqual(state.peers.length, 0)
assert.strictEqual(state.currentExitNode, "")
})
test("parseStatusResult sets isConnected false for non-Running BackendState", () => {
const json = JSON.stringify({ BackendState: "NeedsLogin", Self: { TailscaleIPs: ["100.64.0.5"] }, Peer: {} })
const state = parseStatusResult(json)
assert.strictEqual(state.isConnected, false)
})