feat: add endpoint badge type for external json data#1796
feat: add endpoint badge type for external json data#1796Moshyfawn wants to merge 3 commits intonpmx-dev:mainfrom
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
1 Skipped Deployment
|
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
|
This PR was sparked by https://bsky.app/profile/davedbase.com/post/3mfz63ldthc2p so both the E2E tests and the docs include the Solid Primitives assets as examples. Let me know if you want npmx specific urls instead we could come up with. |
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review infoConfiguration used: Organization UI Review profile: CHILL Plan: Pro 📒 Files selected for processing (1)
🚧 Files skipped from review as they are similar to previous changes (1)
📝 WalkthroughWalkthroughAdds an "Endpoint Badge" feature: documentation and examples; runtime cache support for GitHub raw stage JSON returning mock stage badges; server API changes to handle badge type Possibly related PRs
Suggested reviewers
🚥 Pre-merge checks | ✅ 1✅ Passed checks (1 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
ℹ️ Review info
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (4)
docs/content/2.guide/1.features.mdmodules/runtime/server/cache.tsserver/api/registry/badge/[type]/[...pkg].get.tstest/e2e/badge.spec.ts
| 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, | ||
| color: parsed.color, | ||
| labelColor: parsed.labelColor, | ||
| } |
There was a problem hiding this comment.
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('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"')
+ .replaceAll("'", ''')
+}
+
+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
| 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) |
There was a problem hiding this comment.
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.
| 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) |
| 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.' }) |
There was a problem hiding this comment.
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.' })
}
🧭 Context
Some projects like Solid Primitives use custom metadata hosted as JSON files on GitHub to display project-specific badges.
📚 Description
Adds an
endpointbadge type that fetches and renders badge data from an external shields.io compatible JSON endpoint.URL format:
/api/registry/badge/endpoint/_?url=https://...