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 {