Skip to content

Commit c7f2733

Browse files
committed
fix(cloudflare): handle app.baseURL, cross-zone origin detection and external sources
1 parent 67b40ac commit c7f2733

File tree

2 files changed

+223
-11
lines changed

2 files changed

+223
-11
lines changed

src/runtime/providers/cloudflare.ts

Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { encodeQueryItem, joinURL } from 'ufo'
1+
import { encodeQueryItem, hasProtocol, joinURL } from 'ufo'
22
import { createOperationsGenerator } from '../utils/index'
33
import { defineProvider } from '../utils/provider'
44

@@ -34,22 +34,52 @@ const defaultModifiers = {}
3434

3535
interface CloudflareOptions {
3636
baseURL?: string
37+
/** Explicit app origin for cross-zone resolution (e.g. 'https://app.example.com'). */
38+
appOrigin?: string
3739
}
3840

39-
// https://developers.cloudflare.com/images/image-resizing/url-format/
41+
function getRequestOrigin(event: unknown): string {
42+
const headers = (event as any)?.headers
43+
if (typeof headers?.get === 'function') {
44+
const forwardedHost = headers.get('x-forwarded-host')
45+
const host = (forwardedHost ? forwardedHost.split(',')[0].trim() : '') || headers.get('host')
46+
const proto = (headers.get('x-forwarded-proto') || 'https').split(',')[0].trim()
47+
if (host) return `${proto}://${host}`
48+
}
49+
if (typeof window !== 'undefined' && window.location?.origin && window.location.origin !== 'null') {
50+
return window.location.origin
51+
}
52+
return ''
53+
}
54+
55+
// https://developers.cloudflare.com/images/transform-images/transform-via-url/
4056
export default defineProvider<CloudflareOptions>({
41-
getImage: (src, {
42-
modifiers,
43-
baseURL = '/',
44-
}) => {
57+
getImage: (src, { modifiers, baseURL = '/', appOrigin }, ctx) => {
4558
const mergeModifiers = { ...defaultModifiers, ...modifiers }
4659
const operations = operationsGenerator(mergeModifiers as any)
4760

48-
// https://<ZONE>/cdn-cgi/image/<OPTIONS>/<SOURCE-IMAGE>
49-
const url = operations ? joinURL(baseURL, 'cdn-cgi/image', operations, src) : src
61+
const isExternal = hasProtocol(src)
62+
const sourcePath = isExternal ? src : joinURL(ctx.options.nuxt.baseURL, src)
5063

51-
return {
52-
url,
64+
// Cross-zone: resolve relative src to absolute URL so Cloudflare fetches from the correct origin
65+
let imageSource = sourcePath
66+
if (!isExternal && hasProtocol(baseURL)) {
67+
const origin = appOrigin || getRequestOrigin(ctx.options.event)
68+
if (origin) {
69+
imageSource = joinURL(origin, sourcePath)
70+
}
71+
else {
72+
console.warn(
73+
`[nuxt-image] Cloudflare cross-zone: could not determine app origin for source "${sourcePath}". `
74+
+ 'Set `appOrigin` in your Cloudflare provider options to fix this.',
75+
)
76+
}
5377
}
78+
79+
const url = operations
80+
? joinURL(baseURL, 'cdn-cgi/image', operations, imageSource)
81+
: sourcePath
82+
83+
return { url }
5484
},
5585
})

test/nuxt/providers.test.ts

Lines changed: 183 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, it, expect } from 'vitest'
1+
import { describe, it, expect, vi } from 'vitest'
22

33
import { images } from '../providers'
44

@@ -109,6 +109,188 @@ describe('Providers', () => {
109109
}
110110
})
111111

