Skip to content

Commit 53b2cf5

Browse files
committed
fix: return stream, protect against ssrf
1 parent 87dc65b commit 53b2cf5

File tree

3 files changed

+177
-123
lines changed

3 files changed

+177
-123
lines changed
Lines changed: 99 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { createError, getQuery, setResponseHeaders } from 'h3'
1+
import { createError, getQuery, setResponseHeaders, sendStream } from 'h3'
2+
import { Readable } from 'node:stream'
23
import { CACHE_MAX_AGE_ONE_DAY } from '#shared/utils/constants'
34
import { isAllowedImageUrl } from '#shared/utils/image-proxy'
45

@@ -15,105 +16,119 @@ import { isAllowedImageUrl } from '#shared/utils/image-proxy'
1516
*
1617
* Resolves: https://github.com/npmx-dev/npmx.dev/issues/1138
1718
*/
18-
export default defineCachedEventHandler(
19-
async event => {
20-
const query = getQuery(event)
21-
const url = query.url as string | undefined
19+
export default defineEventHandler(async event => {
20+
const query = getQuery(event)
21+
const rawUrl = query.url
22+
const url = (Array.isArray(rawUrl) ? rawUrl[0] : rawUrl) as string | undefined
2223

23-
if (!url) {
24+
if (!url) {
25+
throw createError({
26+
statusCode: 400,
27+
message: 'Missing required "url" query parameter.',
28+
})
29+
}
30+
31+
// Validate URL
32+
if (!isAllowedImageUrl(url)) {
33+
throw createError({
34+
statusCode: 400,
35+
message: 'Invalid or disallowed image URL.',
36+
})
37+
}
38+
39+
try {
40+
const response = await fetch(url, {
41+
headers: {
42+
// Use a generic User-Agent to avoid leaking server info
43+
'User-Agent': 'npmx-image-proxy/1.0',
44+
'Accept': 'image/*',
45+
},
46+
redirect: 'follow',
47+
})
48+
49+
// Validate final URL after any redirects to prevent SSRF bypass
50+
if (response.url !== url && !isAllowedImageUrl(response.url)) {
2451
throw createError({
2552
statusCode: 400,
26-
message: 'Missing required "url" query parameter.',
53+
message: 'Redirect to disallowed URL.',
2754
})
2855
}
2956

30-
// Validate URL
31-
if (!isAllowedImageUrl(url)) {
57+
if (!response.ok) {
3258
throw createError({
33-
statusCode: 400,
34-
message: 'Invalid or disallowed image URL.',
59+
statusCode: response.status === 404 ? 404 : 502,
60+
message: `Failed to fetch image: ${response.status}`,
3561
})
3662
}
3763

38-
try {
39-
const response = await fetch(url, {
40-
headers: {
41-
// Use a generic User-Agent to avoid leaking server info
42-
'User-Agent': 'npmx-image-proxy/1.0',
43-
'Accept': 'image/*',
44-
},
45-
// Prevent redirects to non-HTTP protocols
46-
redirect: 'follow',
47-
})
48-
49-
if (!response.ok) {
50-
throw createError({
51-
statusCode: response.status === 404 ? 404 : 502,
52-
message: `Failed to fetch image: ${response.status}`,
53-
})
54-
}
55-
56-
const contentType = response.headers.get('content-type') || 'application/octet-stream'
64+
const contentType = response.headers.get('content-type') || 'application/octet-stream'
5765

58-
// Only allow image content types
59-
if (
60-
!contentType.startsWith('image/') &&
61-
!contentType.startsWith('application/octet-stream')
62-
) {
63-
throw createError({
64-
statusCode: 400,
65-
message: 'URL does not point to an image.',
66-
})
67-
}
66+
// Only allow image content types
67+
if (!contentType.startsWith('image/')) {
68+
throw createError({
69+
statusCode: 400,
70+
message: 'URL does not point to an image.',
71+
})
72+
}
6873

69-
// Enforce a maximum size of 10 MB to prevent abuse
70-
const contentLength = response.headers.get('content-length')
71-
const MAX_SIZE = 10 * 1024 * 1024 // 10 MB
72-
if (contentLength && Number.parseInt(contentLength, 10) > MAX_SIZE) {
73-
throw createError({
74-
statusCode: 413,
75-
message: 'Image too large.',
76-
})
77-
}
74+
// Check Content-Length header if present (may be absent or dishonest)
75+
const MAX_SIZE = 10 * 1024 * 1024 // 10 MB
76+
const contentLength = response.headers.get('content-length')
77+
if (contentLength && Number.parseInt(contentLength, 10) > MAX_SIZE) {
78+
throw createError({
79+
statusCode: 413,
80+
message: 'Image too large.',
81+
})
82+
}
7883

79-
const imageBuffer = await response.arrayBuffer()
84+
if (!response.body) {
85+
throw createError({
86+
statusCode: 502,
87+
message: 'No response body from upstream.',
88+
})
89+
}
8090

81-
// Check actual size
82-
if (imageBuffer.byteLength > MAX_SIZE) {
83-
throw createError({
84-
statusCode: 413,
85-
message: 'Image too large.',
86-
})
87-
}
91+
setResponseHeaders(event, {
92+
'Content-Type': contentType,
93+
'Cache-Control': `public, max-age=${CACHE_MAX_AGE_ONE_DAY}, s-maxage=${CACHE_MAX_AGE_ONE_DAY}`,
94+
// Security headers - prevent content sniffing and restrict usage
95+
'X-Content-Type-Options': 'nosniff',
96+
'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'",
97+
})
8898

89-
setResponseHeaders(event, {
90-
'Content-Type': contentType,
91-
'Content-Length': imageBuffer.byteLength.toString(),
92-
'Cache-Control': `public, max-age=${CACHE_MAX_AGE_ONE_DAY}, s-maxage=${CACHE_MAX_AGE_ONE_DAY}`,
93-
// Security headers - prevent content sniffing and restrict usage
94-
'X-Content-Type-Options': 'nosniff',
95-
'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'",
96-
})
99+
// Stream the response with a size limit to prevent memory exhaustion.
100+
// This avoids buffering the entire image into memory before sending.
101+
let bytesRead = 0
102+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
103+
const upstream = Readable.fromWeb(response.body as any)
104+
const limited = new Readable({
105+
read() {
106+
/* pulling is driven by upstream push */
107+
},
108+
})
97109

98-
return Buffer.from(imageBuffer)
99-
} catch (error: unknown) {
100-
// Re-throw H3 errors
101-
if (error && typeof error === 'object' && 'statusCode' in error) {
102-
throw error
110+
upstream.on('data', (chunk: Buffer) => {
111+
bytesRead += chunk.length
112+
if (bytesRead > MAX_SIZE) {
113+
upstream.destroy()
114+
limited.destroy(new Error('Image too large'))
115+
} else {
116+
limited.push(chunk)
103117
}
118+
})
119+
upstream.on('end', () => limited.push(null))
120+
upstream.on('error', (err: Error) => limited.destroy(err))
104121

105-
throw createError({
106-
statusCode: 502,
107-
message: 'Failed to proxy image.',
108-
})
122+
return sendStream(event, limited)
123+
} catch (error: unknown) {
124+
// Re-throw H3 errors
125+
if (error && typeof error === 'object' && 'statusCode' in error) {
126+
throw error
109127
}
110-
},
111-
{
112-
maxAge: CACHE_MAX_AGE_ONE_DAY,
113-
swr: true,
114-
getKey: event => {
115-
const query = getQuery(event)
116-
return `image-proxy:${query.url}`
117-
},
118-
},
119-
)
128+
129+
throw createError({
130+
statusCode: 502,
131+
message: 'Failed to proxy image.',
132+
})
133+
}
134+
})

shared/utils/image-proxy.ts

Lines changed: 54 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -45,46 +45,66 @@ const TRUSTED_IMAGE_DOMAINS = [
4545
* Check if a URL points to a trusted domain that doesn't need proxying.
4646
*/
4747
export function isTrustedImageDomain(url: string): boolean {
48-
try {
49-
const parsed = new URL(url)
50-
const hostname = parsed.hostname.toLowerCase()
51-
return TRUSTED_IMAGE_DOMAINS.some(
52-
domain => hostname === domain || hostname.endsWith(`.${domain}`),
53-
)
54-
} catch {
55-
return false
56-
}
48+
const parsed = URL.parse(url)
49+
if (!parsed) return false
50+
51+
const hostname = parsed.hostname.toLowerCase()
52+
return TRUSTED_IMAGE_DOMAINS.some(
53+
domain => hostname === domain || hostname.endsWith(`.${domain}`),
54+
)
5755
}
5856

5957
/**
6058
* Validate that a URL is a valid HTTP(S) image URL suitable for proxying.
6159
*/
6260
export function isAllowedImageUrl(url: string): boolean {
63-
try {
64-
const parsed = new URL(url)
65-
// Only allow HTTP and HTTPS protocols
66-
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
67-
return false
68-
}
69-
// Block localhost / private IPs to prevent SSRF
70-
const hostname = parsed.hostname.toLowerCase()
71-
if (
72-
hostname === 'localhost' ||
73-
hostname === '127.0.0.1' ||
74-
hostname === '::1' ||
75-
hostname === '0.0.0.0' ||
76-
hostname.startsWith('10.') ||
77-
hostname.startsWith('192.168.') ||
78-
hostname.startsWith('172.') ||
79-
hostname.endsWith('.local') ||
80-
hostname.endsWith('.internal')
81-
) {
82-
return false
83-
}
84-
return true
85-
} catch {
61+
const parsed = URL.parse(url)
62+
if (!parsed) return false
63+
64+
// Only allow HTTP and HTTPS protocols
65+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
8666
return false
8767
}
68+
69+
// Block localhost / private IPs to prevent SSRF
70+
const hostname = parsed.hostname.toLowerCase()
71+
if (
72+
hostname === 'localhost' ||
73+
hostname === '127.0.0.1' ||
74+
hostname === '0.0.0.0' ||
75+
hostname.startsWith('10.') ||
76+
hostname.startsWith('192.168.') ||
77+
// RFC 1918: 172.16.0.0 – 172.31.255.255
78+
/^172\.(1[6-9]|2\d|3[01])\./.test(hostname) ||
79+
// Link-local (cloud metadata: 169.254.169.254)
80+
hostname.startsWith('169.254.') ||
81+
hostname.endsWith('.local') ||
82+
hostname.endsWith('.internal') ||
83+
// IPv6 loopback
84+
hostname === '::1' ||
85+
hostname === '[::1]' ||
86+
// IPv6 link-local
87+
hostname.startsWith('fe80:') ||
88+
hostname.startsWith('[fe80:') ||
89+
// IPv6 unique local (fc00::/7)
90+
hostname.startsWith('fc') ||
91+
hostname.startsWith('fd') ||
92+
hostname.startsWith('[fc') ||
93+
hostname.startsWith('[fd') ||
94+
// IPv4-mapped IPv6 addresses
95+
hostname.startsWith('::ffff:127.') ||
96+
hostname.startsWith('::ffff:10.') ||
97+
hostname.startsWith('::ffff:192.168.') ||
98+
hostname.startsWith('::ffff:169.254.') ||
99+
hostname.startsWith('[::ffff:127.') ||
100+
hostname.startsWith('[::ffff:10.') ||
101+
hostname.startsWith('[::ffff:192.168.') ||
102+
hostname.startsWith('[::ffff:169.254.')
103+
) {
104+
return false
105+
}
106+
107+
return true
88108
}
89109

90110
/**
@@ -98,13 +118,8 @@ export function toProxiedImageUrl(url: string): string {
98118
return url
99119
}
100120

101-
try {
102-
const parsed = new URL(url)
103-
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
104-
return url
105-
}
106-
} catch {
107-
// Not an absolute URL, return as-is (relative URLs are fine)
121+
const parsed = URL.parse(url)
122+
if (!parsed || (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')) {
108123
return url
109124
}
110125

test/unit/shared/utils/image-proxy.spec.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,30 @@ describe('Image Proxy Utils', () => {
8989
expect(isAllowedImageUrl('http://service.internal/image.png')).toBe(false)
9090
})
9191

92+
it('blocks cloud metadata IP (169.254.x.x)', () => {
93+
expect(isAllowedImageUrl('http://169.254.169.254/latest/meta-data/')).toBe(false)
94+
})
95+
96+
it('blocks private 172.16-31.x.x range', () => {
97+
expect(isAllowedImageUrl('http://172.16.0.1/image.png')).toBe(false)
98+
expect(isAllowedImageUrl('http://172.31.255.255/image.png')).toBe(false)
99+
})
100+
101+
it('allows public 172.x IPs outside RFC 1918', () => {
102+
expect(isAllowedImageUrl('http://172.64.0.1/image.png')).toBe(true)
103+
expect(isAllowedImageUrl('http://172.15.0.1/image.png')).toBe(true)
104+
expect(isAllowedImageUrl('http://172.32.0.1/image.png')).toBe(true)
105+
})
106+
107+
it('blocks IPv6 link-local (fe80::)', () => {
108+
expect(isAllowedImageUrl('http://[fe80::1]/image.png')).toBe(false)
109+
})
110+
111+
it('blocks IPv6 unique local (fc00::/fd)', () => {
112+
expect(isAllowedImageUrl('http://[fc00::1]/image.png')).toBe(false)
113+
expect(isAllowedImageUrl('http://[fd12::1]/image.png')).toBe(false)
114+
})
115+
92116
it('returns false for invalid URLs', () => {
93117
expect(isAllowedImageUrl('not-a-url')).toBe(false)
94118
})

0 commit comments

Comments
 (0)