Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
152 changes: 96 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,18 @@ async function fetchInstallSize(packageName: string, version: string): Promise<n
}
}

async function fetchEndpointBadge(url: string) {
const response = await fetch(url, { headers: { Accept: 'application/json' } })
const data = await response.json()
const parsed = v.parse(EndpointResponseSchema, data)
Comment on lines +271 to +277
Copy link
Copy Markdown
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 thread
coderabbitai[bot] marked this conversation as resolved.
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const badgeStrategies = {
'version': async (pkgData: globalThis.Packument, requestedVersion?: string) => {
const version = requestedVersion ?? getLatestVersion(pkgData) ?? 'unknown'
Expand Down Expand Up @@ -388,65 +408,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 +437 to +439
Copy link
Copy Markdown
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.' })
}
Comment thread
Moshyfawn marked this conversation as resolved.
} 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