fix(ssh): budget directory traversal in Include globs

Adds MAX_INCLUDE_GLOB_TRAVERSAL limit to prevent pathological glob patterns from walking excessive directories. When a single Include glob walks more than the budget (1024 directories), stop expanding and mark the result as incomplete.
This commit is contained in:
Jinjing
2026-09-28 16:29:12 -07:00
parent 12119eaecd
commit 03a32aff84
2 changed files with 44 additions and 2 deletions
@@ -180,6 +180,19 @@ describe('SSH config Include completeness', () => {
expect(expandSshConfigIncludes(configPath).fullyExpanded).toBe(true)
})
it('stops a glob that walks too many directories and marks it incomplete', () => {
const home = makeTemporaryHome()
const configPath = writeFile(home, '.ssh/config', 'Include conf.d/*/config\n')
writeFile(home, '.ssh/conf.d/aaa/config', 'Host early\n HostName early.example.com\n')
for (let index = 0; index < 1100; index += 1) {
mkdirSync(join(home, '.ssh', 'conf.d', `dir-${index}`))
}
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
expect(expandSshConfigIncludes(configPath).fullyExpanded).toBe(false)
expect(warn).toHaveBeenCalledWith(expect.stringContaining('walks more than'))
})
})
describe('SSH config Include warnings', () => {
+31 -2
View File
@@ -43,6 +43,8 @@ type ResolvedIncludePaths = {
}
const MAX_INCLUDE_GLOB_MATCHES = 256
// Caps the directories a single Include glob may walk on the main process before we stop collecting.
const MAX_INCLUDE_GLOB_TRAVERSAL = 1024
const MAX_INCLUDE_FILE_BYTES = 1024 * 1024
export function expandSshConfigIncludes(configPath: string): SshConfigExpansion {
@@ -247,11 +249,19 @@ function resolveIncludePaths(
const absolutePattern = resolveIncludePatternPath(withTokens, context)
if (hasGlobPattern(absolutePattern)) {
try {
const matches = globSync(absolutePattern).sort((left, right) => left.localeCompare(right))
if (matches.length > MAX_INCLUDE_GLOB_MATCHES) {
const { matches, traversalLimited } = globWithTraversalLimit(absolutePattern)
matches.sort((left, right) => left.localeCompare(right))
if (traversalLimited) {
console.warn(
`[ssh] Include pattern "${logTarget(absolutePattern)}" walks more than ${MAX_INCLUDE_GLOB_TRAVERSAL} directories; processing matches found so far`
)
} else if (matches.length > MAX_INCLUDE_GLOB_MATCHES) {
console.warn(
`[ssh] Include pattern "${logTarget(absolutePattern)}" matched ${matches.length} files; processing first ${MAX_INCLUDE_GLOB_MATCHES}`
)
}
if (traversalLimited || matches.length > MAX_INCLUDE_GLOB_MATCHES) {
// Already incomplete, so a completeness proof would only add another scan.
context.fullyExpanded = false
return {
paths: matches.slice(0, MAX_INCLUDE_GLOB_MATCHES),
@@ -291,6 +301,25 @@ function resolveIncludePaths(
}
}
/** Stops descending once the walk exceeds its budget, keeping the matches already found. */
function globWithTraversalLimit(pattern: string): {
matches: string[]
traversalLimited: boolean
} {
let remaining = MAX_INCLUDE_GLOB_TRAVERSAL
let traversalLimited = false
const matches = globSync(pattern, {
exclude: () => {
remaining -= 1
if (remaining < 0) {
traversalLimited = true
}
return traversalLimited
}
})
return { matches, traversalLimited }
}
function getCanonicalPath(
filePath: string,
context: IncludeExpansionContext,