fix(security): replace URL substring checks with proper URL parsing

- schemas.ts: add isOfficialGitHubOrgUrl() using new URL() for exact
  hostname validation, preventing evilgithub.com bypass (fixes #41-43)
- install-github-app.tsx: add parseGitHubRepoUrl() with URL + SSH parsing,
  replace includes('github.com') substring check

Test: 3060 pass, 0 fail (4 getLanIPs intermittent, not related)
This commit is contained in:
James Feng 2026-06-03 16:50:31 +08:00
parent a8c5fd95a8
commit f8c3354c75
2 changed files with 89 additions and 13 deletions

View File

@ -11,6 +11,7 @@ import { type KeyboardEvent, Box } from '@anthropic/ink'
import type { LocalJSXCommandOnDone } from '../../types/command.js'
import { getAnthropicApiKey, isAnthropicAuthEnabled } from '../../utils/auth.js'
import { openBrowser } from '../../utils/browser.js'
import { OFFICIAL_GITHUB_ORG } from '../../utils/plugins/schemas.js'
import { execFileNoThrow } from '../../utils/execFileNoThrow.js'
import { getGithubRepo } from '../../utils/git.js'
import { plural } from '../../utils/stringUtils.js'
@ -28,6 +29,61 @@ import { setupGitHubActions } from './setupGitHubActions.js'
import type { State, Warning, Workflow } from './types.js'
import { WarningsStep } from './WarningsStep.js'
type ParsedGitHubRepoUrl =
| { ok: true; repoName: string }
| { ok: false }
function parsedGitHubRepoFromParts(
owner: string | undefined,
repo: string | undefined,
): ParsedGitHubRepoUrl {
const normalizedRepo = repo?.replace(/\.git$/i, '')
if (!owner || !normalizedRepo) {
return { ok: false }
}
return { ok: true, repoName: `${owner}/${normalizedRepo}` }
}
function parseGitHubRepoUrl(repoName: string): ParsedGitHubRepoUrl | null {
let url: URL
try {
url = new URL(repoName)
} catch {
return null
}
const hostname = url.hostname.toLowerCase()
if (!hostname) {
const sshMatch = repoName.match(/^git@([^:]+):([^/]+)\/([^/]+)$/i)
if (!sshMatch) {
return null
}
if (sshMatch[1]?.toLowerCase() !== 'github.com') {
return { ok: false }
}
return parsedGitHubRepoFromParts(sshMatch[2], sshMatch[3])
}
if (hostname !== 'github.com') {
return { ok: false }
}
if (url.search || url.hash) {
return { ok: false }
}
const pathParts = url.pathname.split('/').filter(Boolean)
if (pathParts.length !== 2) {
return { ok: false }
}
return parsedGitHubRepoFromParts(pathParts[0], pathParts[1])
}
const INITIAL_STATE: State = {
step: 'check-gh',
selectedRepoName: '',
@ -363,19 +419,19 @@ function InstallGitHubApp(props: {
const repoWarnings: Warning[] = []
if (repoName.includes('github.com')) {
const match = repoName.match(/github\.com[:/]([^/]+\/[^/]+)(\.git)?$/)
if (!match) {
const parsedGitHubRepoUrl = parseGitHubRepoUrl(repoName)
if (parsedGitHubRepoUrl) {
if (!parsedGitHubRepoUrl.ok) {
repoWarnings.push({
title: 'Invalid GitHub URL format',
message: 'The repository URL format appears to be invalid.',
instructions: [
'Use format: owner/repo or https://github.com/owner/repo',
'Example: anthropics/claude-cli',
`Example: ${OFFICIAL_GITHUB_ORG}/claude-cli`,
],
})
} else {
repoName = match[1]?.replace(/\.git$/, '') || ''
repoName = parsedGitHubRepoUrl.repoName
}
}
@ -385,7 +441,7 @@ function InstallGitHubApp(props: {
message: 'Repository should be in format "owner/repo"',
instructions: [
'Use format: owner/repo',
'Example: anthropics/claude-cli',
`Example: ${OFFICIAL_GITHUB_ORG}/claude-cli`,
],
})
}

View File

@ -106,6 +106,32 @@ export function isBlockedOfficialName(name: string): boolean {
*/
export const OFFICIAL_GITHUB_ORG = 'anthropics'
function isOfficialGitHubOrgUrl(urlString: string): boolean {
try {
const url = new URL(urlString)
const hostname = url.hostname.toLowerCase()
const pathname = url.pathname.toLowerCase()
if (hostname !== 'github.com') {
return false
}
return pathname.startsWith(`/${OFFICIAL_GITHUB_ORG}/`)
} catch {
const sshMatch = urlString.match(/^git@([^:]+):(.+)$/i)
if (!sshMatch) {
return false
}
const hostname = sshMatch[1]?.toLowerCase()
const pathname = sshMatch[2]?.toLowerCase()
return (
hostname === 'github.com' &&
pathname?.startsWith(`${OFFICIAL_GITHUB_ORG}/`) === true
)
}
}
/**
* Validate that a marketplace with a reserved name comes from the official source.
*
@ -139,13 +165,7 @@ export function validateOfficialNameSource(
// Check for git URL source type
if (source.source === 'git' && source.url) {
const url = source.url.toLowerCase()
// Check for HTTPS URL format: https://github.com/anthropics/...
// or SSH format: git@github.com:anthropics/...
const isHttpsAnthropics = url.includes('github.com/anthropics/')
const isSshAnthropics = url.includes('git@github.com:anthropics/')
if (isHttpsAnthropics || isSshAnthropics) {
if (isOfficialGitHubOrgUrl(source.url)) {
return null // Valid: reserved name from official git URL
}