Skip to content

Commit 1ed499b

Browse files
author
Gio Della-Libera
committed
fix(feeds): enforce signed catalog HTTP contract
1 parent 6a2514b commit 1ed499b

2 files changed

Lines changed: 81 additions & 19 deletions

File tree

src/plugins/official-external-plugin-catalog.test.ts

Lines changed: 50 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,12 @@ function signedHostedCatalogSnapshot(params: {
196196
};
197197
}
198198

199+
function dsseResponse(body: BodyInit | null, init: ResponseInit = {}): Response {
200+
const headers = new Headers(init.headers);
201+
headers.set("content-type", "application/vnd.dsse+json");
202+
return new Response(body, { ...init, headers });
203+
}
204+
199205
describe("official external plugin catalog", () => {
200206
it("keeps hosted fetch guard loading lazy for bundled catalog import paths", () => {
201207
const source = readFileSync(
@@ -343,14 +349,20 @@ describe("official external plugin catalog", () => {
343349
id: "clawhub-official",
344350
};
345351
const signed = signedHostedCatalogFeed({ feed });
346-
const fetchImpl = vi.fn(async () => new Response(signed.body, { status: 200 }));
352+
const fetchImpl = vi.fn(async (_url, init) => {
353+
const headers = new Headers(init?.headers);
354+
expect(headers.get("accept")).toBe("application/vnd.dsse+json");
355+
expect(headers.has("if-modified-since")).toBe(false);
356+
return dsseResponse(signed.body, { status: 200 });
357+
});
347358

348359
const result = await loadHostedCatalog({
349360
env: {
350361
[DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_CLAWHUB_TRUSTED_KEY_ID_ENV]: "acme-root",
351362
[DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_CLAWHUB_TRUSTED_PUBLIC_KEY_ENV]:
352363
signed.publicKeyPem,
353364
},
365+
ifModifiedSince: "Mon, 22 Jun 2026 00:00:00 GMT",
354366
fetchImpl,
355367
snapshotStore: null,
356368
});
@@ -381,7 +393,7 @@ describe("official external plugin catalog", () => {
381393
[DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_CLAWHUB_TRUSTED_PUBLIC_KEY_ENV]:
382394
signed.publicKeyPem,
383395
},
384-
fetchImpl: vi.fn(async () => new Response(signed.body, { status: 200 })),
396+
fetchImpl: vi.fn(async () => dsseResponse(signed.body, { status: 200 })),
385397
snapshotStore: null,
386398
});
387399

@@ -426,7 +438,7 @@ describe("official external plugin catalog", () => {
426438
[DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_CLAWHUB_TRUSTED_PUBLIC_KEY_ENV]:
427439
signed.publicKeyPem,
428440
},
429-
fetchImpl: vi.fn(async () => new Response(signed.body, { status: 200 })),
441+
fetchImpl: vi.fn(async () => dsseResponse(signed.body, { status: 200 })),
430442
snapshotStore: null,
431443
});
432444

@@ -770,7 +782,7 @@ describe("official external plugin catalog", () => {
770782
const accepted = await loadHostedCatalog({
771783
feedProfile: "acme",
772784
catalogConfig,
773-
fetchImpl: vi.fn(async () => new Response(newer.body, { status: 200 })),
785+
fetchImpl: vi.fn(async () => dsseResponse(newer.body, { status: 200 })),
774786
now: () => new Date("2026-06-22T00:00:10.000Z"),
775787
snapshotStore,
776788
});
@@ -790,7 +802,7 @@ describe("official external plugin catalog", () => {
790802
const rolledBack = await loadHostedCatalog({
791803
feedProfile: "acme",
792804
catalogConfig,
793-
fetchImpl: vi.fn(async () => new Response(older.body, { status: 200 })),
805+
fetchImpl: vi.fn(async () => dsseResponse(older.body, { status: 200 })),
794806
now: () => new Date("2026-06-22T00:00:11.000Z"),
795807
snapshotStore,
796808
});
@@ -814,7 +826,7 @@ describe("official external plugin catalog", () => {
814826
const result = await loadHostedCatalog({
815827
feedProfile: "acme",
816828
catalogConfig: signedCatalogConfig(malformed.publicKeyPem),
817-
fetchImpl: vi.fn(async () => new Response(malformed.body, { status: 200 })),
829+
fetchImpl: vi.fn(async () => dsseResponse(malformed.body, { status: 200 })),
818830
snapshotStore: createInMemoryHostedCatalogSnapshotStore(),
819831
});
820832

@@ -847,7 +859,7 @@ describe("official external plugin catalog", () => {
847859
const rejected = await loadHostedCatalog({
848860
feedProfile: "acme",
849861
catalogConfig: signedCatalogConfig(lower.publicKeyPem),
850-
fetchImpl: vi.fn(async () => new Response(lower.body, { status: 200 })),
862+
fetchImpl: vi.fn(async () => dsseResponse(lower.body, { status: 200 })),
851863
snapshotStore,
852864
});
853865

@@ -857,7 +869,7 @@ describe("official external plugin catalog", () => {
857869
const result = await loadHostedCatalog({
858870
feedProfile: "acme",
859871
catalogConfig: signedCatalogConfig(valid.publicKeyPem),
860-
fetchImpl: vi.fn(async () => new Response(valid.body, { status: 200 })),
872+
fetchImpl: vi.fn(async () => dsseResponse(valid.body, { status: 200 })),
861873
snapshotStore,
862874
});
863875

@@ -882,7 +894,7 @@ describe("official external plugin catalog", () => {
882894
const result = await loadHostedCatalog({
883895
feedProfile: "acme",
884896
catalogConfig: signedCatalogConfig(candidate.publicKeyPem),
885-
fetchImpl: vi.fn(async () => new Response(candidate.body, { status: 200 })),
897+
fetchImpl: vi.fn(async () => dsseResponse(candidate.body, { status: 200 })),
886898
snapshotStore,
887899
});
888900

@@ -906,7 +918,7 @@ describe("official external plugin catalog", () => {
906918
const unsigned = await loadHostedCatalog({
907919
feedProfile: "acme",
908920
catalogConfig,
909-
fetchImpl: vi.fn(async () => new Response(unsignedBody, { status: 200 })),
921+
fetchImpl: vi.fn(async () => dsseResponse(unsignedBody, { status: 200 })),
910922
snapshotStore: createInMemoryHostedCatalogSnapshotStore(),
911923
});
912924

@@ -956,6 +968,28 @@ describe("official external plugin catalog", () => {
956968
}
957969
});
958970

971+
it("fails closed when a signed feed response does not use the DSSE media type", async () => {
972+
const signed = signedHostedCatalogFeed({
973+
feed: hostedCatalogFeed({ sequence: 8, pluginName: "@openclaw/wrong-media-type" }),
974+
});
975+
const result = await loadHostedCatalog({
976+
feedProfile: "acme",
977+
catalogConfig: signedCatalogConfig(signed.publicKeyPem),
978+
fetchImpl: vi.fn(
979+
async () =>
980+
new Response(signed.body, {
981+
status: 200,
982+
headers: { "content-type": "application/json" },
983+
}),
984+
),
985+
snapshotStore: createInMemoryHostedCatalogSnapshotStore(),
986+
});
987+
988+
expect(result.source).toBe("bundled-fallback");
989+
expect(result.entries).toEqual([]);
990+
expect(result.error).toContain("must use application/vnd.dsse+json");
991+
});
992+
959993
it("rejects expired signed feeds and keeps expired snapshots visible but not installable", async () => {
960994
const feed = {
961995
...hostedCatalogFeed({
@@ -984,8 +1018,8 @@ describe("official external plugin catalog", () => {
9841018
const seeded = await loadHostedCatalog({
9851019
feedProfile: "acme",
9861020
catalogConfig,
987-
fetchImpl: vi.fn(
988-
async () => new Response(signed.body, { status: 200, headers: { etag: '"expiring"' } }),
1021+
fetchImpl: vi.fn(async () =>
1022+
dsseResponse(signed.body, { status: 200, headers: { etag: '"expiring"' } }),
9891023
),
9901024
now: () => new Date("2026-06-22T00:00:30.000Z"),
9911025
snapshotStore,
@@ -998,7 +1032,7 @@ describe("official external plugin catalog", () => {
9981032
const expiredFresh = await loadHostedCatalog({
9991033
feedProfile: "acme",
10001034
catalogConfig,
1001-
fetchImpl: vi.fn(async () => new Response(signed.body, { status: 200 })),
1035+
fetchImpl: vi.fn(async () => dsseResponse(signed.body, { status: 200 })),
10021036
now: () => new Date("2026-06-22T00:01:01.000Z"),
10031037
snapshotStore: null,
10041038
});
@@ -1063,7 +1097,7 @@ describe("official external plugin catalog", () => {
10631097
const result = await loadHostedCatalog({
10641098
feedProfile: "acme",
10651099
catalogConfig: signedCatalogConfig(signed.publicKeyPem),
1066-
fetchImpl: vi.fn(async () => new Response(signed.body, { status: 200 })),
1100+
fetchImpl: vi.fn(async () => dsseResponse(signed.body, { status: 200 })),
10671101
now: () => new Date("2026-06-22T00:00:30.000Z"),
10681102
snapshotStore: null,
10691103
});
@@ -1116,7 +1150,7 @@ describe("official external plugin catalog", () => {
11161150
const updated = await loadHostedCatalog({
11171151
feedProfile: "acme",
11181152
catalogConfig,
1119-
fetchImpl: vi.fn(async () => new Response(newer.body, { status: 200 })),
1153+
fetchImpl: vi.fn(async () => dsseResponse(newer.body, { status: 200 })),
11201154
now: () => new Date("2026-06-22T00:00:30.000Z"),
11211155
snapshotStore,
11221156
});
@@ -1167,7 +1201,7 @@ describe("official external plugin catalog", () => {
11671201
feedProfile: "acme",
11681202
feedUrl: "https://clawhub.ai/v1/feeds/plugins",
11691203
catalogConfig: signedCatalogConfig(signed.publicKeyPem),
1170-
fetchImpl: vi.fn(async () => new Response(unsignedBody, { status: 200 })),
1204+
fetchImpl: vi.fn(async () => dsseResponse(unsignedBody, { status: 200 })),
11711205
snapshotStore: null,
11721206
});
11731207

src/plugins/official-external-plugin-catalog.ts

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,7 @@ const DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_PROFILE_CONFIG: OfficialExternalP
290290
const DEFAULT_HOSTED_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_TIMEOUT_MS = 5000;
291291
const DEFAULT_HOSTED_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_MAX_BYTES = 1024 * 1024;
292292
const DEFAULT_HOSTED_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_CHUNK_TIMEOUT_MS = 5000;
293+
const DSSE_ENVELOPE_MEDIA_TYPE = "application/vnd.dsse+json";
293294
const OFFICIAL_EXTERNAL_PLUGIN_CATALOG_FEED_HOSTNAME_ALLOWLIST = ["clawhub.ai"];
294295
const ISO_CALENDAR_DATE_PREFIX_RE = /^(\d{4})-(\d{2})-(\d{2})/u;
295296

@@ -407,7 +408,9 @@ function resolveDefaultClawHubFeedVerificationFromEnv(
407408
const publicKey = normalizeOptionalString(
408409
env?.[DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_CLAWHUB_TRUSTED_PUBLIC_KEY_ENV],
409410
);
410-
if (!keyId && !publicKey) return undefined;
411+
if (!keyId && !publicKey) {
412+
return undefined;
413+
}
411414
if (!keyId || !publicKey) {
412415
throw new HostedCatalogTrustConfigurationError(
413416
"default ClawHub feed trust requires both key id and public key env vars",
@@ -801,7 +804,7 @@ async function parseHostedCatalogFeedBody(params: {
801804
if (!Number.isFinite(generatedAtMs)) {
802805
throw new Error("hosted catalog signed feed requires a valid generatedAt value");
803806
}
804-
let expired = false;
807+
let expired: boolean;
805808
if (!expiresAt) {
806809
if (params.allowMissingExpiry !== true) {
807810
throw new Error("hosted catalog signed feed requires a valid expiresAt value");
@@ -1113,13 +1116,19 @@ async function loadHostedOfficialExternalPluginCatalogEntries(params?: {
11131116
}
11141117
const headers = new Headers();
11151118
const ifNoneMatch = normalizeOptionalString(params?.ifNoneMatch);
1116-
const ifModifiedSince = normalizeOptionalString(params?.ifModifiedSince);
1119+
const signedOperation = source.verification?.mode === "signed";
1120+
const ifModifiedSince = signedOperation
1121+
? undefined
1122+
: normalizeOptionalString(params?.ifModifiedSince);
11171123
if (ifNoneMatch) {
11181124
headers.set("if-none-match", ifNoneMatch);
11191125
}
11201126
if (ifModifiedSince) {
11211127
headers.set("if-modified-since", ifModifiedSince);
11221128
}
1129+
if (signedOperation) {
1130+
headers.set("accept", DSSE_ENVELOPE_MEDIA_TYPE);
1131+
}
11231132
const metadataBase = (response: Response) => {
11241133
const etag = normalizeHostedCatalogHeader(response.headers.get("etag"));
11251134
const lastModified = normalizeHostedCatalogHeader(response.headers.get("last-modified"));
@@ -1179,6 +1188,25 @@ async function loadHostedOfficialExternalPluginCatalogEntries(params?: {
11791188
now: currentTime(),
11801189
});
11811190
}
1191+
if (
1192+
signedOperation &&
1193+
response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() !==
1194+
DSSE_ENVELOPE_MEDIA_TYPE
1195+
) {
1196+
return await snapshotOrBundledFallbackResult({
1197+
error: `signed hosted catalog feed must use ${DSSE_ENVELOPE_MEDIA_TYPE}`,
1198+
snapshotStore,
1199+
url: url.href,
1200+
metadata: base,
1201+
expectedSha256,
1202+
ifNoneMatch,
1203+
catalogConfig: params?.catalogConfig,
1204+
requireManifestInstallSourceRef,
1205+
expectedFeedId: source.expectedFeedId,
1206+
verification: source.verification,
1207+
now: currentTime(),
1208+
});
1209+
}
11821210
const body = await readHostedCatalogResponseText({
11831211
response,
11841212
maxBytes: params?.maxBytes ?? DEFAULT_HOSTED_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_MAX_BYTES,

0 commit comments

Comments
 (0)