Tighten repo icon href filtering

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-05-30 04:47:24 -07:00
co-authored by Orca
parent 750698cf45
commit 38066a2195
2 changed files with 38 additions and 13 deletions
+24
View File
@@ -88,6 +88,30 @@ describe('detectRepoIcon', () => {
})
})
it('does not reinterpret URL-like icon hrefs as repo-local paths', async () => {
const repoPath = await makeTempRepoDir()
await mkdir(join(repoPath, 'public', 'https:', 'cdn.example.com'), { recursive: true })
await writeFile(
join(repoPath, 'index.html'),
'<link rel="icon" href="https://cdn.example.com/icon.png">'
)
await writeFile(
join(repoPath, 'public', 'https:', 'cdn.example.com', 'icon.png'),
Buffer.from(PNG_1X1_BASE64, 'base64')
)
await writeFile(
join(repoPath, 'package.json'),
JSON.stringify({ homepage: 'https://app.example.com/docs' })
)
await expect(detectRepoIcon({ repoPath, kind: 'folder' })).resolves.toEqual({
type: 'image',
src: 'https://www.google.com/s2/favicons?domain=app.example.com&sz=64',
source: 'favicon',
label: 'Website favicon'
})
})
it('falls back to the GitHub owner avatar for GitHub repos', async () => {
const repoPath = await makeTempRepoDir()
await gitExecFileAsync(['init'], { cwd: repoPath })
+14 -13
View File
@@ -75,17 +75,18 @@ function extractIconHref(source: string): string | null {
return source.match(LINK_ICON_HTML_RE)?.[1] ?? source.match(LINK_ICON_OBJECT_RE)?.[1] ?? null
}
function normalizeIconRelativePath(relativePath: string): string | null {
const normalized = relativePath.replace(/\\/g, '/').trim()
function normalizeIconHrefPath(href: string): string | null {
const normalized = href.replace(/\\/g, '/').trim()
if (
!normalized ||
normalized.startsWith('/') ||
normalized.includes('\0') ||
normalized.startsWith('//') ||
/^[A-Za-z][A-Za-z0-9+.-]*:/.test(normalized)
) {
return null
}
const segments = normalized.split('/').filter((segment) => segment && segment !== '.')
const rootRelativePath = normalized.replace(/^\/+/, '')
const segments = rootRelativePath.split('/').filter((segment) => segment && segment !== '.')
if (segments.length === 0 || segments.some((segment) => segment === '..')) {
return null
}
@@ -93,15 +94,15 @@ function normalizeIconRelativePath(relativePath: string): string | null {
}
function iconHrefCandidates(href: string): string[] {
const clean = href.replace(/^\/+/, '')
const candidates: string[] = []
for (const candidate of [`public/${clean}`, clean]) {
// Why: icon hrefs are repo-controlled input; never let detection probe above
// the selected repo root while looking for a best-effort visual.
const normalized = normalizeIconRelativePath(candidate)
if (normalized && !candidates.includes(normalized)) {
candidates.push(normalized)
}
// Why: icon hrefs are repo-controlled input; reject unsafe shapes before
// adding repo-local lookup prefixes so URL-like hrefs cannot be reinterpreted.
const clean = normalizeIconHrefPath(href)
if (!clean) {
return []
}
const candidates = [`public/${clean}`, clean]
if (candidates[0] === candidates[1]) {
return [candidates[0]]
}
return candidates
}