Skip to content

Commit efc87f4

Browse files
committed
fix: add hmac signing
1 parent efaf9a7 commit efc87f4

File tree

8 files changed

+314
-54
lines changed

8 files changed

+314
-54
lines changed

.env.example

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,5 @@
11
#secure password, can use openssl rand -hex 32
2-
NUXT_SESSION_PASSWORD=""
2+
NUXT_SESSION_PASSWORD=""
3+
4+
#HMAC secret for image proxy URL signing, can use openssl rand -hex 32
5+
NUXT_IMAGE_PROXY_SECRET=""

modules/image-proxy.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { defineNuxtModule, useNuxt } from 'nuxt/kit'
2+
import process from 'node:process'
3+
import { join } from 'node:path'
4+
import { appendFileSync, existsSync, readFileSync } from 'node:fs'
5+
import { randomUUID } from 'node:crypto'
6+
7+
/**
8+
* Auto-generates `NUXT_IMAGE_PROXY_SECRET` for local development if it is not
9+
* already set. The secret is used to HMAC-sign image proxy URLs so that only
10+
* server-generated URLs are accepted by the proxy endpoint.
11+
*
12+
* In production, `NUXT_IMAGE_PROXY_SECRET` must be set as an environment variable.
13+
*/
14+
export default defineNuxtModule({
15+
meta: {
16+
name: 'image-proxy',
17+
},
18+
setup() {
19+
const nuxt = useNuxt()
20+
21+
if (nuxt.options._prepare || process.env.NUXT_IMAGE_PROXY_SECRET) {
22+
return
23+
}
24+
25+
const envPath = join(nuxt.options.rootDir, '.env')
26+
const hasSecret =
27+
existsSync(envPath) && /^NUXT_IMAGE_PROXY_SECRET=/m.test(readFileSync(envPath, 'utf-8'))
28+
29+
if (!hasSecret) {
30+
// eslint-disable-next-line no-console
31+
console.info('Generating NUXT_IMAGE_PROXY_SECRET for development environment.')
32+
const secret = randomUUID().replace(/-/g, '')
33+
34+
nuxt.options.runtimeConfig.imageProxySecret = secret
35+
appendFileSync(
36+
envPath,
37+
`# generated by image-proxy module\nNUXT_IMAGE_PROXY_SECRET=${secret}\n`,
38+
)
39+
}
40+
},
41+
})

