Escape single quotes in submodule Foreach shell commands

This commit is contained in:
Juwan-Hwang 2026-08-18 10:44:10 +08:00
commit 3a65af85aa
5 changed files with 143 additions and 6 deletions

View file

@ -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<any>
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<any>
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) {

View file

@ -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" \\ * ? < > |'
)
})
})