Skip to content

Commit 1d4704e

Browse files
committed
fix(backup): reject pre-backup audit rows that a tab desynchronised
The pre-backup state-dir audit parses `find -printf "%y\t%p\t%l\n"` rows by splitting on tab and reading the third field as the link target. A tab is a legal byte in a Linux filename, so a path containing one splits the row into extra fields: the real link target moves past the third field and is discarded, and a fragment of the row is checked against the symlink whitelist in its place. A symlink the audit is meant to reject is then recorded as allowed. Extra fields can only come from such a tab, so classify those rows as violations. Shorter rows stay on the existing paths: the audit output is trimmed as a whole, which removes the trailing empty `%l` of a final non-symlink row. Row classification moves into `classifyPreBackupAuditRow` so the field-count check does not push `backupSandboxState` past its cognitive-complexity limit; measured 148 before, 135 after. Signed-off-by: Udaya Tejas <udayatejas2004@gmail.com>
1 parent 390e3bb commit 1d4704e

2 files changed

Lines changed: 71 additions & 20 deletions

File tree

src/lib/state/sandbox.ts

Lines changed: 38 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1307,6 +1307,38 @@ function resolveOpenClawBackupMetadata(
13071307
};
13081308
}
13091309

1310+
/**
1311+
* Classify one pre-backup audit row emitted by
1312+
* `find -printf "%y\t%p\t%l\n"`, where `dirPrefix` is the state-dir root the
1313+
* row's absolute path is reported under.
1314+
*
1315+
* A tab is a legal byte in a Linux filename, so a crafted path splits the row
1316+
* into extra fields: the real link target moves past the third field and is
1317+
* discarded, and a fragment of the row is checked against the symlink
1318+
* whitelist in its place, so an entry the audit is meant to reject can be
1319+
* recorded as allowed. Extra fields can only come from such a tab, so a row
1320+
* that carries them is a violation rather than something to audit further.
1321+
* Shorter rows are ordinary: the audit output is trimmed as a whole, which
1322+
* removes the trailing empty `%l` of a final non-symlink row.
1323+
*/
1324+
function classifyPreBackupAuditRow(
1325+
entry: string,
1326+
dirPrefix: string,
1327+
): "whitelisted" | "hardLinked" | "violation" {
1328+
const parts = entry.split("\t");
1329+
if (parts.length > 3) return "violation";
1330+
const type = parts[0] || "";
1331+
const absPath = parts[1] || entry;
1332+
const linkTarget = parts[2] || "";
1333+
const relPath = absPath.startsWith(dirPrefix) ? absPath.slice(dirPrefix.length) : absPath;
1334+
if (type === "l" && isAllowedStateSymlink(relPath, linkTarget)) return "whitelisted";
1335+
// The audit's `find` only emits regular files through its `-links +1`
1336+
// branch, so a reported `f` row is a hard link. Recorded, not rejected —
1337+
// see the rationale at the audit command (#9314).
1338+
if (type === "f") return "hardLinked";
1339+
return "violation";
1340+
}
1341+
13101342
export function backupSandboxState(sandboxName: string, options: BackupOptions = {}): BackupResult {
13111343
const sb = registry.getSandbox(sandboxName);
13121344
const agentName = sb?.agent || "openclaw";
@@ -1565,9 +1597,10 @@ export function backupSandboxState(sandboxName: string, options: BackupOptions =
15651597
// dependency failed its pre-upgrade backup.
15661598
//
15671599
// The printf format emits "<type>\t<absPath>\t<linkTarget>" — %l is
1568-
// empty for non-symlinks but always present, so the field count is
1569-
// stable. Tab separator assumes state-dir paths don't contain tabs,
1570-
// matching the wider convention in this file.
1600+
// empty for non-symlinks but always present, so a well-formed row has
1601+
// three fields. A tab is a legal byte in a Linux filename, so
1602+
// `classifyPreBackupAuditRow` enforces that count instead of assuming
1603+
// it.
15711604
// Per-dir `find` invocations are joined with `;` (not `&&`) and each
15721605
// is tolerant of its own exit code via `|| true`. The base image bakes
15731606
// a few state subdirs as root-owned (e.g. `extensions/<plugin>`,
@@ -1611,26 +1644,11 @@ export function backupSandboxState(sandboxName: string, options: BackupOptions =
16111644
const hardLinked: string[] = [];
16121645
const violations: string[] = [];
16131646
const dirPrefix = `${dir}/`;
1647+
const rows = { whitelisted, hardLinked, violation: violations };
16141648
for (const entry of allEntries) {
16151649
// find -printf "%y\t%p\t%l\n" → "<type>\t<absPath>\t<linkTarget>"
16161650
// (linkTarget is empty for non-symlinks).
1617-
const parts = entry.split("\t");
1618-
const type = parts[0] || "";
1619-
const absPath = parts[1] || entry;
1620-
const linkTarget = parts[2] || "";
1621-
const relPath = absPath.startsWith(dirPrefix)
1622-
? absPath.slice(dirPrefix.length)
1623-
: absPath;
1624-
if (type === "l" && isAllowedStateSymlink(relPath, linkTarget)) {
1625-
whitelisted.push(entry);
1626-
} else if (type === "f") {
1627-
// The audit's `find` only emits regular files through its
1628-
// `-links +1` branch, so a reported `f` row is a hard link.
1629-
// Recorded, not rejected — see the rationale above (#9314).
1630-
hardLinked.push(entry);
1631-
} else {
1632-
violations.push(entry);
1633-
}
1651+
rows[classifyPreBackupAuditRow(entry, dirPrefix)].push(entry);
16341652
}
16351653
if (whitelisted.length > 0) {
16361654
_log(

test/snapshot-backup-audit-hardlinks.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@
99
* `lazy-packages`. Rejecting those aborted the whole pre-upgrade backup
1010
* (#9314). They are now recorded and archived; symlink and special-file
1111
* rejection is unchanged.
12+
*
13+
* The same harness covers how a row is parsed. Rows are tab-delimited, and a
14+
* tab is a legal byte in a Linux filename, so a crafted path must not be able
15+
* to desynchronise the split.
1216
*/
1317

1418
import fs from "node:fs";
@@ -200,3 +204,32 @@ describe("pre-backup audit — multiply-linked regular files (#9314)", () => {
200204
expect(backup.error).toContain("agent.sock");
201205
});
202206
});
207+
208+
describe("pre-backup audit — tab-delimited row parsing", () => {
209+
it("rejects a symlink row carrying a tab in its path", () => {
210+
// `x<TAB>../qq/evil` is one filename an agent can create inside its own
211+
// state dir. `find` emits its bytes verbatim, so the row splits into four
212+
// fields: the audit reads `../qq/evil` as the link target — a shape the
213+
// npm-bin whitelist accepts — and discards the real target in the fourth
214+
// field, whitelisting a symlink it is meant to reject.
215+
const backup = backupWithAuditOutput(
216+
"l\t/sandbox/.openclaw/extensions/example-not-a-real-value-1/node_modules/.bin/x\t../qq/evil\t/etc/passwd",
217+
);
218+
219+
expect(String(backup.error ?? "")).toMatch(/Pre-backup audit rejected/);
220+
expect(String(backup.error ?? "")).toContain("node_modules/.bin/x");
221+
expect(backup.success).toBe(false);
222+
});
223+
224+
it("keeps accepting a hard-link row whose empty link target was trimmed away", () => {
225+
// The audit output is trimmed as a whole, so the trailing empty %l of the
226+
// last row is removed and that row carries two fields. This is the normal
227+
// shape of every audit that ends on a non-symlink; it must not be read as
228+
// a malformed row.
229+
const backup = backupWithAuditOutput(
230+
"f\t/sandbox/.openclaw/workspace/lazy-packages/edge_tts/__init__.py\t",
231+
);
232+
233+
expect(backup.error ?? "").not.toMatch(/Pre-backup audit rejected/);
234+
});
235+
});

0 commit comments

Comments
 (0)