mirror of
https://gh-proxy.org/https://github.com/klinsmaya/new-api.git
synced 2026-11-08 01:17:09 +08:00
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:
@@ -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')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user