Skip to content

Commit 225c883

Browse files
committed
fix: add timeout, block svgs, prevent rebinding
1 parent baf5728 commit 225c883

File tree

3 files changed

+120
-14
lines changed

3 files changed

+120
-14
lines changed

server/api/registry/image-proxy/index.get.ts

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
import { createError, getQuery, setResponseHeaders, sendStream } from 'h3'
22
import { Readable } from 'node:stream'
33
import { CACHE_MAX_AGE_ONE_DAY } from '#shared/utils/constants'
4-
import { isAllowedImageUrl } from '#server/utils/image-proxy'
4+
import { isAllowedImageUrl, resolveAndValidateHost } from '#server/utils/image-proxy'
5+
6+
/** Fetch timeout in milliseconds to prevent slow-drip resource exhaustion */
7+
const FETCH_TIMEOUT_MS = 15_000
8+
9+
/** Maximum image size in bytes (10 MB) */
10+
const MAX_SIZE = 10 * 1024 * 1024
511

612
/**
713
* Image proxy endpoint to prevent privacy leaks from README images.
@@ -28,14 +34,23 @@ export default defineEventHandler(async event => {
2834
})
2935
}
3036

31-
// Validate URL
37+
// Validate URL syntactically
3238
if (!isAllowedImageUrl(url)) {
3339
throw createError({
3440
statusCode: 400,
3541
message: 'Invalid or disallowed image URL.',
3642
})
3743
}
3844

45+
// Resolve hostname via DNS and validate the resolved IP is not private.
46+
// This prevents DNS rebinding attacks where a hostname resolves to a private IP.
47+
if (!(await resolveAndValidateHost(url))) {
48+
throw createError({
49+
statusCode: 400,
50+
message: 'Invalid or disallowed image URL.',
51+
})
52+
}
53+
3954
try {
4055
const response = await fetch(url, {
4156
headers: {
@@ -44,6 +59,7 @@ export default defineEventHandler(async event => {
4459
'Accept': 'image/*',
4560
},
4661
redirect: 'follow',
62+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
4763
})
4864

4965
// Validate final URL after any redirects to prevent SSRF bypass
@@ -54,6 +70,14 @@ export default defineEventHandler(async event => {
5470
})
5571
}
5672

73+
// Also validate the resolved IP of the redirect target
74+
if (response.url !== url && !(await resolveAndValidateHost(response.url))) {
75+
throw createError({
76+
statusCode: 400,
77+
message: 'Redirect to disallowed URL.',
78+
})
79+
}
80+
5781
if (!response.ok) {
5882
throw createError({
5983
statusCode: response.status === 404 ? 404 : 502,
@@ -63,16 +87,16 @@ export default defineEventHandler(async event => {
6387

6488
const contentType = response.headers.get('content-type') || 'application/octet-stream'
6589

66-
// Only allow image content types
67-
if (!contentType.startsWith('image/')) {
90+
// Only allow raster/vector image content types, but block SVG to prevent
91+
// embedded JavaScript execution (SVGs can contain <script> tags, event handlers, etc.)
92+
if (!contentType.startsWith('image/') || contentType.includes('svg')) {
6893
throw createError({
6994
statusCode: 400,
70-
message: 'URL does not point to an image.',
95+
message: 'URL does not point to an allowed image type.',
7196
})
7297
}
7398

7499
// Check Content-Length header if present (may be absent or dishonest)
75-
const MAX_SIZE = 10 * 1024 * 1024 // 10 MB
76100
const contentLength = response.headers.get('content-length')
77101
if (contentLength && Number.parseInt(contentLength, 10) > MAX_SIZE) {
78102
throw createError({
@@ -97,13 +121,14 @@ export default defineEventHandler(async event => {
97121
})
98122

99123
// Stream the response with a size limit to prevent memory exhaustion.
100-
// This avoids buffering the entire image into memory before sending.
124+
// Uses pipe-based backpressure so the upstream pauses when the consumer is slow.
101125
let bytesRead = 0
102126
// eslint-disable-next-line @typescript-eslint/no-explicit-any
103127
const upstream = Readable.fromWeb(response.body as any)
104128
const limited = new Readable({
105129
read() {
106-
/* pulling is driven by upstream push */
130+
// Resume the upstream when the consumer is ready for more data
131+
upstream.resume()
107132
},
108133
})
109134

@@ -113,7 +138,11 @@ export default defineEventHandler(async event => {
113138
upstream.destroy()
114139
limited.destroy(new Error('Image too large'))
115140
} else {
116-
limited.push(chunk)
141+
// Respect backpressure: if push() returns false, pause the upstream
142+
// until the consumer calls read() again
143+
if (!limited.push(chunk)) {
144+
upstream.pause()
145+
}
117146
}
118147
})
119148
upstream.on('end', () => limited.push(null))

server/utils/image-proxy.ts

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
* Resolves: https://github.com/npmx-dev/npmx.dev/issues/1138
55
*/
66

7+
import { lookup } from 'node:dns/promises'
78
import ipaddr from 'ipaddr.js'
89

910
/** Trusted image domains that don't need proxying (first-party or well-known CDNs) */
@@ -56,6 +57,17 @@ export function isTrustedImageDomain(url: string): boolean {
5657
)
5758
}
5859

