fix(web): revalidate marketplace fetches so stale caches do not fail integrity checks

Marketplace hosts serve versioned plugin sources as immutable. When a file
is republished in place, a browser that cached the earlier bytes keeps
reading them, while the index is fetched fresh and declares the new hash,
so the install dialog reports a sha256 mismatch for a correct source.

Fetch the plugin source, the sidecar icon and the index with
cache: 'no-cache' so each request revalidates with the host (a 304 when
unchanged).
This commit is contained in:
CaIon
2026-09-30 17:24:18 +08:00
parent 789c970199
commit f5e54ccdb2
7 changed files with 82 additions and 11 deletions
@@ -81,6 +81,10 @@ function renderPanel(): QueryClient {
return queryClient
}
function requestedUrls(fetchMock: ReturnType<typeof vi.fn>): string[] {
return fetchMock.mock.calls.map(([input]) => String(input))
}
function installIndexFetchMock() {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input)
@@ -117,8 +121,8 @@ describe('MarketplacePanel source switch', () => {
'aria-pressed',
'true'
)
expect(fetchMock).toHaveBeenCalledWith(DEFAULT_MARKETPLACE_INDEX_URL)
expect(fetchMock).not.toHaveBeenCalledWith(GITHUB_MARKETPLACE_INDEX_URL)
expect(requestedUrls(fetchMock)).toContain(DEFAULT_MARKETPLACE_INDEX_URL)
expect(requestedUrls(fetchMock)).not.toContain(GITHUB_MARKETPLACE_INDEX_URL)
})
test('loads GitHub only after the administrator switches to it', async () => {
@@ -127,7 +131,7 @@ describe('MarketplacePanel source switch', () => {
renderPanel()
await screen.findByRole('heading', { name: officialIndex.name })
expect(fetchMock).not.toHaveBeenCalledWith(GITHUB_MARKETPLACE_INDEX_URL)
expect(requestedUrls(fetchMock)).not.toContain(GITHUB_MARKETPLACE_INDEX_URL)
await user.click(screen.getByRole('button', { name: 'GitHub' }))
@@ -141,7 +145,7 @@ describe('MarketplacePanel source switch', () => {
'aria-pressed',
'true'
)
expect(fetchMock).toHaveBeenCalledWith(GITHUB_MARKETPLACE_INDEX_URL)
expect(requestedUrls(fetchMock)).toContain(GITHUB_MARKETPLACE_INDEX_URL)
})
test('does not request GitHub automatically when the official source fails', async () => {
@@ -158,7 +162,28 @@ describe('MarketplacePanel source switch', () => {
expect(
await screen.findByText('Could not load this source')
).toBeInTheDocument()
expect(fetchMock).toHaveBeenCalledWith(DEFAULT_MARKETPLACE_INDEX_URL)
expect(fetchMock).not.toHaveBeenCalledWith(GITHUB_MARKETPLACE_INDEX_URL)
expect(requestedUrls(fetchMock)).toContain(DEFAULT_MARKETPLACE_INDEX_URL)
expect(requestedUrls(fetchMock)).not.toContain(GITHUB_MARKETPLACE_INDEX_URL)
})
test('revalidates the index instead of reusing a stale browser-cached copy', async () => {
const staleIndex: MarketplaceIndex = { ...officialIndex, name: 'Stale' }
vi.stubGlobal(
'fetch',
vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
const revalidated = ['no-cache', 'no-store', 'reload'].includes(
init?.cache ?? 'default'
)
return new Response(
JSON.stringify(revalidated ? officialIndex : staleIndex),
{ status: 200 }
)
})
)
renderPanel()
expect(
await screen.findByRole('heading', { name: officialIndex.name })
).toBeInTheDocument()
})
})
@@ -84,7 +84,8 @@ describe('marketplace card logo', () => {
)
})
expect(fetchMock).toHaveBeenCalledWith(
'https://raw.githubusercontent.com/QuantumNous/new-api-plugins/main/plugins/tasks/incho/icon.svg'
'https://raw.githubusercontent.com/QuantumNous/new-api-plugins/main/plugins/tasks/incho/icon.svg',
expect.anything()
)
})
@@ -111,6 +111,25 @@ describe('fetchPluginIconDataUri', () => {
)
})
test('revalidates with the host so a stale browser-cached logo does not fail the digest', async () => {
const result = await fetchPluginIconDataUri(
'https://raw.example/plugins/tasks/incho/icon.svg',
{
sha256:
'd4dc56669143034f31aa309635d4113d9ad76a02b1739da22c965ed2049be9e6',
fetchImpl: async (_input, init) =>
okResponse(
['no-cache', 'no-store', 'reload'].includes(
init?.cache ?? 'default'
)
? '<svg/>'
: '<svg>stale</svg>'
),
}
)
assert.equal(result, 'data:image/svg+xml;base64,PHN2Zy8+')
})
test('returns null for a non-image path, a failed response, or a network error', async () => {
assert.equal(
await fetchPluginIconDataUri('https://raw.example/icon.js', {
@@ -213,6 +213,24 @@ describe('browser plugin source fetch', () => {
)
})
test('revalidates with the host instead of reusing a stale browser-cached copy', async () => {
// A marketplace host may serve versioned sources as immutable while the
// file was republished in place; the stale copy would fail the index hash.
const text = await fetchPluginSourceText(
'https://example.com/plugin.js',
async (_input, init) =>
stubResponse({
ok: true,
body: ['no-cache', 'no-store', 'reload'].includes(
init?.cache ?? 'default'
)
? 'republished source'
: 'stale cached source',
})
)
assert.equal(text, 'republished source')
})
test('accepts a body exactly at the limit', async () => {
const text = await fetchPluginSourceText(
'https://example.com/limit.js',
@@ -77,7 +77,11 @@ export function MarketplacePanel() {
meta: { errorToast: false },
queryFn: async (): Promise<MarketplaceIndex> => {
if (!selectedSource) throw new Error('marketplace source is not selected')
const response = await fetch(selectedSource.index_url)
// Revalidate so the hashes checked at install time are the host's
// current ones rather than a copy cached before a republish.
const response = await fetch(selectedSource.index_url, {
cache: 'no-cache',
})
if (!response.ok) {
throw new Error(
t('Index request failed with HTTP {{status}}', {
@@ -100,7 +100,9 @@ export async function fetchPluginIconDataUri(
const fetchImpl = options.fetchImpl ?? globalThis.fetch
let response: Response
try {
response = await fetchImpl(url)
// Revalidate so a logo republished in place is not read from a stale
// cache and dropped by the digest check.
response = await fetchImpl(url, { cache: 'no-cache' })
} catch {
return null
}
@@ -90,7 +90,9 @@ export class PluginSourceFetchError extends Error {
/**
* Fetches plugin source in the browser. Every marketplace and URL-import fetch
* goes through here: the gateway never makes the outbound request, so there is
* no server-side SSRF surface.
* no server-side SSRF surface. The request always revalidates with the host:
* versioned sources are often served as immutable, and a file republished in
* place would otherwise be read from a stale cache and fail the index sha256.
*/
export async function fetchPluginSourceText(
url: string,
@@ -98,7 +100,7 @@ export async function fetchPluginSourceText(
): Promise<string> {
let response: Response
try {
response = await fetchImpl(url)
response = await fetchImpl(url, { cache: 'no-cache' })
} catch {
throw new PluginSourceFetchError('unreachable')
}