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 type { LocalJSXCommandOnDone } from '../../types/command.js'
|
||||||
import { getAnthropicApiKey, isAnthropicAuthEnabled } from '../../utils/auth.js'
|
import { getAnthropicApiKey, isAnthropicAuthEnabled } from '../../utils/auth.js'
|
||||||
import { openBrowser } from '../../utils/browser.js'
|
import { openBrowser } from '../../utils/browser.js'
|
||||||
|
import { OFFICIAL_GITHUB_ORG } from '../../utils/plugins/schemas.js'
|
||||||
import { execFileNoThrow } from '../../utils/execFileNoThrow.js'
|
import { execFileNoThrow } from '../../utils/execFileNoThrow.js'
|
||||||
import { getGithubRepo } from '../../utils/git.js'
|
import { getGithubRepo } from '../../utils/git.js'
|
||||||
import { plural } from '../../utils/stringUtils.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 type { State, Warning, Workflow } from './types.js'
|
||||||
import { WarningsStep } from './WarningsStep.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 = {
|
const INITIAL_STATE: State = {
|
||||||
step: 'check-gh',
|
step: 'check-gh',
|
||||||
selectedRepoName: '',
|
selectedRepoName: '',
|
||||||
|
|
@ -363,19 +419,19 @@ function InstallGitHubApp(props: {
|
||||||
|
|
||||||
const repoWarnings: Warning[] = []
|
const repoWarnings: Warning[] = []
|
||||||
|
|
||||||
if (repoName.includes('github.com')) {
|
const parsedGitHubRepoUrl = parseGitHubRepoUrl(repoName)
|
||||||
const match = repoName.match(/github\.com[:/]([^/]+\/[^/]+)(\.git)?$/)
|
if (parsedGitHubRepoUrl) {
|
||||||
if (!match) {
|
if (!parsedGitHubRepoUrl.ok) {
|
||||||
repoWarnings.push({
|
repoWarnings.push({
|
||||||
title: 'Invalid GitHub URL format',
|
title: 'Invalid GitHub URL format',
|
||||||
message: 'The repository URL format appears to be invalid.',
|
message: 'The repository URL format appears to be invalid.',
|
||||||
instructions: [
|
instructions: [
|
||||||
'Use format: owner/repo or https://github.com/owner/repo',
|
'Use format: owner/repo or https://github.com/owner/repo',
|
||||||
'Example: anthropics/claude-cli',
|
`Example: ${OFFICIAL_GITHUB_ORG}/claude-cli`,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
repoName = match[1]?.replace(/\.git$/, '') || ''
|
repoName = parsedGitHubRepoUrl.repoName
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -385,7 +441,7 @@ function InstallGitHubApp(props: {
|
||||||
message: 'Repository should be in format "owner/repo"',
|
message: 'Repository should be in format "owner/repo"',
|
||||||
instructions: [
|
instructions: [
|
||||||
'Use format: owner/repo',
|
'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'
|
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.
|
* 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
|
// Check for git URL source type
|
||||||
if (source.source === 'git' && source.url) {
|
if (source.source === 'git' && source.url) {
|
||||||
const url = source.url.toLowerCase()
|
if (isOfficialGitHubOrgUrl(source.url)) {
|
||||||
// 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) {
|
|
||||||
return null // Valid: reserved name from official git URL
|
return null // Valid: reserved name from official git URL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user