From 03a32aff84d650fca07e37346346f03f1cc95a87 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:29:12 -0700 Subject: [PATCH] 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. --- .../ssh-config-include-completeness.test.ts | 13 ++++++++ src/main/ssh/ssh-config-include-expander.ts | 33 +++++++++++++++++-- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/main/ssh/ssh-config-include-completeness.test.ts b/src/main/ssh/ssh-config-include-completeness.test.ts index 1ab81465a1d..b5983cbb4e0 100644 --- a/src/main/ssh/ssh-config-include-completeness.test.ts +++ b/src/main/ssh/ssh-config-include-completeness.test.ts @@ -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', () => { diff --git a/src/main/ssh/ssh-config-include-expander.ts b/src/main/ssh/ssh-config-include-expander.ts index ba58f2a76a2..19c42c15f3e 100644 --- a/src/main/ssh/ssh-config-include-expander.ts +++ b/src/main/ssh/ssh-config-include-expander.ts @@ -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,