From 750698cf45aa6509f333429d491603a3cd011d87 Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Sat, 30 May 2026 04:20:05 -0700
Subject: [PATCH] Keep repo icon href probes inside repo
---
src/main/repo-icon-autodetect.test.ts | 19 ++++++++++++++++++
src/main/repo-icon-autodetect.ts | 28 ++++++++++++++++++++++++++-
2 files changed, 46 insertions(+), 1 deletion(-)
diff --git a/src/main/repo-icon-autodetect.test.ts b/src/main/repo-icon-autodetect.test.ts
index cfd5eb3f2c5..356fb48294c 100644
--- a/src/main/repo-icon-autodetect.test.ts
+++ b/src/main/repo-icon-autodetect.test.ts
@@ -69,6 +69,25 @@ describe('detectRepoIcon', () => {
})
})
+ it('does not resolve declared icon hrefs outside the repo', async () => {
+ const rootPath = await makeTempRepoDir()
+ const repoPath = join(rootPath, 'repo')
+ await mkdir(repoPath)
+ await writeFile(join(repoPath, 'index.html'), '')
+ await writeFile(join(rootPath, 'outside.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 })
diff --git a/src/main/repo-icon-autodetect.ts b/src/main/repo-icon-autodetect.ts
index 86ef07414d4..c5d6a1f50c9 100644
--- a/src/main/repo-icon-autodetect.ts
+++ b/src/main/repo-icon-autodetect.ts
@@ -75,9 +75,35 @@ 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()
+ if (
+ !normalized ||
+ normalized.startsWith('/') ||
+ normalized.includes('\0') ||
+ /^[A-Za-z][A-Za-z0-9+.-]*:/.test(normalized)
+ ) {
+ return null
+ }
+ const segments = normalized.split('/').filter((segment) => segment && segment !== '.')
+ if (segments.length === 0 || segments.some((segment) => segment === '..')) {
+ return null
+ }
+ return segments.join('/')
+}
+
function iconHrefCandidates(href: string): string[] {
const clean = href.replace(/^\/+/, '')
- return [`public/${clean}`, clean]
+ 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)
+ }
+ }
+ return candidates
}
async function readLocalPngIcon(repoPath: string, relativePath: string): Promise {