Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/content/2.guide/1.features.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ Make sure to replace `TYPE` with one of the options listed below and `YOUR_PACKA
- **maintenance**: NPMS.io maintenance score based on activity. :img{src="https://img.shields.io/badge/%23eab308-eab308" class="inline align-middle h-5 w-14"}
- **score**: The overall NPMS.io combined score. :img{src="https://img.shields.io/badge/%233b82f6-3b82f6" class="inline align-middle h-5 w-14"}
- **name**: Simple badge displaying the package name. :img{src="https://img.shields.io/badge/%2364748b-64748b" class="inline align-middle h-5 w-14"}
- **endpoint**: Displays data from an external JSON endpoint via `url` query parameter. :img{src="https://img.shields.io/badge/%2364748b-64748b" class="inline align-middle h-5 w-14"}

#### Examples

Expand Down Expand Up @@ -151,6 +152,10 @@ Make sure to replace `TYPE` with one of the options listed below and `YOUR_PACKA
# Quality Score

[![Open on npmx.dev](https://npmx.dev/api/registry/badge/quality/pinia)](https://npmx.dev/package/pinia)

# Endpoint Badge

[![Stage](https://npmx.dev/api/registry/badge/endpoint/_?url=https://raw.githubusercontent.com/solidjs-community/solid-primitives/af34b836baba599c525d0db4b1c9871dd0b13f27/assets/badges/stage-2.json)](https://github.com/solidjs-community/solid-primitives)
```

#### Customization Parameters
Expand Down
15 changes: 15 additions & 0 deletions modules/runtime/server/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,21 @@ function getMockForUrl(url: string): MockResult | null {
return { data: { attestations: [] } }
}

// GitHub raw content - return mock endpoint badge JSON
if (host === 'raw.githubusercontent.com') {
const stageMatch = pathname.match(/stage-(\d+)\.json$/)
if (stageMatch) {
return {
data: {
schemaVersion: 1,
label: 'STAGE',
message: stageMatch[1],
color: '#E9DE47',
},
}
}
}

// Constellation API - return empty results for link queries
if (host === 'constellation.microcosm.blue') {
if (pathname === '/links/distinct-dids') {
Expand Down
155 changes: 99 additions & 56 deletions server/api/registry/badge/[type]/[...pkg].get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ const QUERY_SCHEMA = v.object({
label: v.optional(SafeStringSchema),
})

const EndpointResponseSchema = v.object({
schemaVersion: v.literal(1),
label: v.string(),
message: v.string(),
color: v.optional(v.string()),
labelColor: v.optional(v.string()),
})

const COLORS = {
blue: '#3b82f6',
green: '#22c55e',
Expand Down Expand Up @@ -248,6 +256,21 @@ async function fetchInstallSize(packageName: string, version: string): Promise<n
}
}

async function fetchEndpointBadge(url: string) {
const response = await fetch(url, { headers: { Accept: 'application/json' } })
if (!response.ok) {
throw createError({ statusCode: 502, message: `Endpoint returned ${response.status}` })
}
const data = await response.json()
const parsed = v.parse(EndpointResponseSchema, data)
Comment on lines +259 to +265
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add timeout and response-size guards for endpoint fetches.

The outbound call is currently unbounded. Slow or very large responses can tie up workers and increase memory pressure.

🧯 Proposed fix
 async function fetchEndpointBadge(url: string) {
-  const response = await fetch(url, { headers: { Accept: 'application/json' } })
+  const controller = new AbortController()
+  const timeout = setTimeout(() => controller.abort(), 5000)
+  const response = await fetch(url, {
+    headers: { Accept: 'application/json' },
+    signal: controller.signal,
+  })
+  clearTimeout(timeout)
+
   if (!response.ok) {
     throw createError({ statusCode: 502, message: `Endpoint returned ${response.status}` })
   }
+
+  const contentLength = Number(response.headers.get('content-length') ?? 0)
+  if (Number.isFinite(contentLength) && contentLength > 64_000) {
+    throw createError({ statusCode: 502, message: 'Endpoint response is too large.' })
+  }
+
   const data = await response.json()
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async function fetchEndpointBadge(url: string) {
const response = await fetch(url, { headers: { Accept: 'application/json' } })
if (!response.ok) {
throw createError({ statusCode: 502, message: `Endpoint returned ${response.status}` })
}
const data = await response.json()
const parsed = v.parse(EndpointResponseSchema, data)
async function fetchEndpointBadge(url: string) {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 5000)
const response = await fetch(url, {
headers: { Accept: 'application/json' },
signal: controller.signal,
})
clearTimeout(timeout)
if (!response.ok) {
throw createError({ statusCode: 502, message: `Endpoint returned ${response.status}` })
}
const contentLength = Number(response.headers.get('content-length') ?? 0)
if (Number.isFinite(contentLength) && contentLength > 64_000) {
throw createError({ statusCode: 502, message: 'Endpoint response is too large.' })
}
const data = await response.json()
const parsed = v.parse(EndpointResponseSchema, data)

return {
label: parsed.label,
value: parsed.message,
color: parsed.color,
labelColor: parsed.labelColor,
}
Comment on lines +259 to +271
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Escape and validate endpoint-derived values before SVG interpolation.

label, message, and colour values from external JSON flow directly into SVG attributes/text. A malicious endpoint can inject markup or break the SVG structure.

🛡️ Proposed fix
+function escapeXml(value: string): string {
+  return value
+    .replaceAll('&', '&amp;')
+    .replaceAll('<', '&lt;')
+    .replaceAll('>', '&gt;')
+    .replaceAll('"', '&quot;')
+    .replaceAll("'", '&apos;')
+}
+
+function normalizeBadgeColor(value: string, fallback: string): string {
+  const trimmed = value.trim()
+  const isSafeColour = /^#?[0-9a-fA-F]{3,8}$|^[a-zA-Z]+$/.test(trimmed)
+  if (!isSafeColour) return fallback
+  return trimmed.startsWith('#') ? trimmed : `#${trimmed}`
+}
+
 async function fetchEndpointBadge(url: string) {
   const response = await fetch(url, { headers: { Accept: 'application/json' } })
   if (!response.ok) {
     throw createError({ statusCode: 502, message: `Endpoint returned ${response.status}` })
   }
   const data = await response.json()
   const parsed = v.parse(EndpointResponseSchema, data)
   return {
-    label: parsed.label,
-    value: parsed.message,
+    label: escapeXml(parsed.label),
+    value: escapeXml(parsed.message),
     color: parsed.color,
     labelColor: parsed.labelColor,
   }
 }
@@
-    const finalLabel = userLabel ?? strategyResult.label
-    const finalValue = strategyResult.value
-    const rawColor = userColor ?? strategyResult.color ?? COLORS.slate
-    const finalColor = rawColor.startsWith('#') ? rawColor : `#${rawColor}`
+    const finalLabel = escapeXml(userLabel ?? strategyResult.label)
+    const finalValue = escapeXml(strategyResult.value)
+    const rawColor = userColor ?? strategyResult.color ?? COLORS.slate
+    const finalColor = normalizeBadgeColor(rawColor, COLORS.slate)
@@
-    const rawLabelColor = labelColor ?? strategyResult.labelColor ?? defaultLabelColor
-    const finalLabelColor = rawLabelColor.startsWith('#') ? rawLabelColor : `#${rawLabelColor}`
+    const rawLabelColor = labelColor ?? strategyResult.labelColor ?? defaultLabelColor
+    const finalLabelColor = normalizeBadgeColor(rawLabelColor, defaultLabelColor)

Also applies to: 474-483

}

const badgeStrategies = {
'version': async (pkgData: globalThis.Packument, requestedVersion?: string) => {
const version = requestedVersion ?? getLatestVersion(pkgData) ?? 'unknown'
Expand Down Expand Up @@ -388,65 +411,85 @@ export default defineCachedEventHandler(
async event => {
const query = getQuery(event)
const typeParam = getRouterParam(event, 'type')
const pkgParamSegments = getRouterParam(event, 'pkg')?.split('/') ?? []

if (pkgParamSegments.length === 0) {
// TODO: throwing 404 rather than 400 as it's cacheable
throw createError({ statusCode: 404, message: 'Package name is required.' })
const queryParams = v.safeParse(QUERY_SCHEMA, query)
const userColor = queryParams.success ? queryParams.output.color : undefined
const userLabel = queryParams.success ? queryParams.output.label : undefined
const labelColor = queryParams.success ? queryParams.output.labelColor : undefined
const badgeStyleResult = v.safeParse(BadgeStyleSchema, query.style)
const badgeStyle = badgeStyleResult.success ? badgeStyleResult.output : 'default'

let strategyResult: { label: string; value: string; color?: string; labelColor?: string }

if (typeParam === 'endpoint') {
const endpointUrl = typeof query.url === 'string' ? query.url : undefined
if (!endpointUrl || !endpointUrl.startsWith('https://')) {
throw createError({ statusCode: 400, message: 'Missing or invalid "url" query parameter.' })
Comment on lines +425 to +427
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Harden endpoint URL validation to prevent SSRF.

The current https:// prefix check is bypassable for internal targets (e.g. localhost aliases, private hosts, credentialed URLs). Parse and validate the URL structurally before fetch.

🛡️ Proposed fix
+function validateEndpointUrl(input: string): string {
+  let url: URL
+  try {
+    url = new URL(input)
+  } catch {
+    throw createError({ statusCode: 400, message: 'Invalid "url" query parameter.' })
+  }
+
+  if (url.protocol !== 'https:') {
+    throw createError({ statusCode: 400, message: 'Only HTTPS endpoint URLs are allowed.' })
+  }
+
+  if (url.username || url.password) {
+    throw createError({ statusCode: 400, message: 'Credentials are not allowed in endpoint URLs.' })
+  }
+
+  const blockedHosts = new Set(['localhost', '127.0.0.1', '::1'])
+  if (blockedHosts.has(url.hostname)) {
+    throw createError({ statusCode: 400, message: 'Local endpoint URLs are not allowed.' })
+  }
+
+  return url.toString()
+}
+
     if (typeParam === 'endpoint') {
       const endpointUrl = typeof query.url === 'string' ? query.url : undefined
-      if (!endpointUrl || !endpointUrl.startsWith('https://')) {
+      if (!endpointUrl) {
         throw createError({ statusCode: 400, message: 'Missing or invalid "url" query parameter.' })
       }
+      const validatedEndpointUrl = validateEndpointUrl(endpointUrl)
 
       try {
-        strategyResult = await fetchEndpointBadge(endpointUrl)
+        strategyResult = await fetchEndpointBadge(validatedEndpointUrl)
       } catch (error: unknown) {
         handleApiError(error, { statusCode: 502, message: 'Failed to fetch endpoint data.' })
       }

}

try {
strategyResult = await fetchEndpointBadge(endpointUrl)
} catch (error: unknown) {
handleApiError(error, { statusCode: 502, message: 'Failed to fetch endpoint data.' })
}
} else {
const pkgParamSegments = getRouterParam(event, 'pkg')?.split('/') ?? []

if (pkgParamSegments.length === 0) {
// TODO: throwing 404 rather than 400 as it's cacheable
throw createError({ statusCode: 404, message: 'Package name is required.' })
}

const { rawPackageName, rawVersion } = parsePackageParams(pkgParamSegments)

try {
const { packageName, version: requestedVersion } = v.parse(PackageRouteParamsSchema, {
packageName: rawPackageName,
version: rawVersion,
})

const showName = queryParams.success && queryParams.output.name === 'true'

const badgeTypeResult = v.safeParse(BadgeTypeSchema, typeParam)
const strategyKey = badgeTypeResult.success ? badgeTypeResult.output : 'version'
const strategy = badgeStrategies[strategyKey as keyof typeof badgeStrategies]

assertValidPackageName(packageName)

const pkgData = await fetchNpmPackage(packageName)
const result = await strategy(pkgData, requestedVersion)
strategyResult = {
label: showName ? packageName : result.label,
value: result.value,
color: result.color,
}
} catch (error: unknown) {
handleApiError(error, {
statusCode: 502,
message: ERROR_NPM_FETCH_FAILED,
})
}
}

const { rawPackageName, rawVersion } = parsePackageParams(pkgParamSegments)

try {
const { packageName, version: requestedVersion } = v.parse(PackageRouteParamsSchema, {
packageName: rawPackageName,
version: rawVersion,
})

const queryParams = v.safeParse(QUERY_SCHEMA, query)
const userColor = queryParams.success ? queryParams.output.color : undefined
const labelColor = queryParams.success ? queryParams.output.labelColor : undefined
const showName = queryParams.success && queryParams.output.name === 'true'
const userLabel = queryParams.success ? queryParams.output.label : undefined
const badgeStyleResult = v.safeParse(BadgeStyleSchema, query.style)
const badgeStyle = badgeStyleResult.success ? badgeStyleResult.output : 'default'

const badgeTypeResult = v.safeParse(BadgeTypeSchema, typeParam)
const strategyKey = badgeTypeResult.success ? badgeTypeResult.output : 'version'
const strategy = badgeStrategies[strategyKey as keyof typeof badgeStrategies]

assertValidPackageName(packageName)

const pkgData = await fetchNpmPackage(packageName)
const strategyResult = await strategy(pkgData, requestedVersion)

const finalLabel = userLabel ? userLabel : showName ? packageName : strategyResult.label
const finalValue = strategyResult.value

const rawColor = userColor ?? strategyResult.color
const finalColor = rawColor?.startsWith('#') ? rawColor : `#${rawColor}`

const defaultLabelColor = badgeStyle === 'shieldsio' ? '#555' : '#0a0a0a'
const rawLabelColor = labelColor ?? defaultLabelColor
const finalLabelColor = rawLabelColor.startsWith('#') ? rawLabelColor : `#${rawLabelColor}`

const renderFn = badgeStyle === 'shieldsio' ? renderShieldsBadgeSvg : renderDefaultBadgeSvg
const svg = renderFn({ finalColor, finalLabel, finalLabelColor, finalValue })

setHeader(event, 'Content-Type', 'image/svg+xml')
setHeader(
event,
'Cache-Control',
`public, max-age=${CACHE_MAX_AGE_ONE_HOUR}, s-maxage=${CACHE_MAX_AGE_ONE_HOUR}`,
)

return svg
} catch (error: unknown) {
handleApiError(error, {
statusCode: 502,
message: ERROR_NPM_FETCH_FAILED,
})
}
const finalLabel = userLabel ?? strategyResult.label
const finalValue = strategyResult.value
const rawColor = userColor ?? strategyResult.color ?? COLORS.slate
const finalColor = rawColor.startsWith('#') ? rawColor : `#${rawColor}`
const defaultLabelColor = badgeStyle === 'shieldsio' ? '#555' : '#0a0a0a'
const rawLabelColor = labelColor ?? strategyResult.labelColor ?? defaultLabelColor
const finalLabelColor = rawLabelColor.startsWith('#') ? rawLabelColor : `#${rawLabelColor}`

const renderFn = badgeStyle === 'shieldsio' ? renderShieldsBadgeSvg : renderDefaultBadgeSvg
const svg = renderFn({ finalColor, finalLabel, finalLabelColor, finalValue })

setHeader(event, 'Content-Type', 'image/svg+xml')
setHeader(
event,
'Cache-Control',
`public, max-age=${CACHE_MAX_AGE_ONE_HOUR}, s-maxage=${CACHE_MAX_AGE_ONE_HOUR}`,
)

return svg
},
{
maxAge: CACHE_MAX_AGE_ONE_HOUR,
Expand Down
19 changes: 19 additions & 0 deletions test/e2e/badge.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,4 +153,23 @@ test.describe('badge API', () => {

expect(response.status()).toBe(404)
})

test('endpoint badge renders from external JSON', async ({ page, baseURL }) => {
const endpointUrl = encodeURIComponent(
'https://raw.githubusercontent.com/solidjs-community/solid-primitives/main/assets/badges/stage-2.json',
)
const url = toLocalUrl(baseURL, `/api/registry/badge/endpoint/_?url=${endpointUrl}`)
const { body, response } = await fetchBadge(page, url)

expect(response.status()).toBe(200)
expect(body).toContain('STAGE')
expect(body).toContain('>2<')
})

test('endpoint badge without url returns 400', async ({ page, baseURL }) => {
const url = toLocalUrl(baseURL, '/api/registry/badge/endpoint/_')
const { response } = await fetchBadge(page, url)

expect(response.status()).toBe(400)
})
})
Loading