60+
/**
61+
* Check if a resolved IP address is in a private/reserved range.
62+
* Uses ipaddr.js for comprehensive IPv4, IPv6, and IPv4-mapped IPv6 range detection.
63+
*/
64+
function isPrivateIP(ip: string): boolean {
65+
const bare = ip.startsWith('[') && ip.endsWith(']') ? ip.slice(1, -1) : ip
66+
if (!ipaddr.isValid(bare)) return false
67+
const addr = ipaddr.process(bare)
68+
return addr.range() !== 'unicast'
69+
}
70+
5971
/**
6072
* Validate that a URL is a valid HTTP(S) image URL suitable for proxying.
6173
* Blocks private/reserved IPs (SSRF protection) using ipaddr.js for comprehensive
@@ -80,15 +92,41 @@ export function isAllowedImageUrl(url: string): boolean {
8092
// For IP addresses, use ipaddr.js to check against all reserved ranges
8193
// (loopback, private RFC 1918, link-local 169.254, IPv6 ULA fc00::/7, etc.)
8294
// ipaddr.process() also unwraps IPv4-mapped IPv6 (e.g. ::ffff:127.0.0.1 → 127.0.0.1)
95+
if (isPrivateIP(hostname)) {
96+
return false
97+
}
98+
99+
return true
100+
}
101+
102+
/**
103+
* Resolve the hostname of a URL via DNS and validate that all resolved IPs are
104+
* public unicast addresses. This prevents DNS rebinding SSRF attacks where a
105+
* hostname passes the initial string-based check but resolves to a private IP.
106+
*
107+
* Returns true if the hostname resolves to a safe (unicast) IP.
108+
* Returns false if any resolved IP is private/reserved, or if resolution fails.
109+
*/
110+
export async function resolveAndValidateHost(url: string): Promise<boolean> {
111+
const parsed = URL.parse(url)
112+
if (!parsed) return false
113+
114+
const hostname = parsed.hostname.toLowerCase()
115+
116+
// If it's already an IP literal, skip DNS resolution (already validated by isAllowedImageUrl)
83117
const bare = hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname
84118
if (ipaddr.isValid(bare)) {
85-
const addr = ipaddr.process(bare)
86-
if (addr.range() !== 'unicast') {
87-
return false
88-
}
119+
return !isPrivateIP(bare)
89120
}
90121

91-
return true
122+
try {
123+
// Resolve to check all returned IPs
124+
const { address } = await lookup(hostname)
125+
return !isPrivateIP(address)
126+
} catch {
127+
// DNS resolution failed — block the request
128+
return false
129+
}
92130
}
93131

94132
/**

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

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
isTrustedImageDomain,
44
isAllowedImageUrl,
55
toProxiedImageUrl,
6+
resolveAndValidateHost,
67
} from '../../../../server/utils/image-proxy'
78

89
describe('Image Proxy Utils', () => {
@@ -113,11 +114,49 @@ describe('Image Proxy Utils', () => {
113114
expect(isAllowedImageUrl('http://[fd12::1]/image.png')).toBe(false)
114115
})
115116

117+
it('blocks 0.0.0.0 (unspecified address)', () => {
118+
expect(isAllowedImageUrl('http://0.0.0.0/image.png')).toBe(false)
119+
})
120+
116121
it('returns false for invalid URLs', () => {
117122
expect(isAllowedImageUrl('not-a-url')).toBe(false)
118123
})
119124
})
120125

126+
describe('resolveAndValidateHost', () => {
127+
it('allows URLs with publicly-resolvable hostnames', async () => {
128+
// example.com resolves to a public IP
129+
expect(await resolveAndValidateHost('https://example.com/image.png')).toBe(true)
130+
})
131+
132+
it('blocks URLs with hostnames that resolve to loopback', async () => {
133+
// localhost resolves to 127.0.0.1
134+
expect(await resolveAndValidateHost('http://localhost/image.png')).toBe(false)
135+
})
136+
137+
it('blocks IP literals that are private', async () => {
138+
expect(await resolveAndValidateHost('http://127.0.0.1/image.png')).toBe(false)
139+
expect(await resolveAndValidateHost('http://10.0.0.1/image.png')).toBe(false)
140+
})
141+
142+
it('allows IP literals that are public', async () => {
143+
// 93.184.215.14 is example.com's IP
144+
expect(await resolveAndValidateHost('http://93.184.215.14/image.png')).toBe(true)
145+
})
146+
147+
it('blocks hostnames that fail DNS resolution', async () => {
148+
expect(
149+
await resolveAndValidateHost(
150+
'http://this-domain-definitely-does-not-exist.invalid/img.png',
151+
),
152+
).toBe(false)
153+
})
154+
155+
it('returns false for invalid URLs', async () => {
156+
expect(await resolveAndValidateHost('not-a-url')).toBe(false)
157+
})
158+
})
159+
121160
describe('toProxiedImageUrl', () => {
122161
it('returns trusted URLs as-is', () => {
123162
const url = 'https://raw.githubusercontent.com/owner/repo/main/image.png'

0 commit comments

Comments
 (0)