From 3a65af85aa2d6b5cbb71d84809b4a7de16b599a4 Mon Sep 17 00:00:00 2001 From: Juwan-Hwang Date: Tue, 18 Aug 2026 10:44:10 +0800 Subject: [PATCH] Escape single quotes in submodule Foreach shell commands --- __test__/git-auth-helper.test.ts | 54 ++++++++++++++++++++++++++++++++ __test__/shell-escape.test.ts | 43 +++++++++++++++++++++++++ dist/index.js | 27 ++++++++++++++-- src/git-auth-helper.ts | 7 +++-- src/shell-escape.ts | 18 +++++++++++ 5 files changed, 143 insertions(+), 6 deletions(-) create mode 100644 __test__/shell-escape.test.ts create mode 100644 src/shell-escape.ts diff --git a/__test__/git-auth-helper.test.ts b/__test__/git-auth-helper.test.ts index 2c963b9..5276435 100644 --- a/__test__/git-auth-helper.test.ts +++ b/__test__/git-auth-helper.test.ts @@ -67,6 +67,7 @@ describe('git-auth-helper tests', () => { beforeEach(() => { jest.clearAllMocks() + githubServerUrl = '' }) afterEach(() => { @@ -664,6 +665,59 @@ describe('git-auth-helper tests', () => { } ) + const configureSubmoduleAuth_escapesSingleQuotesInServerUrl = + 'configureSubmoduleAuth escapes single quotes in serverUrl to prevent command injection' + it(configureSubmoduleAuth_escapesSingleQuotesInServerUrl, async () => { + // Arrange: URL containing percent-encoded single quote (%27) which URL decoding expands to literal single quote + githubServerUrl = 'https://evil%27$(id)host.com' + await setup(configureSubmoduleAuth_escapesSingleQuotesInServerUrl) + settings.githubServerUrl = githubServerUrl + settings.persistCredentials = true + settings.sshKey = '' + settings.workflowOrganizationId = undefined + const authHelper = gitAuthHelper.createAuthHelper(git, settings) + await authHelper.configureAuth() + const mockSubmoduleForeach = git.submoduleForeach as jest.Mock + mockSubmoduleForeach.mockClear() + + // Act + await authHelper.configureSubmoduleAuth() + + // Assert + // Call 0: removeSubmoduleGitConfig for insteadOfKey + // Call 1: insteadOf configuration + expect(mockSubmoduleForeach).toHaveBeenCalledTimes(2) + const unsetCommand = mockSubmoduleForeach.mock.calls[0][0] as string + expect(unsetCommand).toContain("'\\''") + + const addCommand = mockSubmoduleForeach.mock.calls[1][0] as string + expect(addCommand).toBe( + "git config --local --add 'url.https://evil'\\''$(id)host.com/.insteadOf' 'git@evil'\\''$(id)host.com:'" + ) + }) + + const removeAuth_escapesSingleQuotesInConfigKey = + 'removeAuth removes token from submodules escaping single quotes' + it(removeAuth_escapesSingleQuotesInConfigKey, async () => { + // Arrange + githubServerUrl = 'https://evil%27$(id)host.com' + await setup(removeAuth_escapesSingleQuotesInConfigKey) + settings.githubServerUrl = githubServerUrl + const authHelper = gitAuthHelper.createAuthHelper(git, settings) + await authHelper.configureAuth() + const mockSubmoduleForeach = git.submoduleForeach as jest.Mock + mockSubmoduleForeach.mockClear() + + // Act + await authHelper.removeAuth() + + // Assert: removeSubmoduleGitConfig should have been called for SSH_COMMAND_KEY and tokenConfigKey + const calls = mockSubmoduleForeach.mock.calls.map(c => c[0] as string) + const tokenCleanupCall = calls.find(c => c.includes('extraheader')) + expect(tokenCleanupCall).toBeDefined() + expect(tokenCleanupCall).toContain("'\\''") + }) + const removeAuth_removesSshCommand = 'removeAuth removes SSH command' it(removeAuth_removesSshCommand, async () => { if (!sshPath) { diff --git a/__test__/shell-escape.test.ts b/__test__/shell-escape.test.ts new file mode 100644 index 0000000..da1e517 --- /dev/null +++ b/__test__/shell-escape.test.ts @@ -0,0 +1,43 @@ +import {describe, it, expect} from '@jest/globals' +import {escapeSingleQuote} from '../src/shell-escape.js' + +describe('shell-escape tests', () => { + it('handles empty string', () => { + expect(escapeSingleQuote('')).toBe('') + }) + + it('leaves strings without single quotes unchanged', () => { + expect(escapeSingleQuote('https://github.com')).toBe('https://github.com') + expect(escapeSingleQuote('git@github.com:')).toBe('git@github.com:') + expect(escapeSingleQuote('core.sshCommand')).toBe('core.sshCommand') + expect(escapeSingleQuote('http.https://github.com/.extraheader')).toBe( + 'http.https://github.com/.extraheader' + ) + }) + + it('escapes single quotes with POSIX close-escape-reopen sequence', () => { + expect(escapeSingleQuote("it's")).toBe("it'\\''s") + expect(escapeSingleQuote("'foo'")).toBe("'\\''foo'\\''") + expect(escapeSingleQuote("a'b'c")).toBe("a'\\''b'\\''c") + }) + + it('escapes single quotes in URLs with decoded percent-encoded characters', () => { + // new URL('https://evil%27$(id)host.com') results in hostname "evil'$(id)host.com" + const decodedUrlKey = "http.https://evil'$(id)host.com/.extraheader" + expect(escapeSingleQuote(decodedUrlKey)).toBe( + "http.https://evil'\\''$(id)host.com/.extraheader" + ) + + const decodedInsteadOfValue = "git@evil'$(id)host.com:" + expect(escapeSingleQuote(decodedInsteadOfValue)).toBe( + "git@evil'\\''$(id)host.com:" + ) + }) + + it('preserves other shell special characters literally inside single quotes', () => { + const specialChars = '`$(id) && rm -rf /; echo "hello" \\ * ? < > |' + expect(escapeSingleQuote(specialChars)).toBe( + '`$(id) && rm -rf /; echo "hello" \\ * ? < > |' + ) + }) +}) diff --git a/dist/index.js b/dist/index.js index 06ae5d2..29f951c 100644 --- a/dist/index.js +++ b/dist/index.js @@ -35021,6 +35021,26 @@ function hasContent(text, whitespaceMode) { return refinedText.length > 0; } +;// CONCATENATED MODULE: ./src/shell-escape.ts +/** + * Escapes a value for safe use inside a single-quoted shell string. + * + * In POSIX shells, single-quoted strings treat every character literally + * except for the single quote itself (there is no escape sequence inside + * single quotes). The standard technique is to: + * 1. Close the current single-quoted segment: ' + * 2. Add an escaped single quote: \' + * 3. Re-open a new single-quoted segment: ' + * + * Example: "it's" → "it'\''s" + * + * This prevents shell injection when interpolating values into commands + * executed via `sh -c` (e.g. `git submodule foreach`). + */ +function escapeSingleQuote(value) { + return value.replace(/'/g, "'\\''"); +} + ;// CONCATENATED MODULE: ./src/git-auth-helper.ts @@ -35033,6 +35053,7 @@ function hasContent(text, whitespaceMode) { + const git_auth_helper_IS_WINDOWS = process.platform === 'win32'; const SSH_COMMAND_KEY = 'core.sshCommand'; function createAuthHelper(git, settings) { @@ -35167,12 +35188,12 @@ class GitAuthHelper { } if (this.settings.sshKey) { // Configure core.sshCommand - await this.git.submoduleForeach(`git config --local '${SSH_COMMAND_KEY}' '${this.sshCommand}'`, this.settings.nestedSubmodules); + await this.git.submoduleForeach(`git config --local '${SSH_COMMAND_KEY}' '${escapeSingleQuote(this.sshCommand)}'`, this.settings.nestedSubmodules); } else { // Configure HTTPS instead of SSH for (const insteadOfValue of this.insteadOfValues) { - await this.git.submoduleForeach(`git config --local --add '${this.insteadOfKey}' '${insteadOfValue}'`, this.settings.nestedSubmodules); + await this.git.submoduleForeach(`git config --local --add '${escapeSingleQuote(this.insteadOfKey)}' '${escapeSingleQuote(insteadOfValue)}'`, this.settings.nestedSubmodules); } } } @@ -35414,7 +35435,7 @@ class GitAuthHelper { const pattern = regexp_helper_escape(configKey); await this.git.submoduleForeach( // Wrap the pipeline in quotes to make sure it's handled properly by submoduleForeach, rather than just the first part of the pipeline. - `sh -c "git config --local --name-only --get-regexp '${pattern}' && git config --local --unset-all '${configKey}' || :"`, true); + `sh -c "git config --local --name-only --get-regexp '${escapeSingleQuote(pattern)}' && git config --local --unset-all '${escapeSingleQuote(configKey)}' || :"`, true); } /** * Removes includeIf entries that point to git-credentials-*.config files. diff --git a/src/git-auth-helper.ts b/src/git-auth-helper.ts index dd7e6fb..0e18b51 100644 --- a/src/git-auth-helper.ts +++ b/src/git-auth-helper.ts @@ -11,6 +11,7 @@ import * as urlHelper from './url-helper.js' import {randomUUID} from 'crypto' import {IGitCommandManager} from './git-command-manager.js' import {IGitSourceSettings} from './git-source-settings.js' +import {escapeSingleQuote} from './shell-escape.js' const IS_WINDOWS = process.platform === 'win32' const SSH_COMMAND_KEY = 'core.sshCommand' @@ -215,14 +216,14 @@ class GitAuthHelper { if (this.settings.sshKey) { // Configure core.sshCommand await this.git.submoduleForeach( - `git config --local '${SSH_COMMAND_KEY}' '${this.sshCommand}'`, + `git config --local '${SSH_COMMAND_KEY}' '${escapeSingleQuote(this.sshCommand)}'`, this.settings.nestedSubmodules ) } else { // Configure HTTPS instead of SSH for (const insteadOfValue of this.insteadOfValues) { await this.git.submoduleForeach( - `git config --local --add '${this.insteadOfKey}' '${insteadOfValue}'`, + `git config --local --add '${escapeSingleQuote(this.insteadOfKey)}' '${escapeSingleQuote(insteadOfValue)}'`, this.settings.nestedSubmodules ) } @@ -536,7 +537,7 @@ class GitAuthHelper { const pattern = regexpHelper.escape(configKey) await this.git.submoduleForeach( // Wrap the pipeline in quotes to make sure it's handled properly by submoduleForeach, rather than just the first part of the pipeline. - `sh -c "git config --local --name-only --get-regexp '${pattern}' && git config --local --unset-all '${configKey}' || :"`, + `sh -c "git config --local --name-only --get-regexp '${escapeSingleQuote(pattern)}' && git config --local --unset-all '${escapeSingleQuote(configKey)}' || :"`, true ) } diff --git a/src/shell-escape.ts b/src/shell-escape.ts new file mode 100644 index 0000000..f67b42a --- /dev/null +++ b/src/shell-escape.ts @@ -0,0 +1,18 @@ +/** + * Escapes a value for safe use inside a single-quoted shell string. + * + * In POSIX shells, single-quoted strings treat every character literally + * except for the single quote itself (there is no escape sequence inside + * single quotes). The standard technique is to: + * 1. Close the current single-quoted segment: ' + * 2. Add an escaped single quote: \' + * 3. Re-open a new single-quoted segment: ' + * + * Example: "it's" → "it'\''s" + * + * This prevents shell injection when interpolating values into commands + * executed via `sh -c` (e.g. `git submodule foreach`). + */ +export function escapeSingleQuote(value: string): string { + return value.replace(/'/g, "'\\''") +}