fix: sanitize workspace API envelopes

This commit is contained in:
2026-08-09 20:38:54 +02:00
parent 80aa989523
commit a681c431fb
2 changed files with 103 additions and 9 deletions
+49 -1
View File
@@ -41,7 +41,7 @@ test("uploads a workspace bundle without JSON content type", async () => {
let contentType: string | null = null;
server.use(http.post("/api/workspaces/import", ({ request }) => {
contentType = request.headers.get("content-type");
return HttpResponse.json({ draft: { workspace: {} } });
return HttpResponse.json({ draft: { workspace } });
}));
await importWorkspace(new File(["zip"], "clinical.thoth-workspace.zip", { type: "application/zip" }));
@@ -52,6 +52,54 @@ test("uploads a workspace bundle without JSON content type", async () => {
expect(contentType ?? "").not.toMatch(/application\/json/i);
});
test("sanitizes imported Evidence before returning a browser draft", async () => {
server.use(http.post("/api/workspaces/import", () => HttpResponse.json({
draft: { workspace: evidenceWorkspace, contract: { variables: [] } },
})));
const result = await importWorkspace(new File(["zip"], "clinical.thoth-workspace.zip"));
expect(result.draft.workspace.evidence).toEqual(evidenceWorkspace.evidence);
expect(result.draft.workspace).not.toBe(evidenceWorkspace);
});
test("rejects imported Evidence with a secret-shaped field", async () => {
const malformed = {
...evidenceWorkspace,
evidence: { ...evidenceWorkspace.evidence, signed_urls_file: "/run/secrets/urls" },
};
server.use(http.post("/api/workspaces/import", () => HttpResponse.json({
draft: { workspace: malformed, contract: {} },
})));
await expect(importWorkspace(new File(["zip"], "clinical.thoth-workspace.zip")))
.rejects.toThrow("invalid imported workspace draft");
});
test("rejects read responses with a missing or inconsistent revision", async () => {
server.use(http.get("/api/workspaces/psd-clinical", () => HttpResponse.json({
workspace: evidenceWorkspace,
revision: { ...revision, id: "other-workspace" },
})));
await expect(getWorkspace("psd-clinical")).rejects.toThrow("invalid workspace revision");
server.use(http.get("/api/workspaces/psd-clinical", () => HttpResponse.json({
workspace: evidenceWorkspace, revision: null,
})));
await expect(getWorkspace("psd-clinical")).rejects.toThrow("invalid workspace revision");
});
test("rejects a publish response with a malformed revision", async () => {
server.use(http.post("/api/workspaces/publish", () => HttpResponse.json({
revision: { ...revision, commit: "not-a-commit" },
})));
await expect(publishWorkspace({
action: "update", workspace: evidenceWorkspace,
baseCommit: revision.commit, baseBlob: revision.blob,
})).rejects.toThrow("invalid workspace revision");
});
test("rejects a conflict payload that attempts to surface a secret field", async () => {
server.use(http.post("/api/workspaces/publish", () => HttpResponse.json({
code: "workspace_conflict", message: "Workspace changed in the registry.", fields: ["dwh.password"],
+54 -8
View File
@@ -200,6 +200,33 @@ function object(value: unknown): Record<string, unknown> | undefined {
: undefined;
}
function exactObject(value: unknown, keys: readonly string[]): Record<string, unknown> | undefined {
const source = object(value);
return source && Object.keys(source).every((key) => keys.includes(key)) ? source : undefined;
}
function workspaceRevision(value: unknown, expectedId: string): WorkspaceRevision | undefined {
const source = exactObject(value, ["id", "commit", "blob", "snapshotPath", "state"]);
if (!source) return undefined;
const { id, commit, blob, snapshotPath, state } = source;
if (
id !== expectedId
|| typeof id !== "string" || !/^[a-z][a-z0-9-]{2,62}$/.test(id)
|| typeof commit !== "string" || !/^[0-9a-f]{40}$/.test(commit)
|| typeof blob !== "string" || !/^[0-9a-f]{40}$/.test(blob)
|| typeof snapshotPath !== "string" || snapshotPath.length === 0
|| snapshotPath.trim() !== snapshotPath || /[\u0000-\u001f\u007f]/u.test(snapshotPath)
|| (state !== "operational" && state !== "migration_required")
) return undefined;
return { id, commit, blob, snapshotPath, state };
}
function requireWorkspaceRevision(value: unknown, expectedId: string): WorkspaceRevision {
const revision = workspaceRevision(value, expectedId);
if (!revision) throw new Error("Workspace API returned an invalid workspace revision");
return revision;
}
function conflictRevision(value: unknown): WorkspaceConflictRevision | undefined {
const source = object(value);
const commit = source?.commit;
@@ -258,9 +285,10 @@ export const getWorkspace = async (id: string): Promise<WorkspaceRecord> => {
const response = await apiFetch<unknown>(`/workspaces/${encodeURIComponent(id)}`);
const source = object(response);
if (!source) throw new Error("Workspace API returned an invalid workspace record");
const workspace = requireCanonicalWorkspace(source.workspace);
return {
workspace: requireCanonicalWorkspace(source.workspace),
revision: source.revision as WorkspaceRevision,
workspace,
revision: requireWorkspaceRevision(source.revision, workspace.workspace.id),
};
};
export const getWorkspaceRegistryStatus = () => apiFetch<WorkspaceRegistryStatus>("/workspace-registry/status");
@@ -276,20 +304,38 @@ export const validateWorkspace = async (workspace: CanonicalWorkspace) => {
};
export const testWorkspace = (id: string) =>
apiFetch<WorkspaceDiagnostics>(`/workspaces/${encodeURIComponent(id)}/test`, { method: "POST" });
export const publishWorkspace = (request: PublishWorkspaceRequest) => {
export const publishWorkspace = async (request: PublishWorkspaceRequest) => {
const safeRequest: PublishWorkspaceRequest = request.action === "delete"
? request
: { ...request, workspace: requireCanonicalWorkspace(request.workspace) };
return apiFetch<{ revision: WorkspaceRevision } | undefined>("/workspaces/publish", {
const response = await apiFetch<unknown>("/workspaces/publish", {
method: "POST", body: JSON.stringify(safeRequest),
});
if (response === undefined) return undefined;
const source = exactObject(response, ["revision"]);
const expectedId = safeRequest.action === "delete" ? safeRequest.id : safeRequest.workspace.workspace.id;
if (!source) throw new Error("Workspace API returned an invalid publish result");
return { revision: requireWorkspaceRevision(source.revision, expectedId) };
};
export const exportWorkspace = (id: string) =>
apiFetchBlob(`/workspaces/${encodeURIComponent(id)}/export`);
export const importWorkspace = (bundle: File) => {
export const importWorkspace = async (bundle: File) => {
const body = new FormData();
body.set("bundle", bundle);
return apiFetch<{ draft: { workspace: CanonicalWorkspace; contract?: unknown } }>("/workspaces/import", {
method: "POST", body,
});
const response = await apiFetch<unknown>("/workspaces/import", { method: "POST", body });
const source = exactObject(response, ["draft"]);
const draft = exactObject(source?.draft, ["workspace", "contract"]);
if (!source || !draft) throw new Error("Workspace API returned an invalid imported workspace draft");
let workspace: CanonicalWorkspace;
try {
workspace = requireCanonicalWorkspace(draft.workspace);
} catch {
throw new Error("Workspace API returned an invalid imported workspace draft");
}
return {
draft: {
workspace,
...(draft.contract === undefined ? {} : { contract: draft.contract }),
},
};
};