mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 15:09:42 +02:00
fix(files): fail closed on remote symlinks, guard PUT for remote cases, bound ssh fan-out
Follow-up to #421 (remote-case file reads over ssh), addressing the review. Symlink escape on a host without `readlink -f` (blocker). The probe's portable fallback canonicalized only the directory chain and returned the final component unresolved, so on macOS < 12.3 `ws/notes.txt -> ~/.ssh/id_rsa` came back as `.../ws/notes.txt` (with the target's size), passed every containment and blocklist check that runs on `realPath`, and `cat` followed the link. The fallback now walks the directory chain with `cd -P`/`pwd -P` and follows the LAST component with plain `readlink` for a bounded number of hops, and anything it cannot fully resolve (a loop, a readlink failure, the hop cap) is reported with an `x` marker that parses as null, i.e. 404. It never returns the unresolved string. Measured on a real /bin/sh with `readlink -f` shadowed: the pre-fix script reports `/ws/notes.txt`, the fixed one `/secret/id_rsa`; both branches (native and fallback) now agree. `PUT /api/sessions/:id/file-content` never had the remote guard the PR described. It sits ahead of `validateSessionFilePath`, which resolves against the LOCAL filesystem, because with a same-named directory on the Codeman host (an sshfs mount of the remote tree, the documented stop-gap) the write landed on the local twin while the viewer believed it edited the remote file. ssh fan-out is bounded. `src/remote-ssh-limiter.ts` is a document-conversion-limiter-shaped semaphore (default 4, env `CODEMAN_MAX_REMOTE_FILE_SSH`) around every probe and buffered read; the attachment-history list resolves its whole history in ONE batched probe (`probeRemoteAttachmentHistory`, threaded into `registerExternalAttachment({remoteProbes})` so the guards run unchanged) instead of one handshake per entry; and probes chunk at 40 paths because the whole script is one argv string. Terminal output in a remote session is written on the remote host, so a prompt-injected agent printing hundreds of `codeman://attach` links forked one ssh per link, each holding a 20 s timeout, and a 100-entry history re-listed on every attachment:detected tripped OpenSSH's default MaxStartups. Streams are deliberately not counted (one per browser request, held for a whole playback, and gated behind a counted probe anyway). Smaller items from the same review: probe records are NUL-terminated and index-keyed after a leading NUL (a newline in a filename can no longer shift the alignment, and the banner is fenced off without last-N-lines guessing); size comes from `stat -c %s || stat -f %z`; the three IO functions refuse under VITEST instead of opening a connection; an unreachable host now reads as unknown (missing: false) for detected AND external history entries, where external used to fold its 502 into missing; a client that aborted during the guard probe has its body's ssh child reaped (`reply.raw.destroyed` is checked before the close listener is attached); `describeExecError` never returns Node's `Command failed: <ssh line>` message, which carried the identity path and the probe script into a 502 body; and the docs note that `isSensitivePath`'s three home-anchored entries resolve against the Codeman host's home, not the remote one. Tests: the probe script runs on a real /bin/sh with a `readlink` shim that rejects `-f` (the escape, a relative chain through a symlinked directory, a loop, a newline filename, banner chatter that itself looks like a record), the limiter's cap and FIFO order, and route tests for the PUT guard (local twin untouched, no connection), the single batched history probe, the unreachable-host alignment and the aborted-client reap. All four route tests fail against the pre-fix file-routes.ts. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
+154
-52
@@ -28,9 +28,10 @@
|
||||
|
||||
import { exec, spawn } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import type { Readable } from 'node:stream';
|
||||
import { PassThrough, type Readable } from 'node:stream';
|
||||
import type { SessionRemote } from './types/session.js';
|
||||
import { buildSshConnectionArgs, remoteSshTarget, shellescape } from './remote-hosts.js';
|
||||
import { runWithRemoteSshLimit } from './remote-ssh-limiter.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
@@ -50,6 +51,37 @@ const READ_BUFFER_SLACK_BYTES = 64 * 1024;
|
||||
/** Marker a probe prints when the path does not exist on the remote host. */
|
||||
const NOT_FOUND_MARKER = 'n';
|
||||
|
||||
/**
|
||||
* Marker a probe prints when the path exists but could NOT be canonicalized (no
|
||||
* `readlink -f`, and the portable fallback hit its hop cap or a `readlink` failure).
|
||||
* Parsed as `null`, i.e. 404: a path whose real target is unknown must never be
|
||||
* served, because every containment and blocklist check runs on the resolved path.
|
||||
*/
|
||||
const UNRESOLVABLE_MARKER = 'x';
|
||||
|
||||
/**
|
||||
* Paths per ssh round trip. The whole remote script is ONE shellescaped argument,
|
||||
* and Linux caps a single argv string at 128 KiB, so a 100-entry attachment history
|
||||
* of long paths is split rather than risking `E2BIG` on the local `sh`.
|
||||
*/
|
||||
const REMOTE_PROBE_CHUNK_SIZE = 40;
|
||||
|
||||
/** Symlink hops the portable resolver follows before giving up (Linux uses 40). */
|
||||
const REMOTE_SYMLINK_MAX_HOPS = 40;
|
||||
|
||||
/**
|
||||
* Under vitest no real ssh connection may ever be opened (mirrors
|
||||
* `checkRemoteTmuxAvailable` and friends in remote-hosts.ts). The route tests mock
|
||||
* this module, so nothing reaches here today; this is what keeps the NEXT
|
||||
* remote-session test that touches a file route from opening a connection from CI.
|
||||
* A clear 502-shaped error, never a fake success: there are no fake bytes to return.
|
||||
*/
|
||||
function assertNotUnderTest(): void {
|
||||
if (process.env.VITEST) {
|
||||
throw new RemoteFileAccessError('remote file access is disabled under test');
|
||||
}
|
||||
}
|
||||
|
||||
/** What a remote path turned out to be. `other` = symlink/socket/fifo/device. */
|
||||
export type RemotePathKind = 'file' | 'directory' | 'other';
|
||||
|
||||
@@ -95,40 +127,67 @@ export function buildRemoteFileCommand(remote: SessionRemote, shellCommand: stri
|
||||
* every extra `ssh` is a fresh handshake, and the file routes need the path AND the
|
||||
* workspace root canonicalized to compare them.
|
||||
*
|
||||
* Each path emits exactly one line — `n` when it does not exist, otherwise
|
||||
* `kind|size|mtime|realPath` with `realPath` LAST so a path containing `|` still
|
||||
* parses (the earlier fields are fixed and the remainder is the path).
|
||||
* Output format: the script first prints a lone NUL, then one NUL-terminated record
|
||||
* per path, `<index>|n` (missing), `<index>|x` (exists but cannot be canonicalized) or
|
||||
* `<index>|kind|size|mtime|realPath`. Records are keyed by INDEX and separated by NUL
|
||||
* rather than newline so that a remote filename containing a newline cannot shift the
|
||||
* alignment, and the leading NUL is what separates a login banner or an eager rc-file
|
||||
* `echo` (which land before the script runs) from the records without any "last N
|
||||
* lines" guesswork. `realPath` is the last field, so a `|` in a path still parses.
|
||||
*
|
||||
* Symlink resolution is portable on purpose: `readlink -f` where available (Linux,
|
||||
* macOS >= 12.3), else the POSIX `cd`/`pwd -P` fallback, which resolves the DIRECTORY
|
||||
* chain. Resolution is required here rather than optional: `isSensitivePath()`
|
||||
* demands an already-realpath'd input, so a remote read must not be able to reach a
|
||||
* blocked target through a symlink any more than a local one can.
|
||||
* Symlink resolution is portable AND fails closed. `readlink -f` where available
|
||||
* (Linux, macOS >= 12.3); otherwise the fallback canonicalizes the directory chain
|
||||
* with `cd -P`/`pwd -P` and then follows the LAST component with plain `readlink`
|
||||
* (which the systems lacking `-f` do have) for a bounded number of hops. A path the
|
||||
* fallback cannot resolve prints `x`, never the unresolved string: every containment
|
||||
* and blocklist check downstream runs on `realPath`, and an earlier version of this
|
||||
* fallback returned the directory-resolved path with the final symlink still in it,
|
||||
* so `ws/notes.txt -> ~/.ssh/id_rsa` passed containment while `cat` served the key.
|
||||
*/
|
||||
export function buildRemoteProbeCommand(paths: readonly string[]): string {
|
||||
const probes = paths.map((path) => `probe ${shellescape(path)}`).join('\n');
|
||||
const probes = paths.map((path, index) => `probe ${index} ${shellescape(path)}`).join('\n');
|
||||
return [
|
||||
'resolve_last() {',
|
||||
' q=$1',
|
||||
' hops=0',
|
||||
' while :; do',
|
||||
' d=$(cd -P "$(dirname "$q")" 2>/dev/null && pwd -P) || return 1',
|
||||
' q=$d/$(basename "$q")',
|
||||
' [ -L "$q" ] || break',
|
||||
' hops=$((hops + 1))',
|
||||
` [ "$hops" -le ${REMOTE_SYMLINK_MAX_HOPS} ] || return 1`,
|
||||
' l=$(readlink "$q" 2>/dev/null) || return 1',
|
||||
' [ -n "$l" ] || return 1',
|
||||
' case $l in /*) q=$l ;; *) q=$d/$l ;; esac',
|
||||
' done',
|
||||
' if [ -d "$q" ]; then q=$(cd -P "$q" 2>/dev/null && pwd -P) || return 1; fi',
|
||||
' printf %s "$q"',
|
||||
'}',
|
||||
'probe() {',
|
||||
' p=$1',
|
||||
' r=$(readlink -f "$p" 2>/dev/null) || r=$(cd "$(dirname "$p")" 2>/dev/null && printf %s/%s "$(pwd -P)" "$(basename "$p")")',
|
||||
' [ -n "$r" ] || r=$p',
|
||||
` if [ ! -e "$p" ]; then printf '%s\\n' ${NOT_FOUND_MARKER}; return; fi`,
|
||||
' i=$1',
|
||||
' p=$2',
|
||||
` if [ ! -e "$p" ]; then printf '%s|${NOT_FOUND_MARKER}\\0' "$i"; return; fi`,
|
||||
` r=$(readlink -f "$p" 2>/dev/null) || r=$(resolve_last "$p") || { printf '%s|${UNRESOLVABLE_MARKER}\\0' "$i"; return; }`,
|
||||
` [ -n "$r" ] || { printf '%s|${UNRESOLVABLE_MARKER}\\0' "$i"; return; }`,
|
||||
' if [ -d "$r" ]; then t=d; elif [ -f "$r" ]; then t=f; else t=o; fi',
|
||||
' s=0',
|
||||
' if [ "$t" = f ]; then s=$(wc -c < "$r" 2>/dev/null | tr -d " "); [ -n "$s" ] || s=0; fi',
|
||||
' if [ "$t" = f ]; then s=$(stat -c %s "$r" 2>/dev/null || stat -f %z "$r" 2>/dev/null); [ -n "$s" ] || s=0; fi',
|
||||
' m=$(stat -c %Y "$r" 2>/dev/null || stat -f %m "$r" 2>/dev/null || printf 0)',
|
||||
` printf '%s|%s|%s|%s\\n' "$t" "$s" "$m" "$r"`,
|
||||
` printf '%s|%s|%s|%s|%s\\0' "$i" "$t" "$s" "$m" "$r"`,
|
||||
'}',
|
||||
"printf '\\0'",
|
||||
probes,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/** Parse one probe line. `null` for the not-found marker or anything malformed. */
|
||||
export function parseRemoteProbeLine(line: string): RemoteProbe | null {
|
||||
const trimmed = line.replace(/\r$/, '');
|
||||
if (!trimmed || trimmed === NOT_FOUND_MARKER) return null;
|
||||
/**
|
||||
* Parse one probe record (index prefix already stripped). `null` for the not-found
|
||||
* and unresolvable markers or anything malformed.
|
||||
*/
|
||||
export function parseRemoteProbeRecord(record: string): RemoteProbe | null {
|
||||
if (!record || record === NOT_FOUND_MARKER || record === UNRESOLVABLE_MARKER) return null;
|
||||
|
||||
const parts = trimmed.split('|');
|
||||
const parts = record.split('|');
|
||||
if (parts.length < 4) return null;
|
||||
|
||||
const [kindRaw, sizeRaw, mtimeRaw] = parts;
|
||||
@@ -151,48 +210,77 @@ export function parseRemoteProbeLine(line: string): RemoteProbe | null {
|
||||
|
||||
/**
|
||||
* Parse the output of {@link buildRemoteProbeCommand} into one entry per requested
|
||||
* path, in order. Throws when the output cannot be one line per path — that means the
|
||||
* transport or the remote shell did something unexpected, and silently treating it as
|
||||
* "not found" would turn an infrastructure failure into a wrong 404.
|
||||
* path, in order. Throws when a path's record is missing: that means the transport
|
||||
* or the remote shell did something unexpected, and silently treating it as "not
|
||||
* found" would turn an infrastructure failure into a wrong 404.
|
||||
*
|
||||
* The LAST `paths.length` lines are used so a login banner or an eager rc-file `echo`
|
||||
* on the remote host cannot shift the alignment.
|
||||
* Everything before the first NUL is the remote shell's own chatter (banner, rc-file
|
||||
* output) and is discarded; records are matched by their index prefix, so neither
|
||||
* extra output nor a newline inside a filename can shift the mapping.
|
||||
*/
|
||||
export function parseRemoteProbeLines(stdout: string, paths: readonly string[]): Array<RemoteProbe | null> {
|
||||
const lines = stdout.split('\n').filter((line) => line !== '');
|
||||
if (lines.length < paths.length) {
|
||||
throw new RemoteFileAccessError('remote host returned no usable file information');
|
||||
export function parseRemoteProbeOutput(stdout: string, paths: readonly string[]): Array<RemoteProbe | null> {
|
||||
const records = stdout.split('\0').slice(1);
|
||||
const byIndex = new Map<number, string>();
|
||||
for (const record of records) {
|
||||
const match = /^(\d+)\|([\s\S]*)$/.exec(record);
|
||||
if (!match) continue;
|
||||
const index = Number.parseInt(match[1], 10);
|
||||
if (!byIndex.has(index)) byIndex.set(index, match[2]);
|
||||
}
|
||||
return lines.slice(-paths.length).map((line) => parseRemoteProbeLine(line));
|
||||
return paths.map((_, index) => {
|
||||
const record = byIndex.get(index);
|
||||
if (record === undefined) {
|
||||
throw new RemoteFileAccessError('remote host returned no usable file information');
|
||||
}
|
||||
return parseRemoteProbeRecord(record);
|
||||
});
|
||||
}
|
||||
|
||||
/** Probe one or more remote paths. Entry is `null` for a path that does not exist. */
|
||||
/**
|
||||
* Probe one or more remote paths. Entry is `null` for a path that does not exist (or
|
||||
* could not be canonicalized, which is refused the same way).
|
||||
*
|
||||
* Large batches are split into round trips of {@link REMOTE_PROBE_CHUNK_SIZE}, each
|
||||
* counted against the global ssh limiter, so an attachment history of 100 entries
|
||||
* costs three connections in sequence rather than 100 at once.
|
||||
*/
|
||||
export async function remoteProbePaths(
|
||||
remote: SessionRemote,
|
||||
paths: readonly string[]
|
||||
): Promise<Array<RemoteProbe | null>> {
|
||||
const command = buildRemoteFileCommand(remote, buildRemoteProbeCommand(paths));
|
||||
let stdout: string;
|
||||
try {
|
||||
const result = await execAsync(command, { timeout: REMOTE_PROBE_TIMEOUT_MS, maxBuffer: 64 * 1024 });
|
||||
stdout = result.stdout;
|
||||
} catch (err) {
|
||||
throw new RemoteFileAccessError(
|
||||
`remote host ${remote.label || remote.host} unreachable: ${describeExecError(err)}`
|
||||
);
|
||||
assertNotUnderTest();
|
||||
const results: Array<RemoteProbe | null> = [];
|
||||
for (let offset = 0; offset < paths.length; offset += REMOTE_PROBE_CHUNK_SIZE) {
|
||||
const chunk = paths.slice(offset, offset + REMOTE_PROBE_CHUNK_SIZE);
|
||||
const command = buildRemoteFileCommand(remote, buildRemoteProbeCommand(chunk));
|
||||
let stdout: string;
|
||||
try {
|
||||
const result = await runWithRemoteSshLimit(() =>
|
||||
execAsync(command, { timeout: REMOTE_PROBE_TIMEOUT_MS, maxBuffer: 256 * 1024 })
|
||||
);
|
||||
stdout = result.stdout;
|
||||
} catch (err) {
|
||||
throw new RemoteFileAccessError(
|
||||
`remote host ${remote.label || remote.host} unreachable: ${describeExecError(err)}`
|
||||
);
|
||||
}
|
||||
results.push(...parseRemoteProbeOutput(stdout, chunk));
|
||||
}
|
||||
return parseRemoteProbeLines(stdout, paths);
|
||||
return results;
|
||||
}
|
||||
|
||||
/** Read a whole remote file into memory, capped by `maxBytes`. */
|
||||
export async function remoteReadFile(remote: SessionRemote, remotePath: string, maxBytes: number): Promise<Buffer> {
|
||||
assertNotUnderTest();
|
||||
const command = buildRemoteFileCommand(remote, `cat ${shellescape(remotePath)}`);
|
||||
try {
|
||||
const result = await execAsync(command, {
|
||||
timeout: REMOTE_READ_TIMEOUT_MS,
|
||||
maxBuffer: maxBytes + READ_BUFFER_SLACK_BYTES,
|
||||
encoding: 'buffer',
|
||||
});
|
||||
const result = await runWithRemoteSshLimit(() =>
|
||||
execAsync(command, {
|
||||
timeout: REMOTE_READ_TIMEOUT_MS,
|
||||
maxBuffer: maxBytes + READ_BUFFER_SLACK_BYTES,
|
||||
encoding: 'buffer',
|
||||
})
|
||||
);
|
||||
return Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.from(result.stdout);
|
||||
} catch (err) {
|
||||
throw new RemoteFileAccessError(`failed to read remote file: ${describeExecError(err)}`);
|
||||
@@ -238,6 +326,13 @@ export function remoteCreateReadStream(
|
||||
remotePath: string,
|
||||
range?: { start: number; end: number }
|
||||
): RemoteFileStream {
|
||||
if (process.env.VITEST) {
|
||||
// Same rule as the buffered calls, in stream form: the consumer sees the error
|
||||
// through the stream's normal failure path instead of a connection attempt.
|
||||
const stream = new PassThrough();
|
||||
process.nextTick(() => stream.destroy(new RemoteFileAccessError('remote file access is disabled under test')));
|
||||
return { stream, close: () => stream.destroy() };
|
||||
}
|
||||
const command = buildRemoteFileCommand(remote, buildRemoteReadCommand(remotePath, range));
|
||||
const child = spawn(command, { shell: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
|
||||
@@ -277,20 +372,27 @@ export function remoteCreateReadStream(
|
||||
};
|
||||
}
|
||||
|
||||
/** First useful line of an exec/stderr error, for a user-facing message. */
|
||||
/**
|
||||
* First useful line of an exec/stderr error, for a user-facing message.
|
||||
*
|
||||
* ⚠️ Never Node's `err.message`: for a failed `exec` it is `Command failed: <the whole
|
||||
* ssh line>`, which carries the identity-file path and the probe script, and this
|
||||
* string goes out in a 502 body. stderr, the timeout flag and the exit/spawn code are
|
||||
* everything a user can act on.
|
||||
*/
|
||||
function describeExecError(err: unknown): string {
|
||||
if (typeof err === 'object' && err !== null) {
|
||||
const record = err as { stderr?: unknown; message?: unknown; code?: unknown; killed?: unknown };
|
||||
const record = err as { stderr?: unknown; code?: unknown; killed?: unknown };
|
||||
const stderr =
|
||||
typeof record.stderr === 'string' ? record.stderr : Buffer.isBuffer(record.stderr) ? String(record.stderr) : '';
|
||||
const line = stderr
|
||||
.split('\n')
|
||||
.map((entry) => entry.trim())
|
||||
.find((entry) => entry.length > 0);
|
||||
if (line) return line;
|
||||
if (line) return line.slice(0, 300);
|
||||
if (record.killed) return 'timed out';
|
||||
if (typeof record.message === 'string' && record.message.length > 0) return record.message;
|
||||
if (typeof record.code === 'string' || typeof record.code === 'number') return `ssh exit ${record.code}`;
|
||||
if (typeof record.code === 'number') return `ssh exit ${record.code}`;
|
||||
if (typeof record.code === 'string') return `ssh could not be started (${record.code})`;
|
||||
}
|
||||
return 'unknown error';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user