fix: sanitize workspace API envelopes
This commit is contained in:
@@ -41,7 +41,7 @@ test("uploads a workspace bundle without JSON content type", async () => {
|
|||||||
let contentType: string | null = null;
|
let contentType: string | null = null;
|
||||||
server.use(http.post("/api/workspaces/import", ({ request }) => {
|
server.use(http.post("/api/workspaces/import", ({ request }) => {
|
||||||
contentType = request.headers.get("content-type");
|
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" }));
|
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);
|
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 () => {
|
test("rejects a conflict payload that attempts to surface a secret field", async () => {
|
||||||
server.use(http.post("/api/workspaces/publish", () => HttpResponse.json({
|
server.use(http.post("/api/workspaces/publish", () => HttpResponse.json({
|
||||||
code: "workspace_conflict", message: "Workspace changed in the registry.", fields: ["dwh.password"],
|
code: "workspace_conflict", message: "Workspace changed in the registry.", fields: ["dwh.password"],
|
||||||
|
|||||||
@@ -200,6 +200,33 @@ function object(value: unknown): Record<string, unknown> | undefined {
|
|||||||
: 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 {
|
function conflictRevision(value: unknown): WorkspaceConflictRevision | undefined {
|
||||||
const source = object(value);
|
const source = object(value);
|
||||||
const commit = source?.commit;
|
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 response = await apiFetch<unknown>(`/workspaces/${encodeURIComponent(id)}`);
|
||||||
const source = object(response);
|
const source = object(response);
|
||||||
if (!source) throw new Error("Workspace API returned an invalid workspace record");
|
if (!source) throw new Error("Workspace API returned an invalid workspace record");
|
||||||
|
const workspace = requireCanonicalWorkspace(source.workspace);
|
||||||
return {
|
return {
|
||||||
workspace: requireCanonicalWorkspace(source.workspace),
|
workspace,
|
||||||
revision: source.revision as WorkspaceRevision,
|
revision: requireWorkspaceRevision(source.revision, workspace.workspace.id),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
export const getWorkspaceRegistryStatus = () => apiFetch<WorkspaceRegistryStatus>("/workspace-registry/status");
|
export const getWorkspaceRegistryStatus = () => apiFetch<WorkspaceRegistryStatus>("/workspace-registry/status");
|
||||||
@@ -276,20 +304,38 @@ export const validateWorkspace = async (workspace: CanonicalWorkspace) => {
|
|||||||
};
|
};
|
||||||
export const testWorkspace = (id: string) =>
|
export const testWorkspace = (id: string) =>
|
||||||
apiFetch<WorkspaceDiagnostics>(`/workspaces/${encodeURIComponent(id)}/test`, { method: "POST" });
|
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"
|
const safeRequest: PublishWorkspaceRequest = request.action === "delete"
|
||||||
? request
|
? request
|
||||||
: { ...request, workspace: requireCanonicalWorkspace(request.workspace) };
|
: { ...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),
|
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) =>
|
export const exportWorkspace = (id: string) =>
|
||||||
apiFetchBlob(`/workspaces/${encodeURIComponent(id)}/export`);
|
apiFetchBlob(`/workspaces/${encodeURIComponent(id)}/export`);
|
||||||
export const importWorkspace = (bundle: File) => {
|
export const importWorkspace = async (bundle: File) => {
|
||||||
const body = new FormData();
|
const body = new FormData();
|
||||||
body.set("bundle", bundle);
|
body.set("bundle", bundle);
|
||||||
return apiFetch<{ draft: { workspace: CanonicalWorkspace; contract?: unknown } }>("/workspaces/import", {
|
const response = await apiFetch<unknown>("/workspaces/import", { method: "POST", body });
|
||||||
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 }),
|
||||||
|
},
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user