From 38066a2195c8cb4d630db9016b7fdce88cb9dfbb Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Sat, 30 May 2026 04:47:24 -0700 Subject: [PATCH] Tighten repo icon href filtering Co-authored-by: Orca --- src/main/repo-icon-autodetect.test.ts | 24 ++++++++++++++++++++++++ src/main/repo-icon-autodetect.ts | 27 ++++++++++++++------------- 2 files changed, 38 insertions(+), 13 deletions(-) diff --git a/src/main/repo-icon-autodetect.test.ts b/src/main/repo-icon-autodetect.test.ts index 356fb48294c..5003f08d94b 100644 --- a/src/main/repo-icon-autodetect.test.ts +++ b/src/main/repo-icon-autodetect.test.ts @@ -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'), + '' + ) + 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 }) diff --git a/src/main/repo-icon-autodetect.ts b/src/main/repo-icon-autodetect.ts index c5d6a1f50c9..cc389e828b1 100644 --- a/src/main/repo-icon-autodetect.ts +++ b/src/main/repo-icon-autodetect.ts @@ -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 }