Skip to content

Commit 1640ccd

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

2 files changed

Lines changed: 183 additions & 10 deletions

File tree

src/runtime/providers/cloudflare.ts

Lines changed: 46 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,58 @@ const defaultModifiers = {}
3434

3535
interface CloudflareOptions {
3636
baseURL?: string
37+
/**
38+
* Pinned app origin for cross-zone source resolution (e.g. 'https://app.example.com').
39+
* When set, request header-derived origin is ignored, preventing header injection.
40+
*/
41+
appOrigin?: string
3742
}
3843

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

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

51-
return {
52-
url,
66+
// When baseURL is a different zone (absolute URL) and src is relative,
67+
// resolve to an absolute URL so Cloudflare can fetch from the correct origin.
68+
// appOrigin takes priority over header detection to prevent header injection.
69+
let imageSource = sourcePath
70+
if (!isExternal && hasProtocol(baseURL)) {
71+
const origin = appOrigin || getRequestOrigin(ctx.options.event)
72+
if (origin) {
73+
imageSource = joinURL(origin, sourcePath)
74+
}
75+
else if (typeof console !== 'undefined') {
76+
console.warn(
77+
`[nuxt-image] Cloudflare cross-zone: could not determine app origin for source "${sourcePath}". `
78+
+ 'Image will resolve against the CDN zone which may be incorrect. '
79+
+ 'Set `appOrigin` in your Cloudflare provider options to fix this.',
80+
)
81+
}
5382
}
83+
84+
// Without operations no Cloudflare transform is needed — return the local path directly
85+
const url = operations
86+
? joinURL(baseURL, 'cdn-cgi/image', operations, imageSource)
87+
: sourcePath
88+
89+
return { url }
5490
},
5591
})

test/nuxt/providers.test.ts

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,143 @@ 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 appOrigin overrides headers', () => {
229+
const ctx = {
230+
options: {
231+
...emptyContext.options,
232+
nuxt: { baseURL: '/' },
233+
event: {
234+
headers: new Headers({
235+
'host': 'injected.attacker.com',
236+
'x-forwarded-proto': 'https',
237+
}),
238+
},
239+
},
240+
} as any
241+
242+
expect(cloudflare().getImage('/images/test.png', {
243+
modifiers: { width: 200 },
244+
baseURL: 'https://cdn.example.com',
245+
appOrigin: 'https://app.example.com',
246+
}, ctx)).toMatchObject({ url: 'https://cdn.example.com/cdn-cgi/image/w=200/https://app.example.com/images/test.png' })
247+
})
248+
112249
it('cloudinary', () => {
113250
const providerOptions = {
114251
baseURL: '/',

0 commit comments

Comments
 (0)