Skip to content

Commit 40f7ec9

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

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

@@ -111,6 +111,188 @@ describe('Providers', () => {
111111
}
112112
})
113113

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

0 commit comments

Comments
 (0)