Skip to content

Commit ae4e571

Browse files
authored
fix(proxy): hash upstream cache keys (#835)
1 parent 0eb882e commit ae4e571

3 files changed

Lines changed: 87 additions & 5 deletions

File tree

packages/script/src/runtime/server/instagram-embed.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createError, defineEventHandler, getQuery, setHeader } from 'h3'
22
import { defineCachedFunction, useRuntimeConfig } from 'nitropack/runtime'
33
import { $fetch } from 'ofetch'
4+
import { hash } from 'ohash'
45
import { ELEMENT_NODE, parse, renderSync, TEXT_NODE, walkSync } from 'ultrahtml'
56
import { createCachedJsonFetch } from './utils/cached-upstream'
67
import { isEmbedShell, proxyAssetUrl, rewriteUrl, rewriteUrlsInText, RSRC_RE, scopeCss } from './utils/instagram-embed'
@@ -39,7 +40,7 @@ const cachedEmbedFetch = defineCachedFunction(
3940
const parts = [url]
4041
for (const [k, v] of Object.entries(headers).sort(([a], [b]) => a.localeCompare(b)))
4142
parts.push(`${k}=${v}`)
42-
return parts.join('|')
43+
return hash(parts)
4344
},
4445
},
4546
)

packages/script/src/runtime/server/utils/cached-upstream.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Buffer } from 'node:buffer'
22
import { defineCachedFunction } from 'nitropack/runtime'
33
import { $fetch } from 'ofetch'
4+
import { hash } from 'ohash'
45

56
/**
67
* Server-side caches for upstream proxy fetches.
@@ -75,7 +76,7 @@ export function createCachedBinaryFetch(
7576
staleMaxAge: maxAge,
7677
getKey: (url: string, opts?: CachedBinaryFetchOptions) => {
7778
if (!opts)
78-
return url
79+
return hash(url)
7980
// Vary on headers + redirect mode — callers with different user agents
8081
// or redirect policies may get different upstream responses.
8182
const parts = [url]
@@ -86,7 +87,9 @@ export function createCachedBinaryFetch(
8687
}
8788
if (opts.redirect)
8889
parts.push(`redirect=${opts.redirect}`)
89-
return parts.join('|')
90+
if (opts.ignoreResponseError !== undefined)
91+
parts.push(`ignoreResponseError=${opts.ignoreResponseError}`)
92+
return hash(parts)
9093
},
9194
},
9295
)
@@ -102,7 +105,8 @@ export function createCachedBinaryFetch(
102105
/**
103106
* Cache upstream JSON/text fetches. `getKey` is caller-controlled so handlers
104107
* can normalize on whichever inner params identify the resource (tweet ID,
105-
* post URL, query hash).
108+
* post URL, query hash). The normalized value is hashed before it reaches
109+
* Nitro storage because raw URLs can contain reserved key characters.
106110
*/
107111
export function createCachedJsonFetch<T>(
108112
name: string,
@@ -121,7 +125,7 @@ export function createCachedJsonFetch<T>(
121125
maxAge,
122126
swr: true,
123127
staleMaxAge: maxAge,
124-
getKey,
128+
getKey: (url, opts) => hash(getKey(url, opts)),
125129
},
126130
)
127131
}

test/unit/cached-upstream.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest'
2+
3+
const { cacheDefinitions, hashMock } = vi.hoisted(() => ({
4+
cacheDefinitions: [] as Array<{ getKey?: (...args: any[]) => string }>,
5+
hashMock: vi.fn((value: unknown) => `hashed:${JSON.stringify(value)}`),
6+
}))
7+
8+
vi.mock('nitropack/runtime', () => ({
9+
defineCachedFunction: vi.fn((handler, options) => {
10+
cacheDefinitions.push(options)
11+
return handler
12+
}),
13+
}))
14+
15+
vi.mock('ohash', () => ({
16+
hash: hashMock,
17+
}))
18+
19+
vi.mock('ofetch', () => ({
20+
$fetch: Object.assign(vi.fn(), { raw: vi.fn() }),
21+
}))
22+
23+
const { createCachedBinaryFetch, createCachedJsonFetch } = await import(
24+
'../../packages/script/src/runtime/server/utils/cached-upstream',
25+
)
26+
27+
beforeEach(() => {
28+
cacheDefinitions.length = 0
29+
hashMock.mockClear()
30+
})
31+
32+
describe('upstream cache keys', () => {
33+
it('hashes normalized JSON cache keys before passing them to Nitro storage', () => {
34+
const normalizeKey = vi.fn((url: string) => new URL(url).searchParams.get('actor') || url)
35+
createCachedJsonFetch('profile', 60, normalizeKey)
36+
37+
const getKey = cacheDefinitions[0]?.getKey
38+
expect(getKey).toBeTypeOf('function')
39+
expect(getKey?.('https://public.api.bsky.app/profile?actor=nuxt.com')).toBe('hashed:"nuxt.com"')
40+
expect(normalizeKey).toHaveBeenCalledOnce()
41+
expect(hashMock).toHaveBeenCalledWith('nuxt.com')
42+
})
43+
44+
it('hashes binary URLs instead of exposing reserved characters to storage', () => {
45+
createCachedBinaryFetch('image', 60)
46+
47+
const getKey = cacheDefinitions[0]?.getKey
48+
const url = 'https://cdn.example.com/image.jpg?width=640&format=webp'
49+
expect(getKey?.(url)).toBe(`hashed:${JSON.stringify(url)}`)
50+
expect(hashMock).toHaveBeenCalledWith(url)
51+
})
52+
53+
it('uses stable binary option ordering and varies on response-error behavior', () => {
54+
createCachedBinaryFetch('image', 60)
55+
56+
const getKey = cacheDefinitions[0]?.getKey
57+
const key = getKey?.('https://cdn.example.com/image.jpg', {
58+
headers: { Zebra: 'last', Accept: 'image/webp' },
59+
redirect: 'manual',
60+
ignoreResponseError: true,
61+
})
62+
63+
expect(key).toBe('hashed:["https://cdn.example.com/image.jpg","Accept=image/webp","Zebra=last","redirect=manual","ignoreResponseError=true"]')
64+
})
65+
66+
it('hashes Instagram embed URLs together with their response-varying headers', async () => {
67+
await import('../../packages/script/src/runtime/server/instagram-embed')
68+
69+
const getKey = cacheDefinitions[0]?.getKey
70+
const key = getKey?.('https://www.instagram.com/p/example/embed/captioned/', {
71+
'User-Agent': 'Nuxt Scripts',
72+
'Accept': 'text/html',
73+
})
74+
75+
expect(key).toBe('hashed:["https://www.instagram.com/p/example/embed/captioned/","Accept=text/html","User-Agent=Nuxt Scripts"]')
76+
})
77+
})

0 commit comments

Comments
 (0)