mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
refactor(agents): validate workspace attestation rows structurally, not by filename allowlist (#113686)
* refactor(agents): validate attestation hashes structurally * fix(agents): reject Windows reserved device names in attestation filenames * refactor(agents): close attestation filename set with an ASCII markdown charset
This commit is contained in:
committed by
GitHub
parent
1a11f5394e
commit
14da8a2523
@@ -49,6 +49,17 @@ function deleteState(targetDir: string): void {
|
||||
deleteWorkspaceState(prepareWorkspaceStateDeletion(targetDir));
|
||||
}
|
||||
|
||||
function insertPersistedAttestationHash(filename: string, sha256: string): void {
|
||||
const identity = resolveWorkspaceStateIdentity(workspaceDir());
|
||||
const db = openOpenClawStateDatabase().db;
|
||||
db.prepare(
|
||||
"INSERT INTO workspace_attestations (workspace_key, attested_at_ms, updated_at_ms) VALUES (?, 1, 1)",
|
||||
).run(identity.workspaceKey);
|
||||
db.prepare(
|
||||
"INSERT INTO workspace_generated_bootstrap_hashes (workspace_key, filename, sha256) VALUES (?, ?, ?)",
|
||||
).run(identity.workspaceKey, filename, sha256);
|
||||
}
|
||||
|
||||
describe("workspace state store", () => {
|
||||
it("round-trips setup and attestation state after a database restart", () => {
|
||||
const dir = workspaceDir();
|
||||
@@ -81,6 +92,44 @@ describe("workspace state store", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(["HEARTBEAT.md", "RETIRED.md"])(
|
||||
"reads a persisted hash for a retired or unknown bootstrap filename: %s",
|
||||
(filename) => {
|
||||
insertPersistedAttestationHash(filename, "a".repeat(64));
|
||||
|
||||
expect([
|
||||
...readWorkspaceStateSnapshot(workspaceDir()).attestation!.generatedHashes.entries(),
|
||||
]).toStrictEqual([[filename, "a".repeat(64)]]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
"../AGENTS.md",
|
||||
"nested\\AGENTS.md",
|
||||
"C:outside.md",
|
||||
"NUL.md",
|
||||
"com1.md",
|
||||
"CON.md",
|
||||
"COM¹.md",
|
||||
"CONIN$.md",
|
||||
"CONOUT$.md",
|
||||
".hidden.md",
|
||||
])("rejects an unsafe persisted attestation filename: %s", (filename) => {
|
||||
insertPersistedAttestationHash(filename, "a".repeat(64));
|
||||
|
||||
expect(() => readWorkspaceStateSnapshot(workspaceDir())).toThrow(
|
||||
"workspace attestation hash row is invalid",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a malformed persisted attestation hash", () => {
|
||||
insertPersistedAttestationHash("AGENTS.md", "a".repeat(63));
|
||||
|
||||
expect(() => readWorkspaceStateSnapshot(workspaceDir())).toThrow(
|
||||
"workspace attestation hash row is invalid",
|
||||
);
|
||||
});
|
||||
|
||||
it("never regresses persisted setup milestones", () => {
|
||||
const dir = workspaceDir();
|
||||
mergeWorkspaceSetupState(dir, { bootstrapSeededAt: "2026-07-16T01:00:00.000Z" }, 1_000);
|
||||
|
||||
@@ -18,15 +18,25 @@ import { resolveUserPath } from "../utils.js";
|
||||
export const WORKSPACE_SETUP_STATE_VERSION = 1 as const;
|
||||
export const WORKSPACE_ATTESTATION_RECENT_MS = 24 * 60 * 60 * 1000;
|
||||
export const WORKSPACE_LEGACY_STATE_MIGRATION_KIND = "legacy-workspace-setup-files";
|
||||
export const WORKSPACE_ATTESTED_BOOTSTRAP_FILENAMES: ReadonlySet<string> = new Set([
|
||||
"AGENTS.md",
|
||||
"SOUL.md",
|
||||
"TOOLS.md",
|
||||
"IDENTITY.md",
|
||||
"USER.md",
|
||||
"HEARTBEAT.md",
|
||||
]);
|
||||
const MAX_WORKSPACE_ATTESTATION_FILENAME_LENGTH = 255;
|
||||
const SHA256_HEX_PATTERN = /^[a-f0-9]{64}$/u;
|
||||
// Attested names are joined onto the workspace dir and read back, so keep the
|
||||
// accepted set closed rather than denying unsafe forms one at a time: a plain
|
||||
// ASCII markdown basename excludes separators, traversal, colons, NUL, and the
|
||||
// Win32 superscript/`CONIN$` device aliases in one rule.
|
||||
const SAFE_ATTESTATION_BASENAME = /^[A-Za-z0-9._-]+\.md$/u;
|
||||
// Win32 keeps these stems special even with an extension, so `NUL.md` names a
|
||||
// device rather than a workspace file; the charset above cannot catch them.
|
||||
const WINDOWS_RESERVED_DEVICE_STEMS = /^(?:con|prn|aux|nul|com[0-9]|lpt[0-9])$/iu;
|
||||
|
||||
export function isSafeWorkspaceAttestationFilename(filename: string): boolean {
|
||||
return (
|
||||
filename.length <= MAX_WORKSPACE_ATTESTATION_FILENAME_LENGTH &&
|
||||
SAFE_ATTESTATION_BASENAME.test(filename) &&
|
||||
!filename.startsWith(".") &&
|
||||
!WINDOWS_RESERVED_DEVICE_STEMS.test(filename.split(".")[0] ?? "")
|
||||
);
|
||||
}
|
||||
|
||||
function isCanonicalIsoTimestamp(value: string): boolean {
|
||||
const timestamp = new Date(value);
|
||||
@@ -322,8 +332,10 @@ function readSnapshotFromDatabase(params: {
|
||||
.orderBy("filename", "asc"),
|
||||
).rows;
|
||||
for (const row of hashRows) {
|
||||
// Validate names structurally rather than against today's bootstrap set:
|
||||
// retiring a seeded file must not make an existing attestation unreadable.
|
||||
if (
|
||||
!WORKSPACE_ATTESTED_BOOTSTRAP_FILENAMES.has(row.filename) ||
|
||||
!isSafeWorkspaceAttestationFilename(row.filename) ||
|
||||
!SHA256_HEX_PATTERN.test(row.sha256)
|
||||
) {
|
||||
throw new Error("workspace attestation hash row is invalid");
|
||||
@@ -466,7 +478,7 @@ export function replaceWorkspaceAttestation(params: {
|
||||
assertCanonicalIntegerTimestamp(params.nowMs, "attestation update");
|
||||
}
|
||||
for (const [filename, sha256] of params.generatedHashes) {
|
||||
if (!WORKSPACE_ATTESTED_BOOTSTRAP_FILENAMES.has(filename) || !SHA256_HEX_PATTERN.test(sha256)) {
|
||||
if (!isSafeWorkspaceAttestationFilename(filename) || !SHA256_HEX_PATTERN.test(sha256)) {
|
||||
throw new Error("workspace attestation hash is invalid");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { LEGACY_WORKSPACE_ATTESTATION_HEADER } from "../agents/workspace-legacy-state.js";
|
||||
import {
|
||||
WORKSPACE_ATTESTED_BOOTSTRAP_FILENAMES,
|
||||
WORKSPACE_LEGACY_STATE_MIGRATION_KIND,
|
||||
WORKSPACE_SETUP_STATE_VERSION,
|
||||
isSafeWorkspaceAttestationFilename,
|
||||
registerWorkspaceStateAliasesInTransaction,
|
||||
} from "../agents/workspace-state-store.js";
|
||||
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
|
||||
@@ -129,7 +129,7 @@ function parseAttestation(snapshot: SourceSnapshot): ParsedSource {
|
||||
const generatedHashes = new Map<string, string>();
|
||||
for (const line of lines.slice(2)) {
|
||||
const match = /^generated:([^:]+):([a-f0-9]{64})$/.exec(line);
|
||||
if (!match?.[1] || !match[2] || !WORKSPACE_ATTESTED_BOOTSTRAP_FILENAMES.has(match[1])) {
|
||||
if (!match?.[1] || !match[2] || !isSafeWorkspaceAttestationFilename(match[1])) {
|
||||
throw new Error("legacy workspace attestation has an invalid generated hash");
|
||||
}
|
||||
if (generatedHashes.has(match[1])) {
|
||||
|
||||
Reference in New Issue
Block a user