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:
parent
a8c5fd95a8
commit
f8c3354c75
|
|
@ -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`,
|
||||
],
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user