security: validate hostname in makeExitNodeCommand (balanced paranoia, direct-argv defense-in-depth, TDD)
This commit is contained in:
parent
8ef466a928
commit
1a95d0330e
3 changed files with 58 additions and 8 deletions
|
|
@ -64,6 +64,8 @@ PluginComponent {
|
||||||
Process {
|
Process {
|
||||||
id: exitNodeProcess
|
id: exitNodeProcess
|
||||||
|
|
||||||
|
// Command is set dynamically by setExitNode()
|
||||||
|
|
||||||
stderr: StdioCollector {}
|
stderr: StdioCollector {}
|
||||||
|
|
||||||
onExited: (code, status) => {
|
onExited: (code, status) => {
|
||||||
|
|
@ -113,7 +115,12 @@ PluginComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
function setExitNode(hostname) {
|
function setExitNode(hostname) {
|
||||||
exitNodeProcess.command = TailscaleLib.makeExitNodeCommand(hostname)
|
const cmd = TailscaleLib.makeExitNodeCommand(hostname)
|
||||||
|
if (!cmd) {
|
||||||
|
ToastService.showError("tailscalectl", TailscaleLib.getStrings().invalidExitNodeHostname)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
exitNodeProcess.command = cmd
|
||||||
exitNodeProcess.running = true
|
exitNodeProcess.running = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,13 @@ function parsePeers(peerMap) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeExitNodeCommand(hostname) {
|
function makeExitNodeCommand(hostname) {
|
||||||
if (hostname === "") {
|
if (!isValidExitNodeHostname(hostname)) {
|
||||||
return ["tailscale", "set", "--exit-node="]
|
return null;
|
||||||
}
|
}
|
||||||
return ["tailscale", "set", "--exit-node=" + hostname]
|
if (hostname === "") {
|
||||||
|
return ["tailscale", "set", "--exit-node="];
|
||||||
|
}
|
||||||
|
return ["tailscale", "set", "--exit-node=" + hostname];
|
||||||
}
|
}
|
||||||
|
|
||||||
function findActiveExitNode(peerMap) {
|
function findActiveExitNode(peerMap) {
|
||||||
|
|
@ -72,7 +75,8 @@ function getStrings() {
|
||||||
noClipboardTool: "No clipboard tool found",
|
noClipboardTool: "No clipboard tool found",
|
||||||
invalidClipboardCommand: "Invalid clipboard command",
|
invalidClipboardCommand: "Invalid clipboard command",
|
||||||
clearExitNode: "×",
|
clearExitNode: "×",
|
||||||
setExitNode: "↗"
|
setExitNode: "↗",
|
||||||
|
invalidExitNodeHostname: "Invalid exit node hostname"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -85,6 +89,17 @@ function isActiveExitNode(currentExitNode, hostname) {
|
||||||
return currentExitNode === hostname
|
return currentExitNode === hostname
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Security: validate hostnames coming from tailscale status JSON before we
|
||||||
|
// concatenate them into a command argument. Direct argv is already used
|
||||||
|
// (see #49), but we still fail closed on obviously malicious input.
|
||||||
|
function isValidExitNodeHostname(hostname) {
|
||||||
|
if (typeof hostname !== "string") return false;
|
||||||
|
if (hostname === "") return true; // clear command
|
||||||
|
// Permissive enough for real Tailscale hostnames while rejecting
|
||||||
|
// classic shell metacharacters and control characters.
|
||||||
|
return /^[a-zA-Z0-9]([a-zA-Z0-9-_.]{0,62}[a-zA-Z0-9])?$/.test(hostname);
|
||||||
|
}
|
||||||
|
|
||||||
function parseStatusResult(jsonText) {
|
function parseStatusResult(jsonText) {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(jsonText)
|
const data = JSON.parse(jsonText)
|
||||||
|
|
@ -134,5 +149,5 @@ function formatError(action, detail) {
|
||||||
|
|
||||||
// CommonJS export for Node.js tests (ignored by QML)
|
// CommonJS export for Node.js tests (ignored by QML)
|
||||||
if (typeof module !== "undefined" && module.exports) {
|
if (typeof module !== "undefined" && module.exports) {
|
||||||
module.exports = { parsePeers, makeExitNodeCommand, findActiveExitNode, errorMessage, formatError, getStatusCommand, validateClipboardTool, buildCopyCommand, nextClipboardTool, allClipboardTools, buildToggleCommand, parseStatusResult, getStrings, shouldShowClearExitNode, isActiveExitNode }
|
module.exports = { parsePeers, makeExitNodeCommand, findActiveExitNode, errorMessage, formatError, getStatusCommand, isValidExitNodeHostname, validateClipboardTool, buildCopyCommand, nextClipboardTool, allClipboardTools, buildToggleCommand, parseStatusResult, getStrings, shouldShowClearExitNode, isActiveExitNode }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { test } from "node:test"
|
import { test } from "node:test"
|
||||||
import assert from "node:assert"
|
import assert from "node:assert"
|
||||||
import lib from "../tailscalectl/lib.js"
|
import lib from "../tailscalectl/lib.js"
|
||||||
const { parsePeers, makeExitNodeCommand, findActiveExitNode, errorMessage, formatError, getStatusCommand, validateClipboardTool, buildCopyCommand, nextClipboardTool, allClipboardTools, buildToggleCommand, parseStatusResult, getStrings, shouldShowClearExitNode, isActiveExitNode } = lib
|
const { parsePeers, makeExitNodeCommand, findActiveExitNode, errorMessage, formatError, getStatusCommand, isValidExitNodeHostname, validateClipboardTool, buildCopyCommand, nextClipboardTool, allClipboardTools, buildToggleCommand, parseStatusResult, getStrings, shouldShowClearExitNode, isActiveExitNode } = lib
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Test coverage note for #48:
|
* Test coverage note for #48:
|
||||||
|
|
@ -261,6 +261,34 @@ test("formatError handles empty or falsy detail gracefully", () => {
|
||||||
assert.strictEqual(formatError("connect", null), "Failed to connect to Tailscale")
|
assert.strictEqual(formatError("connect", null), "Failed to connect to Tailscale")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// --- isValidExitNodeHostname + makeExitNodeCommand safety (balanced paranoia, direct-argv defense-in-depth) ---
|
||||||
|
|
||||||
|
test("isValidExitNodeHostname accepts empty string (clear)", () => {
|
||||||
|
assert.strictEqual(isValidExitNodeHostname(""), true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("isValidExitNodeHostname accepts realistic Tailscale hostnames", () => {
|
||||||
|
["router", "gluetun-sjc", "my-exit-node-01", "peer_with_underscore", "a.b.c"].forEach(h =>
|
||||||
|
assert.strictEqual(isValidExitNodeHostname(h), true, h)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("isValidExitNodeHostname rejects obvious injection attempts", () => {
|
||||||
|
["; rm -rf /", "$(whoami)", "`id`", "foo;bar", "a&b", "x\ny", "evil$(date)"].forEach(h =>
|
||||||
|
assert.strictEqual(isValidExitNodeHostname(h), false, h)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("makeExitNodeCommand returns null for invalid hostname", () => {
|
||||||
|
assert.strictEqual(makeExitNodeCommand("; rm"), null)
|
||||||
|
assert.strictEqual(makeExitNodeCommand("$(whoami)"), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("makeExitNodeCommand still produces correct argv for valid input", () => {
|
||||||
|
assert.deepStrictEqual(makeExitNodeCommand(""), ["tailscale", "set", "--exit-node="])
|
||||||
|
assert.deepStrictEqual(makeExitNodeCommand("gluetun-sjc"), ["tailscale", "set", "--exit-node=gluetun-sjc"])
|
||||||
|
})
|
||||||
|
|
||||||
// --- getStatusCommand (new pure helper for low-duty-cycle architecture) ---
|
// --- getStatusCommand (new pure helper for low-duty-cycle architecture) ---
|
||||||
|
|
||||||
test("getStatusCommand returns the canonical tailscale status --json argv", () => {
|
test("getStatusCommand returns the canonical tailscale status --json argv", () => {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue