mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 22:19: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:
+108
-13
@@ -135,6 +135,16 @@ function sendRawStream(reply: FastifyReply, content: Readable, cleanup?: () => v
|
||||
// when the client goes away (tab closed, video seek, a cancelled fetch), or the
|
||||
// ssh process outlives the request. Registered here because this is the one place
|
||||
// that owns the response's lifecycle.
|
||||
//
|
||||
// ⚠️ Check BEFORE attaching: the guard probe that ran ahead of this is an ssh round
|
||||
// trip, and a client that gave up during it has already closed the response, so
|
||||
// `close` has already fired and a listener attached now would never run. The
|
||||
// `open()` call above still spawned the body's ssh child; reap it here instead.
|
||||
if (reply.raw.destroyed) {
|
||||
cleanup?.();
|
||||
content.destroy();
|
||||
return;
|
||||
}
|
||||
if (cleanup) {
|
||||
reply.raw.on('close', cleanup);
|
||||
}
|
||||
@@ -1010,12 +1020,64 @@ function getSessionAttachmentHistory(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The remote probes an attachment-history listing needs, resolved in ONE batch.
|
||||
*
|
||||
* The list route used to probe each entry on its own, i.e. one ssh handshake per
|
||||
* history item, up to `ATTACHMENT_HISTORY_LIMIT` (100) of them, and the attachments
|
||||
* drawer re-runs the route on every `attachment:detected` event while it is open,
|
||||
* which is exactly when an agent is writing files. OpenSSH's default
|
||||
* `MaxStartups 10:30:100` starts dropping connections at ten concurrent handshakes,
|
||||
* so most of such a burst simply failed. `remoteProbePaths` already takes an array
|
||||
* (and chunks it), so the whole history is one call, plus the global ssh limiter
|
||||
* bounding whatever is left.
|
||||
*/
|
||||
interface RemoteHistoryProbes {
|
||||
/** The workspace root, canonicalized on the remote host. */
|
||||
root: RemoteProbe | null;
|
||||
/** Keyed by the exact path handed to the probe (a lexical resolution or an external path). */
|
||||
byPath: Map<string, RemoteProbe | null>;
|
||||
/**
|
||||
* The batch itself failed (unreachable host). Every entry is then UNKNOWN, not
|
||||
* missing: reporting "missing" would tell the user their files are gone when the
|
||||
* host is merely asleep.
|
||||
*/
|
||||
unreachable: boolean;
|
||||
}
|
||||
|
||||
async function probeRemoteAttachmentHistory(
|
||||
scope: SessionFileScope,
|
||||
history: readonly SessionAttachmentHistoryItem[]
|
||||
): Promise<RemoteHistoryProbes | undefined> {
|
||||
const remote = scope.remote;
|
||||
if (!remote || history.length === 0) return undefined;
|
||||
|
||||
const paths = new Set<string>();
|
||||
for (const item of history) {
|
||||
if (item.source === 'external') {
|
||||
if (item.externalPath) paths.add(item.externalPath);
|
||||
} else if (item.relativePath) {
|
||||
const lexical = validateSessionFilePathLexical(scope.workingDir, item.relativePath);
|
||||
if (lexical) paths.add(lexical.resolvedPath);
|
||||
}
|
||||
}
|
||||
|
||||
const list = [...paths];
|
||||
try {
|
||||
const [root, ...rest] = await remoteProbePaths(remote, [scope.workingDir, ...list]);
|
||||
return { root, byPath: new Map(list.map((path, index) => [path, rest[index] ?? null])), unreachable: false };
|
||||
} catch {
|
||||
return { root: null, byPath: new Map(), unreachable: true };
|
||||
}
|
||||
}
|
||||
|
||||
// History item for a file detected inside the workspace: re-stat for live
|
||||
// size/mtime and resolve preview/thumbnail/raw routes off the relative path.
|
||||
async function buildDetectedAttachmentRouteItem(
|
||||
sessionId: string,
|
||||
scope: SessionFileScope,
|
||||
item: SessionAttachmentHistoryItem
|
||||
item: SessionAttachmentHistoryItem,
|
||||
batch?: RemoteHistoryProbes
|
||||
): Promise<AttachmentHistoryRouteItem> {
|
||||
const safe = sanitizeAttachmentHistoryItem(item);
|
||||
if (!item.relativePath) {
|
||||
@@ -1032,15 +1094,22 @@ async function buildDetectedAttachmentRouteItem(
|
||||
// inside the workspace), executed on the host that owns the files.
|
||||
const lexical = validateSessionFilePathLexical(workingDir, item.relativePath);
|
||||
if (!lexical) return { ...safe, missing: true };
|
||||
let probes: Array<RemoteProbe | null>;
|
||||
try {
|
||||
probes = await remoteProbePaths(scope.remote, [lexical.resolvedPath, workingDir]);
|
||||
} catch {
|
||||
// Unreachable host: the entry is not "missing", it is unknown. Reporting it as
|
||||
// missing would tell the user their file is gone when its host is merely asleep.
|
||||
return { ...safe, missing: false, size, mtimeMs };
|
||||
let probe: RemoteProbe | null;
|
||||
let rootProbe: RemoteProbe | null;
|
||||
if (batch) {
|
||||
// The list route resolved the whole history in one round trip.
|
||||
if (batch.unreachable) return { ...safe, missing: false, size, mtimeMs };
|
||||
probe = batch.byPath.get(lexical.resolvedPath) ?? null;
|
||||
rootProbe = batch.root;
|
||||
} else {
|
||||
try {
|
||||
[probe, rootProbe] = await remoteProbePaths(scope.remote, [lexical.resolvedPath, workingDir]);
|
||||
} catch {
|
||||
// Unreachable host: the entry is not "missing", it is unknown. Reporting it as
|
||||
// missing would tell the user their file is gone when its host is merely asleep.
|
||||
return { ...safe, missing: false, size, mtimeMs };
|
||||
}
|
||||
}
|
||||
const [probe, rootProbe] = probes;
|
||||
if (!probe || !isPathWithinRoot(rootProbe?.realPath ?? workingDir, probe.realPath)) {
|
||||
return { ...safe, missing: true };
|
||||
}
|
||||
@@ -1090,17 +1159,24 @@ async function buildDetectedAttachmentRouteItem(
|
||||
async function buildExternalAttachmentRouteItem(
|
||||
sessionId: string,
|
||||
item: SessionAttachmentHistoryItem,
|
||||
scope: SessionFileScope
|
||||
scope: SessionFileScope,
|
||||
batch?: RemoteHistoryProbes
|
||||
): Promise<AttachmentHistoryRouteItem> {
|
||||
const safe = sanitizeAttachmentHistoryItem(item);
|
||||
if (!item.externalPath) {
|
||||
return { ...safe, missing: true };
|
||||
}
|
||||
// Same answer as the detected branch for the same event: an unreachable host makes
|
||||
// the entry unknown, never missing.
|
||||
if (batch?.unreachable) {
|
||||
return { ...safe, missing: false };
|
||||
}
|
||||
|
||||
try {
|
||||
const event = await registerExternalAttachment(sessionId, item.externalPath, {
|
||||
sessionWorkingDir: scope.workingDir,
|
||||
remote: scope.remote,
|
||||
remoteProbes: batch ? [batch.byPath.get(item.externalPath) ?? null, batch.root] : undefined,
|
||||
});
|
||||
return {
|
||||
...safe,
|
||||
@@ -1118,7 +1194,9 @@ async function buildExternalAttachmentRouteItem(
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof AttachmentRegistrationError) {
|
||||
return { ...safe, missing: true };
|
||||
// 502 is the transport, not the file (see resolveRemoteAttachment): unknown,
|
||||
// like the detected branch. Anything else (404, 403, wrong kind) is missing.
|
||||
return { ...safe, missing: err.statusCode === 502 ? false : true };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
@@ -1815,6 +1893,20 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
async (req): Promise<ApiResponse<FileWriteData>> => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
// Remote WRITES are out of scope by design (docs/file-viewer-edit-plan.md §6),
|
||||
// and this guard must sit ahead of `validateSessionFilePath`: that helper
|
||||
// resolves against the LOCAL filesystem, so with a directory of the same
|
||||
// absolute name on this host (an sshfs mount of the remote tree, `/srv/case`,
|
||||
// a same-named home) the write would land on the local twin while the viewer
|
||||
// believes it edited the remote file. The read-remote/write-local split is
|
||||
// exactly what the no-local-fallback rule exists to prevent.
|
||||
if (session.remote) {
|
||||
throwFileEditError(
|
||||
400,
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'Editing is not supported for files in a remote (SSH) case'
|
||||
);
|
||||
}
|
||||
const body = parseBody(FileWriteSchema, req.body);
|
||||
|
||||
// Exact byte cap — the schema's .max() counts UTF-16 code units and is
|
||||
@@ -2056,11 +2148,14 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
return;
|
||||
}
|
||||
|
||||
// Remote: every entry's realpath + stat in one batched probe, never one ssh per
|
||||
// item (see probeRemoteAttachmentHistory). Local: undefined, each item stats itself.
|
||||
const batch = await probeRemoteAttachmentHistory(sessionHistory.scope, sessionHistory.history);
|
||||
const items = await Promise.all(
|
||||
sessionHistory.history.map((item) =>
|
||||
(item.source === 'external'
|
||||
? buildExternalAttachmentRouteItem(id, item, sessionHistory.scope)
|
||||
: buildDetectedAttachmentRouteItem(id, sessionHistory.scope, item)
|
||||
? buildExternalAttachmentRouteItem(id, item, sessionHistory.scope, batch)
|
||||
: buildDetectedAttachmentRouteItem(id, sessionHistory.scope, item, batch)
|
||||
).catch(() => ({ ...sanitizeAttachmentHistoryItem(item), missing: true }))
|
||||
)
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user