Skip to content

Commit 85d5bd9

Browse files
committed
refactor: use ipaddr.js to handle private ranges + move to server/ dir
1 parent 53b2cf5 commit 85d5bd9

File tree

7 files changed

+26
-37
lines changed

7 files changed

+26
-37
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@
8383
"fast-npm-meta": "1.0.0",
8484
"focus-trap": "^7.8.0",
8585
"gray-matter": "4.0.3",
86+
"ipaddr.js": "2.3.0",
8687
"marked": "17.0.1",
8788
"module-replacements": "2.11.0",
8889
"nuxt": "4.3.1",

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
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 '#shared/utils/image-proxy'
4+
import { isAllowedImageUrl } from '#server/utils/image-proxy'
55

66
/**
77
* Image proxy endpoint to prevent privacy leaks from README images.
Lines changed: 18 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
* Resolves: https://github.com/npmx-dev/npmx.dev/issues/1138
55
*/
66

7+
import ipaddr from 'ipaddr.js'
8+
79
/** Trusted image domains that don't need proxying (first-party or well-known CDNs) */
810
const TRUSTED_IMAGE_DOMAINS = [
911
// First-party
@@ -56,6 +58,8 @@ export function isTrustedImageDomain(url: string): boolean {
5658

5759
/**
5860
* Validate that a URL is a valid HTTP(S) image URL suitable for proxying.
61+
* Blocks private/reserved IPs (SSRF protection) using ipaddr.js for comprehensive
62+
* IPv4, IPv6, and IPv4-mapped IPv6 range detection.
5963
*/
6064
export function isAllowedImageUrl(url: string): boolean {
6165
const parsed = URL.parse(url)
@@ -66,44 +70,24 @@ export function isAllowedImageUrl(url: string): boolean {
6670
return false
6771
}
6872

69-
// Block localhost / private IPs to prevent SSRF
7073
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-
) {
74+
75+
// Block non-IP hostnames that resolve to internal services
76+
if (hostname === 'localhost' || hostname.endsWith('.local') || hostname.endsWith('.internal')) {
10477
return false
10578
}
10679

80+
// For IP addresses, use ipaddr.js to check against all reserved ranges
81+
// (loopback, private RFC 1918, link-local 169.254, IPv6 ULA fc00::/7, etc.)
82+
// ipaddr.process() also unwraps IPv4-mapped IPv6 (e.g. ::ffff:127.0.0.1 → 127.0.0.1)
83+
const bare = hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname
84+
if (ipaddr.isValid(bare)) {
85+
const addr = ipaddr.process(bare)
86+
if (addr.range() !== 'unicast') {
87+
return false
88+
}
89+
}
90+
10791
return true
10892
}
10993

server/utils/readme.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type { ReadmeResponse, TocItem } from '#shared/types/readme'
55
import { convertBlobOrFileToRawUrl, type RepositoryInfo } from '#shared/utils/git-providers'
66
import { highlightCodeSync } from './shiki'
77
import { convertToEmoji } from '#shared/utils/emoji'
8-
import { toProxiedImageUrl } from '#shared/utils/image-proxy'
8+
import { toProxiedImageUrl } from '#server/utils/image-proxy'
99

1010
/**
1111
* Playground provider configuration

test/unit/shared/utils/image-proxy.spec.ts renamed to test/unit/server/utils/image-proxy.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import {
33
isTrustedImageDomain,
44
isAllowedImageUrl,
55
toProxiedImageUrl,
6-
} from '../../../../shared/utils/image-proxy'
6+
} from '../../../../server/utils/image-proxy'
77

88
describe('Image Proxy Utils', () => {
99
describe('isTrustedImageDomain', () => {

vitest.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export default defineConfig({
1212
resolve: {
1313
alias: {
1414
'#shared': `${rootDir}/shared`,
15+
'#server': `${rootDir}/server`,
1516
},
1617
},
1718
test: {

0 commit comments

Comments
 (0)