Skip to content

Commit 5f71635

Browse files
committed
fix(plugins): validate hosted snapshot request validators
1 parent 45857b5 commit 5f71635

2 files changed

Lines changed: 147 additions & 2 deletions

File tree

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

Lines changed: 109 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -411,7 +411,13 @@ describe("official external plugin catalog", () => {
411411
});
412412
const seeded = await loadHostedOfficialExternalPluginCatalogEntries({
413413
snapshotStore: createInMemoryHostedOfficialExternalPluginCatalogSnapshotStore(),
414-
fetchImpl: vi.fn(async () => new Response(body, { status: 200 })),
414+
fetchImpl: vi.fn(
415+
async () =>
416+
new Response(body, {
417+
status: 200,
418+
headers: { etag: '"snapshot-v1"' },
419+
}),
420+
),
415421
});
416422
if (seeded.source !== "hosted") {
417423
throw new Error("expected seeded hosted feed");
@@ -426,7 +432,14 @@ describe("official external plugin catalog", () => {
426432

427433
const result = await loadHostedOfficialExternalPluginCatalogEntries({
428434
snapshotStore,
429-
fetchImpl: vi.fn(async () => new Response(null, { status: 304 })),
435+
ifNoneMatch: '"snapshot-v1"',
436+
fetchImpl: vi.fn(
437+
async () =>
438+
new Response(null, {
439+
status: 304,
440+
headers: { etag: '"snapshot-v1"' },
441+
}),
442+
),
430443
});
431444

432445
expect(result.source).toBe("hosted-snapshot");
@@ -438,6 +451,60 @@ describe("official external plugin catalog", () => {
438451
}
439452
});
440453

454+
it("does not use a stale snapshot when HTTP 304 validators do not match", async () => {
455+
const body = JSON.stringify({
456+
schemaVersion: 1,
457+
id: "openclaw-official-external-plugins",
458+
generatedAt: "2026-06-22T00:00:00.000Z",
459+
sequence: 4,
460+
entries: [
461+
{
462+
name: "@openclaw/stale-snapshot-proof",
463+
kind: "plugin",
464+
openclaw: { plugin: { id: "stale-snapshot-proof" } },
465+
},
466+
],
467+
});
468+
const seeded = await loadHostedOfficialExternalPluginCatalogEntries({
469+
snapshotStore: createInMemoryHostedOfficialExternalPluginCatalogSnapshotStore(),
470+
fetchImpl: vi.fn(
471+
async () =>
472+
new Response(body, {
473+
status: 200,
474+
headers: { etag: '"snapshot-v1"' },
475+
}),
476+
),
477+
});
478+
if (seeded.source !== "hosted") {
479+
throw new Error("expected seeded hosted feed");
480+
}
481+
const snapshotStore = createInMemoryHostedOfficialExternalPluginCatalogSnapshotStore([
482+
{
483+
body,
484+
metadata: seeded.metadata,
485+
savedAt: "2026-06-22T01:02:03.000Z",
486+
},
487+
]);
488+
489+
const result = await loadHostedOfficialExternalPluginCatalogEntries({
490+
snapshotStore,
491+
ifNoneMatch: '"snapshot-v2"',
492+
fetchImpl: vi.fn(
493+
async () =>
494+
new Response(null, {
495+
status: 304,
496+
headers: { etag: '"snapshot-v2"' },
497+
}),
498+
),
499+
});
500+
501+
expect(result.source).toBe("bundled-fallback");
502+
if (result.source === "bundled-fallback") {
503+
expect(result.error).toContain("snapshot fallback failed");
504+
expect(result.error).toContain("ETag");
505+
}
506+
});
507+
441508
it("uses a valid snapshot before bundled fallback when hosted validation fails", async () => {
442509
const body = JSON.stringify({
443510
schemaVersion: 1,
@@ -477,6 +544,46 @@ describe("official external plugin catalog", () => {
477544
}
478545
});
479546

547+
it("does not use a stale snapshot when hosted validation fails with unmatched validators", async () => {
548+
const body = JSON.stringify({
549+
schemaVersion: 1,
550+
id: "openclaw-official-external-plugins",
551+
generatedAt: "2026-06-22T00:00:00.000Z",
552+
sequence: 5,
553+
entries: [
554+
{
555+
name: "@openclaw/stale-validation-snapshot-proof",
556+
kind: "plugin",
557+
openclaw: { plugin: { id: "stale-validation-snapshot-proof" } },
558+
},
559+
],
560+
});
561+
const seeded = await loadHostedOfficialExternalPluginCatalogEntries({
562+
snapshotStore: createInMemoryHostedOfficialExternalPluginCatalogSnapshotStore(),
563+
fetchImpl: vi.fn(
564+
async () => new Response(body, { status: 200, headers: { etag: '"snapshot-v1"' } }),
565+
),
566+
});
567+
if (seeded.source !== "hosted") {
568+
throw new Error("expected seeded hosted feed");
569+
}
570+
const snapshotStore = createInMemoryHostedOfficialExternalPluginCatalogSnapshotStore([
571+
{ body, metadata: seeded.metadata, savedAt: "2026-06-22T01:02:03.000Z" },
572+
]);
573+
574+
const result = await loadHostedOfficialExternalPluginCatalogEntries({
575+
snapshotStore,
576+
ifNoneMatch: '"snapshot-v2"',
577+
fetchImpl: vi.fn(async () => new Response("{ nope", { status: 200 })),
578+
});
579+
580+
expect(result.source).toBe("bundled-fallback");
581+
if (result.source === "bundled-fallback") {
582+
expect(result.error).toContain("snapshot fallback failed");
583+
expect(result.error).toContain("ETag");
584+
}
585+
});
586+
480587
it("falls back to bundled entries when the snapshot is invalid", async () => {
481588
const snapshotStore = createInMemoryHostedOfficialExternalPluginCatalogSnapshotStore([
482589
{

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,7 +408,14 @@ function loadHostedCatalogSnapshotResult(params: {
408408
snapshot: HostedOfficialExternalPluginCatalogSnapshot;
409409
error: unknown;
410410
expectedSha256?: string;
411+
ifNoneMatch?: string;
412+
ifModifiedSince?: string;
411413
}): HostedOfficialExternalPluginCatalogLoadResult {
414+
assertSnapshotMatchesRequestValidators({
415+
snapshot: params.snapshot,
416+
ifNoneMatch: params.ifNoneMatch,
417+
ifModifiedSince: params.ifModifiedSince,
418+
});
412419
const checksum = sha256Hex(params.snapshot.body);
413420
if (checksum !== params.snapshot.metadata.checksum) {
414421
throw new Error("hosted catalog snapshot checksum mismatch");
@@ -432,12 +439,31 @@ function loadHostedCatalogSnapshotResult(params: {
432439
};
433440
}
434441

442+
function assertSnapshotMatchesRequestValidators(params: {
443+
snapshot: HostedOfficialExternalPluginCatalogSnapshot;
444+
ifNoneMatch?: string;
445+
ifModifiedSince?: string;
446+
}): void {
447+
if (params.ifNoneMatch && params.snapshot.metadata.etag !== params.ifNoneMatch) {
448+
throw new Error("hosted catalog snapshot ETag did not match request validator");
449+
}
450+
if (
451+
!params.ifNoneMatch &&
452+
params.ifModifiedSince &&
453+
params.snapshot.metadata.lastModified !== params.ifModifiedSince
454+
) {
455+
throw new Error("hosted catalog snapshot Last-Modified did not match request validator");
456+
}
457+
}
458+
435459
async function snapshotOrBundledFallbackResult(params: {
436460
error: unknown;
437461
snapshotStore?: HostedOfficialExternalPluginCatalogSnapshotStore;
438462
url: string;
439463
metadata?: HostedOfficialExternalPluginCatalogLoadResult["metadata"];
440464
expectedSha256?: string;
465+
ifNoneMatch?: string;
466+
ifModifiedSince?: string;
441467
}): Promise<HostedOfficialExternalPluginCatalogLoadResult> {
442468
if (params.snapshotStore) {
443469
try {
@@ -447,6 +473,8 @@ async function snapshotOrBundledFallbackResult(params: {
447473
snapshot,
448474
error: params.error,
449475
expectedSha256: params.expectedSha256,
476+
ifNoneMatch: params.ifNoneMatch,
477+
ifModifiedSince: params.ifModifiedSince,
450478
});
451479
}
452480
} catch (snapshotErr) {
@@ -538,6 +566,8 @@ export async function loadHostedOfficialExternalPluginCatalogEntries(params?: {
538566
url: url.href,
539567
metadata: base,
540568
expectedSha256,
569+
ifNoneMatch,
570+
ifModifiedSince,
541571
});
542572
}
543573
if (!response.ok) {
@@ -547,6 +577,8 @@ export async function loadHostedOfficialExternalPluginCatalogEntries(params?: {
547577
url: url.href,
548578
metadata: base,
549579
expectedSha256,
580+
ifNoneMatch,
581+
ifModifiedSince,
550582
});
551583
}
552584
const body = await readHostedCatalogResponseText({
@@ -564,6 +596,8 @@ export async function loadHostedOfficialExternalPluginCatalogEntries(params?: {
564596
url: url.href,
565597
metadata,
566598
expectedSha256,
599+
ifNoneMatch,
600+
ifModifiedSince,
567601
});
568602
}
569603
const raw = JSON.parse(body) as unknown;
@@ -574,6 +608,8 @@ export async function loadHostedOfficialExternalPluginCatalogEntries(params?: {
574608
url: url.href,
575609
metadata,
576610
expectedSha256,
611+
ifNoneMatch,
612+
ifModifiedSince,
577613
});
578614
}
579615
await params?.snapshotStore
@@ -597,6 +633,8 @@ export async function loadHostedOfficialExternalPluginCatalogEntries(params?: {
597633
snapshotStore: params?.snapshotStore,
598634
url: url.href,
599635
expectedSha256,
636+
ifNoneMatch,
637+
ifModifiedSince,
600638
});
601639
} finally {
602640
if (response?.bodyUsed !== true) {

0 commit comments

Comments
 (0)