diff --git a/docs/plugin-platform/registry-client.md b/docs/plugin-platform/registry-client.md index 0f5b59b66..59cb52447 100644 --- a/docs/plugin-platform/registry-client.md +++ b/docs/plugin-platform/registry-client.md @@ -12,14 +12,16 @@ prove the repository or an online artifact is available. `id`, `version`, `name`, `description`, `author`, `repository` (`owner/repository`), `commit` (full 40-character SHA), `license`, `readme` (relative Markdown path), and `manifest`. Optional `artifact` contains `url` and SHA-256 `sha256`. -The existing manifest schema, browser-only distribution policy, identity/version +Unknown catalog-entry and artifact fields are rejected. The whole JSON value, +including manifest defaults, uses the existing 128-level / 100,000-value budget +and finite-number validation. The existing manifest schema, browser-only distribution policy, identity/version and license checks apply. Source-only entries remain build-and-import examples. No download counts, ratings, or timestamps are invented. Brand previews remain bundled and reviewed; arbitrary author image URLs are never loaded. Reads are anonymous HTTPS, redirect-free for the registry, bounded to 2 MiB, -and time out after 30 seconds. All entries must validate before replacing the -current snapshot. Invalid JSON/schema, network failure, and oversized responses +and time out after 30 seconds. All entries must validate, safely clone, and generate every selection token before +atomically replacing the current snapshot. Invalid JSON/schema, network failure, and oversized responses leave the previous good snapshot intact. Errors and Retry remain visible. The Community header also has Refresh, including when the last refresh succeeded; it is disabled while a request is pending. The last good catalog is retained **in memory for this application session**; diff --git a/electron/plugins/catalogService.cjs b/electron/plugins/catalogService.cjs index 70e58e9da..dad468923 100644 --- a/electron/plugins/catalogService.cjs +++ b/electron/plugins/catalogService.cjs @@ -8,6 +8,9 @@ const { getTempFilePath } = require("../bridges/tempDirBridge.cjs"); const { createDefinitionValidator, formatValidationErrors } = require("./contractValidator.cjs"); const validateManifest = createDefinitionValidator("PluginManifest"); const { assertCatalogManifest } = require("./catalogPolicy.cjs"); +const { assertPluginJsonValue } = require("./jsonBoundary.cjs"); +const MAX_CATALOG_BYTES = 2 * 1024 * 1024; +const CATALOG_FIELDS = new Set(["id", "version", "name", "description", "author", "repository", "commit", "license", "readme", "manifest", "artifact"]); const DOWNLOAD_HOSTS = new Set(["github.com", "raw.githubusercontent.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com"]); function assertDownloadUrl(value) { @@ -28,10 +31,11 @@ function pinnedReadmeUrl(entry) { return url.href; } function validateCatalog(entries) { + assertPluginJsonValue(entries, { maxBytes: MAX_CATALOG_BYTES }); if (!Array.isArray(entries) || entries.length > 1000) throw new Error("Invalid plugin catalog"); const ids = new Set(); for (const entry of entries) { - if (!entry || ["id", "version", "name", "description", "author", "repository", "commit", "license", "readme"].some((key) => typeof entry[key] !== "string" || entry[key].length > 4096) || !/^[a-z0-9][a-z0-9.-]+$/u.test(entry.id) || ids.has(entry.id) + if (!entry || Object.keys(entry).some((key) => !CATALOG_FIELDS.has(key)) || ["id", "version", "name", "description", "author", "repository", "commit", "license", "readme"].some((key) => typeof entry[key] !== "string" || entry[key].length > 4096) || !/^[a-z0-9][a-z0-9.-]+$/u.test(entry.id) || ids.has(entry.id) || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(entry.repository) || !/^[a-f0-9]{40}$/u.test(entry.commit) || !entry.license || !entry.author || !entry.name @@ -43,7 +47,12 @@ function validateCatalog(entries) { if (!validateManifest(entry.manifest)) throw new Error(`Catalog manifest is invalid: ${formatValidationErrors(validateManifest.errors)}`); assertCatalogManifest(entry.manifest, entry); ids.add(entry.id); - if (entry.artifact) { + if (Object.hasOwn(entry, "artifact")) { + if (!entry.artifact || typeof entry.artifact !== "object" || Array.isArray(entry.artifact) + || Object.keys(entry.artifact).some((key) => key !== "url" && key !== "sha256") + || typeof entry.artifact.url !== "string" || typeof entry.artifact.sha256 !== "string") { + throw new Error("Invalid catalog artifact metadata"); + } assertDownloadUrl(entry.artifact.url); if (!/^[a-f0-9]{64}$/u.test(entry.artifact.sha256)) throw new Error("Catalog artifact requires SHA-256"); } @@ -82,7 +91,7 @@ async function fetchBounded(url, limit, fetchImpl = fetch, maxRedirects = 4) { } class PluginCatalogService { constructor(options) { - this.entries = validateCatalog(structuredClone(options.entries ?? catalog)); + this.entries = structuredClone(validateCatalog(options.entries ?? catalog)); this.fetch = options.fetch ?? fetch; this.getTempFilePath = options.getTempFilePath ?? getTempFilePath; this.manager = options.manager; @@ -118,4 +127,4 @@ class PluginCatalogService { } } } -module.exports = { PluginCatalogService, assertDownloadUrl, fetchBounded, validateCatalog, pinnedReadmeUrl }; +module.exports = { PluginCatalogService, assertDownloadUrl, fetchBounded, validateCatalog, pinnedReadmeUrl, MAX_CATALOG_BYTES }; diff --git a/electron/plugins/registryCatalogService.cjs b/electron/plugins/registryCatalogService.cjs index 10ec50b0f..ef086e49b 100644 --- a/electron/plugins/registryCatalogService.cjs +++ b/electron/plugins/registryCatalogService.cjs @@ -1,14 +1,20 @@ "use strict"; const { createHash } = require("node:crypto"); -const { PluginCatalogService, fetchBounded, validateCatalog } = require("./catalogService.cjs"); +const { PluginCatalogService, fetchBounded, validateCatalog, MAX_CATALOG_BYTES } = require("./catalogService.cjs"); const REGISTRY_URL = "https://raw.githubusercontent.com/binaricat/netcatty-plugins/main/catalog.json"; -const MAX_CATALOG_BYTES = 2 * 1024 * 1024; const tokenFor = (entry) => createHash("sha256").update(JSON.stringify(entry)).digest("hex"); +function prepareSnapshot(value) { + const entries = structuredClone(validateCatalog(value)); + const publicEntries = structuredClone(entries).map((entry) => ({ ...entry, selectionToken: tokenFor(entry) })); + return { entries, publicEntries }; +} + class RegistryCatalogService extends PluginCatalogService { constructor(options) { super(options); + this.publicEntries = prepareSnapshot(this.entries).publicEntries; this.source = "builtin"; this.error = null; this.refreshing = null; @@ -19,8 +25,10 @@ class RegistryCatalogService extends PluginCatalogService { try { // No redirects: only this exact registry endpoint can replace the catalog. const bytes = await fetchBounded(REGISTRY_URL, MAX_CATALOG_BYTES, this.fetch, 0); - const next = validateCatalog(JSON.parse(bytes.toString("utf8"))); - this.entries = next; + const next = prepareSnapshot(JSON.parse(bytes.toString("utf8"))); + // Publish only after full validation, clones and every token succeeded. + this.entries = next.entries; + this.publicEntries = next.publicEntries; this.source = "registry"; this.error = null; return true; @@ -35,7 +43,7 @@ class RegistryCatalogService extends PluginCatalogService { async list() { await this.refresh(); return { - entries: this.entries.map((entry) => ({ ...structuredClone(entry), selectionToken: tokenFor(entry) })), + entries: structuredClone(this.publicEntries), source: this.source, error: this.error, }; diff --git a/electron/plugins/registryCatalogService.test.cjs b/electron/plugins/registryCatalogService.test.cjs index e86183091..ec6e008c6 100644 --- a/electron/plugins/registryCatalogService.test.cjs +++ b/electron/plugins/registryCatalogService.test.cjs @@ -111,3 +111,43 @@ test("URL-stripped README controls cannot escape a selected commit or replace th assert.match((await service.list()).error, /escapes the pinned commit/); assert.ok(requested.every((url) => url === REGISTRY_URL)); }); + +test("whole-catalog JSON budgets and unknown fields cannot poison a good offline snapshot", async () => { + let response = JSON.stringify(builtin); + const service = new RegistryCatalogService({ fetch: async () => { + if (response === null) throw new Error("offline"); + return new Response(response); + } }); + const good = await service.list(); + let nested = null; + for (let depth = 0; depth < 130; depth++) nested = { child: nested }; + const nonfinite = structuredClone(builtin[0]); + nonfinite.manifest.contributes.settings[0].default = "finite-marker"; + for (const invalid of [ + JSON.stringify([{ ...builtin[0], unknownNestedField: nested }]), + JSON.stringify([{ ...builtin[0], unknownField: true }]), + JSON.stringify([nonfinite]).replace('"finite-marker"', '1e999'), + JSON.stringify([{ ...builtin[0], artifact: { url: "https://github.com/a/b", sha256: "a".repeat(64), unexpected: true } }]), + ]) { + response = invalid; + const rejected = await service.list(); + assert.equal(rejected.source, "cached"); assert.ok(rejected.error); + assert.deepEqual(rejected.entries, good.entries); + response = null; + const offline = await service.list(); + assert.deepEqual(offline.entries, good.entries); assert.match(offline.error, /offline/); + } +}); + +test("snapshot preparation failure leaves both private entries and public tokens unchanged", async () => { + const service = new RegistryCatalogService({ fetch: async () => new Response(JSON.stringify(builtin)) }); + const good = await service.list(); + const originalClone = global.structuredClone; + try { + global.structuredClone = () => { throw new Error("synthetic clone failure"); }; + assert.equal(await service.refresh(), false); + } finally { global.structuredClone = originalClone; } + service.fetch = async () => { throw new Error("offline"); }; + assert.deepEqual((await service.list()).entries, good.entries); + assert.equal(service.selected(good.entries[0].id, good.entries[0].selectionToken).id, good.entries[0].id); +});