mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-14 19:17:26 +00:00
fix(plugins): validate and prepare registry snapshots before publication
This commit is contained in:
1 parent
b48567d448
commit
e5bd294cdd
4 files changed
+71
-12
No files matched your search
@@ -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**;
|
||||
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user