fix: bound trans stdout and sanitize win ps json
This commit is contained in:
+11
-1
@@ -8,6 +8,7 @@ import { summarizeMicroserviceHealthResponse, summarizeMicroserviceObservation,
|
||||
import { parseNetworkPerfOptions, runNetworkPerf } from "./network-perf";
|
||||
import {
|
||||
buildWindowsPowerShellInvocation,
|
||||
createSshStdoutForwarder,
|
||||
formatSshFailureHint,
|
||||
formatSshRuntimeTimeoutHint,
|
||||
formatSshRuntimeTimingHint,
|
||||
@@ -985,6 +986,10 @@ async function runRemoteSshWebSocket(
|
||||
|
||||
return await new Promise<number>((resolve) => {
|
||||
const rawMode = parsed.remoteCommand === null && process.stdin.isTTY && typeof process.stdin.setRawMode === "function";
|
||||
const stdoutForwarder = parsed.remoteCommand === null ? null : createSshStdoutForwarder({
|
||||
invocation,
|
||||
transport: "frontend-websocket",
|
||||
});
|
||||
let timedOut = false;
|
||||
const openTimer = setTimeout(() => {
|
||||
if (sessionReady || settled) return;
|
||||
@@ -1068,7 +1073,12 @@ async function runRemoteSshWebSocket(
|
||||
if (message.type === "ssh.data") {
|
||||
const chunk = Buffer.from(String(message.data ?? ""), message.encoding === "base64" ? "base64" : "utf8");
|
||||
if (message.stream === "stderr") process.stderr.write(chunk);
|
||||
else process.stdout.write(chunk);
|
||||
else if (stdoutForwarder === null) {
|
||||
process.stdout.write(chunk);
|
||||
} else {
|
||||
const hint = stdoutForwarder.write(chunk);
|
||||
if (hint !== null) process.stderr.write(hint);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (message.type === "ssh.error") {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
createSshStdoutForwarder,
|
||||
formatSshStdoutTruncationHint,
|
||||
parseSshInvocation,
|
||||
sshStdoutStreamMaxBytes,
|
||||
sshStdoutTruncationHint,
|
||||
windowsPowerShellScriptPrelude,
|
||||
} from "./ssh";
|
||||
|
||||
describe("ssh windows PowerShell safety prelude", () => {
|
||||
test("shadows ConvertTo-Json and strips filesystem ETS metadata", () => {
|
||||
const prelude = windowsPowerShellScriptPrelude("C:\\test");
|
||||
|
||||
expect(prelude).toContain("function ConvertTo-UniDeskPlainJsonValue");
|
||||
expect(prelude).toContain("function ConvertTo-Json");
|
||||
expect(prelude).toContain("'PSPath','PSParentPath','PSChildName','PSDrive','PSProvider','ReadCount'");
|
||||
expect(prelude).toContain("Microsoft.PowerShell.Utility\\ConvertTo-Json");
|
||||
expect(prelude).toContain("Set-Location -LiteralPath 'C:\\test'");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ssh stdout bounded streaming", () => {
|
||||
test("uses bounded defaults and clamps env override", () => {
|
||||
expect(sshStdoutStreamMaxBytes({} as NodeJS.ProcessEnv)).toBe(256 * 1024);
|
||||
expect(sshStdoutStreamMaxBytes({ UNIDESK_SSH_STDOUT_STREAM_MAX_BYTES: "8192" } as NodeJS.ProcessEnv)).toBe(8192);
|
||||
expect(sshStdoutStreamMaxBytes({ UNIDESK_SSH_STDOUT_STREAM_MAX_BYTES: "1" } as NodeJS.ProcessEnv)).toBe(4096);
|
||||
expect(sshStdoutStreamMaxBytes({ UNIDESK_SSH_STDOUT_STREAM_MAX_BYTES: String(64 * 1024 * 1024) } as NodeJS.ProcessEnv)).toBe(16 * 1024 * 1024);
|
||||
});
|
||||
|
||||
test("formats truncation hint without echoing remote command", () => {
|
||||
const invocation = parseSshInvocation("D601:win", ["ps"]);
|
||||
expect(invocation.parsed.remoteCommand).not.toBeNull();
|
||||
const hint = sshStdoutTruncationHint({
|
||||
invocation,
|
||||
transport: "backend-core-broker",
|
||||
thresholdBytes: 4096,
|
||||
observedBytesAtTruncation: 5000,
|
||||
dumpPath: "/tmp/unidesk-cli-output/sample.stdout.bin",
|
||||
});
|
||||
const formatted = formatSshStdoutTruncationHint(hint);
|
||||
|
||||
expect(formatted.startsWith("UNIDESK_SSH_STDOUT_TRUNCATED ")).toBe(true);
|
||||
const payload = JSON.parse(formatted.slice("UNIDESK_SSH_STDOUT_TRUNCATED ".length)) as Record<string, unknown>;
|
||||
expect(payload.code).toBe("ssh-stdout-truncated");
|
||||
expect(payload.providerId).toBe("D601");
|
||||
expect(payload.route).toBe("D601:win");
|
||||
expect(payload.thresholdBytes).toBe(4096);
|
||||
expect(formatted).not.toContain("Get-Content");
|
||||
});
|
||||
|
||||
test("forwarder bounds stdout and emits one truncation hint", () => {
|
||||
const invocation = parseSshInvocation("D601:win", ["ps"]);
|
||||
const forwarded: Buffer[] = [];
|
||||
const stdout = {
|
||||
write(chunk: string | Buffer): boolean {
|
||||
forwarded.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
return true;
|
||||
},
|
||||
} as NodeJS.WritableStream;
|
||||
const forwarder = createSshStdoutForwarder({
|
||||
invocation,
|
||||
transport: "frontend-websocket",
|
||||
maxBytes: 5,
|
||||
stdout,
|
||||
});
|
||||
|
||||
expect(forwarder.write(Buffer.from("abc"))).toBeNull();
|
||||
const hint = forwarder.write(Buffer.from("defgh"));
|
||||
expect(hint).not.toBeNull();
|
||||
expect(forwarder.write(Buffer.from("ijkl"))).toBeNull();
|
||||
expect(Buffer.concat(forwarded).toString("utf8")).toBe("abcde");
|
||||
expect(hint).toContain("UNIDESK_SSH_STDOUT_TRUNCATED");
|
||||
expect(hint).toContain("\"transport\":\"frontend-websocket\"");
|
||||
});
|
||||
});
|
||||
+203
-2
@@ -1,5 +1,8 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { appendFileSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { type UniDeskConfig, repoRoot } from "./config";
|
||||
import {
|
||||
decodeApplyPatchV2BulkRead,
|
||||
@@ -88,6 +91,22 @@ export interface SshRuntimeTimeoutHint {
|
||||
note: string;
|
||||
}
|
||||
|
||||
export interface SshStdoutTruncationHint {
|
||||
code: "ssh-stdout-truncated";
|
||||
level: "warning";
|
||||
providerId: string;
|
||||
route: string;
|
||||
transport: "backend-core-broker" | "frontend-websocket";
|
||||
invocationKind: SshInvocationKind;
|
||||
thresholdBytes: number;
|
||||
observedBytesAtTruncation: number;
|
||||
dumpPath: string | null;
|
||||
dumpError: string | null;
|
||||
message: string;
|
||||
action: string;
|
||||
note: string;
|
||||
}
|
||||
|
||||
const argvQuotedSshSubcommands = new Set(["git", "rg", "grep", "sed", "nl", "stat", "du", "ls", "cat", "head", "tail", "wc", "pwd"]);
|
||||
const nativeK3sKubeconfig = "/etc/rancher/k3s/k3s.yaml";
|
||||
const windowsBridgeCwd = "/mnt/c/Windows";
|
||||
@@ -96,6 +115,10 @@ const windowsCmdExeNativePath = "C:\\Windows\\System32\\cmd.exe";
|
||||
const defaultSshSlowWarningMs = 10_000;
|
||||
const defaultSshRuntimeTimeoutMs = 60_000;
|
||||
const maxSshRuntimeTimeoutMs = 60_000;
|
||||
const defaultSshStdoutStreamMaxBytes = 256 * 1024;
|
||||
const minSshStdoutStreamMaxBytes = 4 * 1024;
|
||||
const maxSshStdoutStreamMaxBytes = 16 * 1024 * 1024;
|
||||
const sshStdoutDumpDir = join(tmpdir(), "unidesk-cli-output");
|
||||
export const sshShellCompatibilityPrelude = 'printf(){ if [ "${1+x}" = x ] && [ "$1" = "-v" ] && [ -n "${BASH_VERSION:-}" ]; then command printf "$@"; return $?; fi; if [ "${1+x}" = x ] && [ "$1" = "--" ]; then shift; fi; command printf -- "$@"; }';
|
||||
export const sshUserToolPathPrelude = [
|
||||
'for unidesk_path_dir in "$HOME/.bun/bin" "$HOME/.local/bin" "$HOME/bin" "/root/.bun/bin"; do',
|
||||
@@ -1236,7 +1259,58 @@ function buildWindowsCmdStdinLauncherScript(cwd: string | null): string {
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
function windowsPowerShellScriptPrelude(cwd: string | null): string {
|
||||
const windowsPowerShellJsonSafetyPreludeLines = [
|
||||
"function ConvertTo-UniDeskPlainJsonValue {",
|
||||
" param([object]$Value, [int]$Depth = 6, [int]$Level = 0)",
|
||||
" if ($null -eq $Value) { return $null }",
|
||||
" if ($Value -is [string]) { return [string]$Value }",
|
||||
" if ($Value -is [char]) { return [string]$Value }",
|
||||
" if ($Value -is [bool] -or $Value -is [byte] -or $Value -is [sbyte] -or $Value -is [int16] -or $Value -is [uint16] -or $Value -is [int] -or $Value -is [uint32] -or $Value -is [long] -or $Value -is [uint64] -or $Value -is [single] -or $Value -is [double] -or $Value -is [decimal]) { return $Value }",
|
||||
" if ($Value -is [datetime]) { return $Value.ToUniversalTime().ToString('o') }",
|
||||
" if ($Level -ge $Depth) { return [string]$Value }",
|
||||
" if ($Value -is [System.Collections.IDictionary]) {",
|
||||
" $map = [ordered]@{}",
|
||||
" foreach ($key in $Value.Keys) { $map[[string]$key] = ConvertTo-UniDeskPlainJsonValue -Value $Value[$key] -Depth $Depth -Level ($Level + 1) }",
|
||||
" return $map",
|
||||
" }",
|
||||
" if ($Value -is [System.Collections.IEnumerable] -and -not ($Value -is [string])) {",
|
||||
" $items = New-Object System.Collections.ArrayList",
|
||||
" foreach ($item in $Value) { [void]$items.Add((ConvertTo-UniDeskPlainJsonValue -Value $item -Depth $Depth -Level ($Level + 1))) }",
|
||||
" return $items.ToArray()",
|
||||
" }",
|
||||
" $skip = @('PSPath','PSParentPath','PSChildName','PSDrive','PSProvider','ReadCount')",
|
||||
" $props = @($Value.PSObject.Properties | Where-Object { @('NoteProperty','Property','AliasProperty') -contains $_.MemberType.ToString() -and -not ($skip -contains $_.Name) })",
|
||||
" if ($props.Count -eq 0) { return [string]$Value }",
|
||||
" $out = [ordered]@{}",
|
||||
" foreach ($prop in $props) {",
|
||||
" try { $out[$prop.Name] = ConvertTo-UniDeskPlainJsonValue -Value $prop.Value -Depth $Depth -Level ($Level + 1) }",
|
||||
" catch { $out[$prop.Name] = '<unreadable>' }",
|
||||
" }",
|
||||
" return $out",
|
||||
"}",
|
||||
"function ConvertTo-Json {",
|
||||
" [CmdletBinding()]",
|
||||
" param(",
|
||||
" [Parameter(ValueFromPipeline=$true)] [object]$InputObject,",
|
||||
" [int]$Depth = 2,",
|
||||
" [switch]$Compress,",
|
||||
" [switch]$EnumsAsStrings",
|
||||
" )",
|
||||
" begin { $items = New-Object System.Collections.ArrayList }",
|
||||
" process { [void]$items.Add($InputObject) }",
|
||||
" end {",
|
||||
" if ($items.Count -eq 0) { $value = $null } elseif ($items.Count -eq 1) { $value = $items[0] } else { $value = $items.ToArray() }",
|
||||
" $plain = ConvertTo-UniDeskPlainJsonValue -Value $value -Depth $Depth -Level 0",
|
||||
" if ($Compress) {",
|
||||
" Microsoft.PowerShell.Utility\\ConvertTo-Json -InputObject $plain -Depth $Depth -Compress",
|
||||
" } else {",
|
||||
" Microsoft.PowerShell.Utility\\ConvertTo-Json -InputObject $plain -Depth $Depth",
|
||||
" }",
|
||||
" }",
|
||||
"}",
|
||||
];
|
||||
|
||||
export function windowsPowerShellScriptPrelude(cwd: string | null): string {
|
||||
return [
|
||||
"$ErrorActionPreference = 'Stop'",
|
||||
"$ProgressPreference = 'SilentlyContinue'",
|
||||
@@ -1245,6 +1319,7 @@ function windowsPowerShellScriptPrelude(cwd: string | null): string {
|
||||
"$OutputEncoding = [System.Text.UTF8Encoding]::new()",
|
||||
"$env:PYTHONUTF8 = '1'",
|
||||
"$env:PYTHONIOENCODING = 'utf-8'",
|
||||
...windowsPowerShellJsonSafetyPreludeLines,
|
||||
...(cwd === null ? [] : [`Set-Location -LiteralPath ${powerShellSingleQuote(cwd)}`]),
|
||||
].join("\r\n") + "\r\n";
|
||||
}
|
||||
@@ -2274,6 +2349,118 @@ export function formatSshRuntimeTimeoutHint(hint: SshRuntimeTimeoutHint): string
|
||||
return `UNIDESK_SSH_RUNTIME_TIMEOUT ${JSON.stringify(hint)}\n`;
|
||||
}
|
||||
|
||||
export function sshStdoutStreamMaxBytes(env: NodeJS.ProcessEnv = process.env): number {
|
||||
const raw = env.UNIDESK_SSH_STDOUT_STREAM_MAX_BYTES ?? env.UNIDESK_TRAN_STDOUT_STREAM_MAX_BYTES;
|
||||
const parsed = raw === undefined ? NaN : Number(raw);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return defaultSshStdoutStreamMaxBytes;
|
||||
return Math.min(maxSshStdoutStreamMaxBytes, Math.max(minSshStdoutStreamMaxBytes, Math.trunc(parsed)));
|
||||
}
|
||||
|
||||
export function sshStdoutTruncationHint(options: {
|
||||
invocation: ParsedSshInvocation;
|
||||
transport: SshStdoutTruncationHint["transport"];
|
||||
thresholdBytes: number;
|
||||
observedBytesAtTruncation: number;
|
||||
dumpPath: string | null;
|
||||
dumpError?: string | null;
|
||||
}): SshStdoutTruncationHint {
|
||||
return {
|
||||
code: "ssh-stdout-truncated",
|
||||
level: "warning",
|
||||
providerId: safeProviderId(options.invocation.providerId),
|
||||
route: options.invocation.route.raw,
|
||||
transport: options.transport,
|
||||
invocationKind: options.invocation.parsed.invocationKind,
|
||||
thresholdBytes: options.thresholdBytes,
|
||||
observedBytesAtTruncation: options.observedBytesAtTruncation,
|
||||
dumpPath: options.dumpPath,
|
||||
dumpError: options.dumpError ?? null,
|
||||
message: `ssh stdout exceeded ${options.thresholdBytes} bytes; stdout is bounded and the complete stream is written to a local dump when possible.`,
|
||||
action: "Inspect the dump path, or rerun a narrower remote command with tail/paging instead of emitting full logs or huge JSON.",
|
||||
note: "This hint is written to stderr and intentionally does not echo the original remote command.",
|
||||
};
|
||||
}
|
||||
|
||||
export function formatSshStdoutTruncationHint(hint: SshStdoutTruncationHint): string {
|
||||
return `UNIDESK_SSH_STDOUT_TRUNCATED ${JSON.stringify(hint)}\n`;
|
||||
}
|
||||
|
||||
function sshStdoutDumpPath(invocation: ParsedSshInvocation): string {
|
||||
mkdirSync(sshStdoutDumpDir, { recursive: true, mode: 0o700 });
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/gu, "-");
|
||||
const suffix = randomBytes(4).toString("hex");
|
||||
const slug = `${invocation.providerId}-${invocation.route.raw}-${invocation.route.plane}`
|
||||
.replace(/[^A-Za-z0-9._-]+/gu, "-")
|
||||
.replace(/^-+|-+$/gu, "")
|
||||
.slice(0, 80) || "ssh";
|
||||
return join(sshStdoutDumpDir, `${timestamp}-${process.pid}-${suffix}-${slug}.stdout.bin`);
|
||||
}
|
||||
|
||||
export function createSshStdoutForwarder(options: {
|
||||
invocation: ParsedSshInvocation;
|
||||
transport: SshStdoutTruncationHint["transport"];
|
||||
maxBytes?: number;
|
||||
stdout?: NodeJS.WritableStream;
|
||||
}): { write: (chunk: Buffer) => string | null } {
|
||||
const stdout = options.stdout ?? process.stdout;
|
||||
const maxBytes = options.maxBytes ?? sshStdoutStreamMaxBytes();
|
||||
let observedBytes = 0;
|
||||
let forwardedBytes = 0;
|
||||
let truncated = false;
|
||||
let dumpPath: string | null = null;
|
||||
let dumpError: string | null = null;
|
||||
const bufferedChunks: Buffer[] = [];
|
||||
|
||||
const appendDump = (chunk: Buffer): void => {
|
||||
if (dumpError !== null) return;
|
||||
try {
|
||||
if (dumpPath === null) {
|
||||
dumpPath = sshStdoutDumpPath(options.invocation);
|
||||
writeFileSync(dumpPath, Buffer.alloc(0), { mode: 0o600 });
|
||||
for (const buffered of bufferedChunks) appendFileSync(dumpPath, buffered);
|
||||
bufferedChunks.length = 0;
|
||||
}
|
||||
appendFileSync(dumpPath, chunk);
|
||||
} catch (error) {
|
||||
dumpError = error instanceof Error ? error.message : String(error);
|
||||
dumpPath = null;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
write(chunk: Buffer): string | null {
|
||||
observedBytes += chunk.length;
|
||||
if (!truncated && observedBytes <= maxBytes) {
|
||||
bufferedChunks.push(Buffer.from(chunk));
|
||||
stdout.write(chunk);
|
||||
forwardedBytes += chunk.length;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!truncated) {
|
||||
truncated = true;
|
||||
const remaining = Math.max(0, maxBytes - forwardedBytes);
|
||||
if (remaining > 0) {
|
||||
stdout.write(chunk.subarray(0, remaining));
|
||||
forwardedBytes += remaining;
|
||||
}
|
||||
appendDump(chunk);
|
||||
return formatSshStdoutTruncationHint(sshStdoutTruncationHint({
|
||||
invocation: options.invocation,
|
||||
transport: options.transport,
|
||||
thresholdBytes: maxBytes,
|
||||
observedBytesAtTruncation: observedBytes,
|
||||
dumpPath,
|
||||
dumpError,
|
||||
}));
|
||||
}
|
||||
|
||||
appendDump(chunk);
|
||||
return null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function brokerSource(): string {
|
||||
return String.raw`
|
||||
const open = JSON.parse(process.argv[2] || process.argv[1] || "{}");
|
||||
@@ -2753,7 +2940,21 @@ export async function runSsh(config: UniDeskConfig, providerId: string, args: st
|
||||
const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : chunk;
|
||||
stderrTail = (stderrTail + text).slice(-16_384);
|
||||
};
|
||||
child.stdout.pipe(process.stdout);
|
||||
if (parsed.remoteCommand === null) {
|
||||
child.stdout.pipe(process.stdout);
|
||||
} else {
|
||||
const stdoutForwarder = createSshStdoutForwarder({
|
||||
invocation,
|
||||
transport: "backend-core-broker",
|
||||
});
|
||||
child.stdout.on("data", (chunk: Buffer) => {
|
||||
const hint = stdoutForwarder.write(chunk);
|
||||
if (hint !== null) {
|
||||
appendStderrTail(hint);
|
||||
process.stderr.write(hint);
|
||||
}
|
||||
});
|
||||
}
|
||||
child.stderr.on("data", (chunk: Buffer) => {
|
||||
appendStderrTail(chunk);
|
||||
process.stderr.write(chunk);
|
||||
|
||||
Reference in New Issue
Block a user