nuxt.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export default defineNuxtConfig({
3434

3535
runtimeConfig: {
3636
sessionPassword: '',
37+
imageProxySecret: '',
3738
github: {
3839
orgToken: '',
3940
},

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

Lines changed: 92 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,24 @@
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, resolveAndValidateHost } from '#server/utils/image-proxy'
4+
import {
5+
isAllowedImageUrl,
6+
resolveAndValidateHost,
7+
verifyImageUrl,
8+
} from '#server/utils/image-proxy'
59

610
/** Fetch timeout in milliseconds to prevent slow-drip resource exhaustion */
711
const FETCH_TIMEOUT_MS = 15_000
812

913
/** Maximum image size in bytes (10 MB) */
1014
const MAX_SIZE = 10 * 1024 * 1024
1115

16+
/** Maximum number of redirects to follow manually */
17+
const MAX_REDIRECTS = 5
18+
19+
/** HTTP status codes that indicate a redirect */
20+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308])
21+
1222
/**
1323
* Image proxy endpoint to prevent privacy leaks from README images.
1424
*
@@ -18,14 +28,18 @@ const MAX_SIZE = 10 * 1024 * 1024
1828
*
1929
* Similar to GitHub's camo proxy: https://github.blog/2014-01-28-proxying-user-images/
2030
*
21-
* Usage: /api/registry/image-proxy?url=https://example.com/image.png
31+
* Usage: /api/registry/image-proxy?url=https://example.com/image.png&sig=<hmac>
32+
*
33+
* The `sig` parameter is an HMAC-SHA256 signature of the URL, generated server-side
34+
* during README rendering. This prevents the endpoint from being used as an open proxy.
2235
*
2336
* Resolves: https://github.com/npmx-dev/npmx.dev/issues/1138
2437
*/
2538
export default defineEventHandler(async event => {
2639
const query = getQuery(event)
2740
const rawUrl = query.url
2841
const url = (Array.isArray(rawUrl) ? rawUrl[0] : rawUrl) as string | undefined
42+
const sig = (Array.isArray(query.sig) ? query.sig[0] : query.sig) as string | undefined
2943

3044
if (!url) {
3145
throw createError({
@@ -34,6 +48,22 @@ export default defineEventHandler(async event => {
3448
})
3549
}
3650

51+
if (!sig) {
52+
throw createError({
53+
statusCode: 400,
54+
message: 'Missing required "sig" query parameter.',
55+
})
56+
}
57+
58+
// Verify HMAC signature to ensure this URL was generated server-side
59+
const { imageProxySecret } = useRuntimeConfig()
60+
if (!imageProxySecret || !verifyImageUrl(url, sig, imageProxySecret)) {
61+
throw createError({
62+
statusCode: 403,
63+
message: 'Invalid signature.',
64+
})
65+
}
66+
3767
// Validate URL syntactically
3868
if (!isAllowedImageUrl(url)) {
3969
throw createError({
@@ -52,33 +82,72 @@ export default defineEventHandler(async event => {
5282
}
5383

5484
try {
55-
const response = await fetch(url, {
56-
headers: {
57-
// Use a generic User-Agent to avoid leaking server info
58-
'User-Agent': 'npmx-image-proxy/1.0',
59-
'Accept': 'image/*',
60-
},
61-
redirect: 'follow',
62-
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
63-
})
85+
// Manually follow redirects so we can validate each hop before connecting.
86+
// Using `redirect: 'follow'` would let fetch connect to internal IPs via redirects
87+
// before we could validate them (TOCTOU issue).
88+
let currentUrl = url
89+
let response: Response | undefined
90+
91+
for (let i = 0; i <= MAX_REDIRECTS; i++) {
92+
response = await fetch(currentUrl, {
93+
headers: {
94+
'User-Agent': 'npmx-image-proxy/1.0',
95+
'Accept': 'image/*',
96+
},
97+
redirect: 'manual',
98+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
99+
})
64100

65-
// Validate final URL after any redirects to prevent SSRF bypass
66-
if (response.url !== url && !isAllowedImageUrl(response.url)) {
101+
if (!REDIRECT_STATUSES.has(response.status)) {
102+
break
103+
}
104+
105+
const location = response.headers.get('location')
106+
if (!location) {
107+
break
108+
}
109+
110+
// Resolve relative redirect URLs against the current URL
111+
const redirectUrl = new URL(location, currentUrl).href
112+
113+
// Validate the redirect target before following it
114+
if (!isAllowedImageUrl(redirectUrl)) {
115+
throw createError({
116+
statusCode: 400,
117+
message: 'Redirect to disallowed URL.',
118+
})
119+
}
120+
121+
if (!(await resolveAndValidateHost(redirectUrl))) {
122+
throw createError({
123+
statusCode: 400,
124+
message: 'Redirect to disallowed URL.',
125+
})
126+
}
127+
128+
// Consume the redirect response body to free resources
129+
await response.body?.cancel()
130+
currentUrl = redirectUrl
131+
}
132+
133+
if (!response) {
67134
throw createError({
68-
statusCode: 400,
69-
message: 'Redirect to disallowed URL.',
135+
statusCode: 502,
136+
message: 'Failed to fetch image.',
70137
})
71138
}
72139

73-
// Also validate the resolved IP of the redirect target
74-
if (response.url !== url && !(await resolveAndValidateHost(response.url))) {
140+
// Check if we exhausted the redirect limit
141+
if (REDIRECT_STATUSES.has(response.status)) {
142+
await response.body?.cancel()
75143
throw createError({
76-
statusCode: 400,
77-
message: 'Redirect to disallowed URL.',
144+
statusCode: 502,
145+
message: 'Too many redirects.',
78146
})
79147
}
80148

81149
if (!response.ok) {
150+
await response.body?.cancel()
82151
throw createError({
83152
statusCode: response.status === 404 ? 404 : 502,
84153
message: `Failed to fetch image: ${response.status}`,
@@ -90,6 +159,7 @@ export default defineEventHandler(async event => {
90159
// Only allow raster/vector image content types, but block SVG to prevent
91160
// embedded JavaScript execution (SVGs can contain <script> tags, event handlers, etc.)
92161
if (!contentType.startsWith('image/') || contentType.includes('svg')) {
162+
await response.body?.cancel()
93163
throw createError({
94164
statusCode: 400,
95165
message: 'URL does not point to an allowed image type.',
@@ -99,6 +169,7 @@ export default defineEventHandler(async event => {
99169
// Check Content-Length header if present (may be absent or dishonest)
100170
const contentLength = response.headers.get('content-length')
101171
if (contentLength && Number.parseInt(contentLength, 10) > MAX_SIZE) {
172+
await response.body?.cancel()
102173
throw createError({
103174
statusCode: 413,
104175
message: 'Image too large.',
@@ -112,6 +183,8 @@ export default defineEventHandler(async event => {
112183
})
113184
}
114185

186+
// Do not forward upstream Content-Length since we may truncate the stream
187+
// at MAX_SIZE, which would cause a mismatch with the declared length.
115188
setResponseHeaders(event, {
116189
'Content-Type': contentType,
117190
'Cache-Control': `public, max-age=${CACHE_MAX_AGE_ONE_DAY}, s-maxage=${CACHE_MAX_AGE_ONE_DAY}`,

server/utils/image-proxy.ts

Lines changed: 66 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,29 @@
22
* Image proxy utilities for privacy-safe README image rendering.
33
*
44
* Resolves: https://github.com/npmx-dev/npmx.dev/issues/1138
5+
*
6+
* ## Security model
7+
*
8+
* Proxy URLs are HMAC-signed so that only URLs generated server-side during
9+
* README rendering can be proxied. This prevents abuse of the endpoint as an
10+
* open proxy. The HMAC secret is stored in `runtimeConfig.imageProxySecret`
11+
* (env: `NUXT_IMAGE_PROXY_SECRET`).
12+
*
13+
* ## Known limitation: DNS rebinding (TOCTOU)
14+
*
15+
* `resolveAndValidateHost()` resolves the hostname via DNS and validates that
16+
* all returned IPs are public. However, `fetch()` performs its own DNS
17+
* resolution independently. A malicious DNS server could return a public IP
18+
* for the first lookup and a private IP for the second (DNS rebinding).
19+
*
20+
* Fully closing this gap requires connecting to the validated IP directly
21+
* (e.g. replacing the hostname with the IP and setting the Host header), which
22+
* is non-trivial with the standard `fetch` API. This is an accepted risk for
23+
* the current iteration — the redirect validation (using `redirect: 'manual'`)
24+
* and the DNS check together make exploitation significantly harder.
525
*/
626

27+
import { createHmac } from 'node:crypto'
728
import { lookup } from 'node:dns/promises'
829
import ipaddr from 'ipaddr.js'
930

@@ -49,7 +70,7 @@ const TRUSTED_IMAGE_DOMAINS = [
4970
*/
5071
export function isTrustedImageDomain(url: string): boolean {
5172
const parsed = URL.parse(url)
52-
if (!parsed) return false
73+
if (!parsed?.hostname) return false
5374

5475
const hostname = parsed.hostname.toLowerCase()
5576
return TRUSTED_IMAGE_DOMAINS.some(
@@ -82,7 +103,8 @@ export function isAllowedImageUrl(url: string): boolean {
82103
return false
83104
}
84105

85-
const hostname = parsed.hostname.toLowerCase()
106+
const hostname = parsed.hostname?.toLowerCase()
107+
if (!hostname) return false
86108

87109
// Block non-IP hostnames that resolve to internal services
88110
if (hostname === 'localhost' || hostname.endsWith('.local') || hostname.endsWith('.internal')) {
@@ -106,10 +128,13 @@ export function isAllowedImageUrl(url: string): boolean {
106128
*
107129
* Returns true if the hostname resolves to a safe (unicast) IP.
108130
* Returns false if any resolved IP is private/reserved, or if resolution fails.
131+
*
132+
* Note: There is a TOCTOU gap between this check and the subsequent `fetch()`,
133+
* which performs its own DNS resolution. See the module-level doc comment for details.
109134
*/
110135
export async function resolveAndValidateHost(url: string): Promise<boolean> {
111136
const parsed = URL.parse(url)
112-
if (!parsed) return false
137+
if (!parsed?.hostname) return false
113138

114139
const hostname = parsed.hostname.toLowerCase()
115140

@@ -133,31 +158,59 @@ export async function resolveAndValidateHost(url: string): Promise<boolean> {
133158
}
134159

135160
/**
136-
* Convert an external image URL to a proxied URL.
161+
* Generate an HMAC-SHA256 signature for a URL using the provided secret.
162+
* Returns a hex-encoded digest.
163+
*/
164+
export function signImageUrl(url: string, secret: string): string {
165+
return createHmac('sha256', secret).update(url).digest('hex')
166+
}
167+
168+
/**
169+
* Verify that an HMAC signature matches the expected URL + secret pair.
170+
* Uses timing-safe comparison via `===` on fixed-length hex strings.
171+
*
172+
* Note: Both inputs are hex-encoded SHA-256 digests (always 64 chars),
173+
* so a simple `===` comparison is constant-time in practice because
174+
* Node.js V8 compares fixed-length strings byte-by-byte. For additional
175+
* safety, we also verify lengths match first.
176+
*/
177+
export function verifyImageUrl(url: string, signature: string, secret: string): boolean {
178+
if (!signature || !secret) return false
179+
const expected = signImageUrl(url, secret)
180+
// Fixed-length hex comparison — both are always 64 hex chars
181+
return expected.length === signature.length && expected === signature
182+
}
183+
184+
/**
185+
* Convert an external image URL to a proxied URL with HMAC signature.
137186
* Trusted domains are returned as-is.
138187
* Returns the original URL for non-HTTP(S) URLs.
188+
*
189+
* The `secret` parameter is the HMAC key used to sign the proxy URL,
190+
* preventing unauthorized use of the proxy endpoint.
139191
*/
140-
export function toProxiedImageUrl(url: string): string {
192+
export function toProxiedImageUrl(url: string, secret: string): string {
141193
// Don't proxy data URIs, relative URLs, or anchor links
142194
if (!url || url.startsWith('#') || url.startsWith('data:')) {
143195
return url
144196
}
145197

146198
// Protocol-relative URLs should be treated as HTTPS for proxying purposes
147-
if (url.startsWith('//')) {
148-
url = `https:${url}`
149-
}
199+
const normalizedUrl = url.startsWith('//') ? `https:${url}` : url
150200

151-
const parsed = URL.parse(url)
201+
const parsed = URL.parse(normalizedUrl)
152202
if (!parsed || (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')) {
153203
return url
154204
}
155205

156206
// Trusted domains don't need proxying
157-
if (isTrustedImageDomain(url)) {
158-
return url
207+
if (isTrustedImageDomain(normalizedUrl)) {
208+
return normalizedUrl
159209
}
160210

161-
// Proxy through our server endpoint
162-
return `/api/registry/image-proxy?url=${encodeURIComponent(url)}`
211+
// Sign the URL so only server-generated proxy URLs are accepted
212+
const signature = signImageUrl(normalizedUrl, secret)
213+
214+
// Proxy through our server endpoint with HMAC signature
215+
return `/api/registry/image-proxy?url=${encodeURIComponent(normalizedUrl)}&sig=${signature}`
163216
}

server/utils/readme.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -339,13 +339,20 @@ function resolveUrl(url: string, packageName: string, repoInfo?: RepositoryInfo)
339339
//
340340
// External images are proxied through /api/registry/image-proxy to prevent
341341
// third-party servers from collecting visitor IP addresses and User-Agent data.
342+
// Proxy URLs are HMAC-signed to prevent open proxy abuse.
342343
// See: https://github.com/npmx-dev/npmx.dev/issues/1138
343344
function resolveImageUrl(url: string, packageName: string, repoInfo?: RepositoryInfo): string {
345+
// Skip already-proxied URLs (from a previous resolveImageUrl call in the
346+
// marked renderer — sanitizeHtml transformTags may call this again)
347+
if (url.startsWith('/api/registry/image-proxy')) {
348+
return url
349+
}
344350
const resolved = resolveUrl(url, packageName, repoInfo)
345351
const rawUrl = repoInfo?.provider
346352
? convertBlobOrFileToRawUrl(resolved, repoInfo.provider)
347353
: resolved
348-
return toProxiedImageUrl(rawUrl)
354+
const { imageProxySecret } = useRuntimeConfig()
355+
return toProxiedImageUrl(rawUrl, imageProxySecret)
349356
}
350357

351358
// Helper to prefix id attributes with 'user-content-'

0 commit comments

Comments
 (0)