112+
it('cloudflare with app.baseURL', () => {
113+
const ctx = { options: { ...emptyContext.options, nuxt: { baseURL: '/admin/' } } } as any
114+
115+
expect(cloudflare().getImage('/images/test.png', {
116+
modifiers: { width: 200 },
117+
baseURL: '/',
118+
}, ctx)).toMatchObject({ url: '/cdn-cgi/image/w=200/admin/images/test.png' })
119+
120+
expect(cloudflare().getImage('/images/test.png', {
121+
modifiers: {},
122+
baseURL: '/',
123+
}, ctx)).toMatchObject({ url: '/admin/images/test.png' })
124+
})
125+
126+
it('cloudflare with external image', () => {
127+
expect(cloudflare().getImage('https://example.com/photo.jpg', {
128+
modifiers: { width: 200 },
129+
baseURL: '/',
130+
}, emptyContext)).toMatchObject({ url: '/cdn-cgi/image/w=200/https://example.com/photo.jpg' })
131+
132+
expect(cloudflare().getImage('https://example.com/photo.jpg', {
133+
modifiers: {},
134+
baseURL: '/',
135+
}, emptyContext)).toMatchObject({ url: 'https://example.com/photo.jpg' })
136+
})
137+
138+
it('cloudflare cross-zone', () => {
139+
const ctx = {
140+
options: {
141+
...emptyContext.options,
142+
nuxt: { baseURL: '/' },
143+
event: {
144+
headers: new Headers({
145+
'host': 'app.example.com',
146+
'x-forwarded-proto': 'https',
147+
}),
148+
},
149+
},
150+
} as any
151+
152+
expect(cloudflare().getImage('/images/test.png', {
153+
modifiers: { width: 200 },
154+
baseURL: 'https://cdn.example.com',
155+
}, ctx)).toMatchObject({ url: 'https://cdn.example.com/cdn-cgi/image/w=200/https://app.example.com/images/test.png' })
156+
157+
expect(cloudflare().getImage('/images/test.png', {
158+
modifiers: {},
159+
baseURL: 'https://cdn.example.com',
160+
}, ctx)).toMatchObject({ url: '/images/test.png' })
161+
})
162+
163+
it('cloudflare cross-zone with app.baseURL', () => {
164+
const ctx = {
165+
options: {
166+
...emptyContext.options,
167+
nuxt: { baseURL: '/admin/' },
168+
event: {
169+
headers: new Headers({
170+
'host': 'app.example.com',
171+
'x-forwarded-proto': 'https',
172+
}),
173+
},
174+
},
175+
} as any
176+
177+
expect(cloudflare().getImage('/images/test.png', {
178+
modifiers: { width: 200 },
179+
baseURL: 'https://cdn.example.com',
180+
}, ctx)).toMatchObject({ url: 'https://cdn.example.com/cdn-cgi/image/w=200/https://app.example.com/admin/images/test.png' })
181+
182+
expect(cloudflare().getImage('/images/test.png', {
183+
modifiers: {},
184+
baseURL: 'https://cdn.example.com',
185+
}, ctx)).toMatchObject({ url: '/admin/images/test.png' })
186+
})
187+
188+
it('cloudflare cross-zone with external src', () => {
189+
const ctx = {
190+
options: {
191+
...emptyContext.options,
192+
nuxt: { baseURL: '/' },
193+
event: {
194+
headers: new Headers({
195+
'host': 'app.example.com',
196+
'x-forwarded-proto': 'https',
197+
}),
198+
},
199+
},
200+
} as any
201+
202+
expect(cloudflare().getImage('https://other.example.com/images/test.png', {
203+
modifiers: { width: 200 },
204+
baseURL: 'https://cdn.example.com',
205+
}, ctx)).toMatchObject({ url: 'https://cdn.example.com/cdn-cgi/image/w=200/https://other.example.com/images/test.png' })
206+
207+
expect(cloudflare().getImage('https://other.example.com/images/test.png', {
208+
modifiers: {},
209+
baseURL: 'https://cdn.example.com',
210+
}, ctx)).toMatchObject({ url: 'https://other.example.com/images/test.png' })
211+
})
212+
213+
it('cloudflare cross-zone with appOrigin', () => {
214+
const ctx = {
215+
options: {
216+
...emptyContext.options,
217+
nuxt: { baseURL: '/admin/' },
218+
},
219+
} as any
220+
221+
expect(cloudflare().getImage('/images/test.png', {
222+
modifiers: { width: 200 },
223+
baseURL: 'https://cdn.example.com',
224+
appOrigin: 'https://app.example.com',
225+
}, ctx)).toMatchObject({ url: 'https://cdn.example.com/cdn-cgi/image/w=200/https://app.example.com/admin/images/test.png' })
226+
})
227+
228+
it('cloudflare cross-zone warns when origin cannot be determined', () => {
229+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
230+
const ctx = {
231+
options: {
232+
...emptyContext.options,
233+
nuxt: { baseURL: '/' },
234+
},
235+
} as any
236+
237+
const origOrigin = window.location.origin
238+
Object.defineProperty(window, 'location', { value: { origin: 'null' }, writable: true })
239+
240+
cloudflare().getImage('/images/test.png', {
241+
modifiers: { width: 200 },
242+
baseURL: 'https://cdn.example.com',
243+
}, ctx)
244+
245+
expect(warnSpy).toHaveBeenCalledWith(
246+
expect.stringContaining('[nuxt-image] Cloudflare cross-zone'),
247+
)
248+
249+
Object.defineProperty(window, 'location', { value: { origin: origOrigin }, writable: true })
250+
warnSpy.mockRestore()
251+
})
252+
253+
it('cloudflare cross-zone handles multi-value x-forwarded-proto', () => {
254+
const ctx = {
255+
options: {
256+
...emptyContext.options,
257+
nuxt: { baseURL: '/' },
258+
event: {
259+
headers: new Headers({
260+
'host': 'app.example.com',
261+
'x-forwarded-proto': 'https, http',
262+
}),
263+
},
264+
},
265+
} as any
266+
267+
expect(cloudflare().getImage('/images/test.png', {
268+
modifiers: { width: 200 },
269+
baseURL: 'https://cdn.example.com',
270+
}, ctx)).toMatchObject({ url: 'https://cdn.example.com/cdn-cgi/image/w=200/https://app.example.com/images/test.png' })
271+
})
272+
273+
it('cloudflare cross-zone appOrigin overrides headers', () => {
274+
const ctx = {
275+
options: {
276+
...emptyContext.options,
277+
nuxt: { baseURL: '/' },
278+
event: {
279+
headers: new Headers({
280+
'host': 'injected.attacker.com',
281+
'x-forwarded-proto': 'https',
282+
}),
283+
},
284+
},
285+
} as any
286+
287+
expect(cloudflare().getImage('/images/test.png', {
288+
modifiers: { width: 200 },
289+
baseURL: 'https://cdn.example.com',
290+
appOrigin: 'https://app.example.com',
291+
}, ctx)).toMatchObject({ url: 'https://cdn.example.com/cdn-cgi/image/w=200/https://app.example.com/images/test.png' })
292+
})
293+
112294
it('cloudinary', () => {
113295
const providerOptions = {
114296
baseURL: '/',

0 commit comments

Comments
 (0)