mirror of
https://code.forgejo.org/actions/checkout.git
synced 2026-08-27 20:44:58 -04:00
Escape single quotes in submodule Foreach shell commands
This commit is contained in:
parent
f548e57e54
commit
3a65af85aa
5 changed files with 143 additions and 6 deletions
|
|
@ -67,6 +67,7 @@ describe('git-auth-helper tests', () => {
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks()
|
jest.clearAllMocks()
|
||||||
|
githubServerUrl = ''
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
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'
|
const removeAuth_removesSshCommand = 'removeAuth removes SSH command'
|
||||||
it(removeAuth_removesSshCommand, async () => {
|
it(removeAuth_removesSshCommand, async () => {
|
||||||
if (!sshPath) {
|
if (!sshPath) {
|
||||||
|
|
|
||||||
43
__test__/shell-escape.test.ts
Normal file
43
__test__/shell-escape.test.ts
Normal 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" \\ * ? < > |'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
27
dist/index.js
vendored
27
dist/index.js
vendored
|
|
@ -35021,6 +35021,26 @@ function hasContent(text, whitespaceMode) {
|
||||||
return refinedText.length > 0;
|
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
|
;// 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 git_auth_helper_IS_WINDOWS = process.platform === 'win32';
|
||||||
const SSH_COMMAND_KEY = 'core.sshCommand';
|
const SSH_COMMAND_KEY = 'core.sshCommand';
|
||||||
function createAuthHelper(git, settings) {
|
function createAuthHelper(git, settings) {
|
||||||
|
|
@ -35167,12 +35188,12 @@ class GitAuthHelper {
|
||||||
}
|
}
|
||||||
if (this.settings.sshKey) {
|
if (this.settings.sshKey) {
|
||||||
// Configure core.sshCommand
|
// 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 {
|
else {
|
||||||
// Configure HTTPS instead of SSH
|
// Configure HTTPS instead of SSH
|
||||||
for (const insteadOfValue of this.insteadOfValues) {
|
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);
|
const pattern = regexp_helper_escape(configKey);
|
||||||
await this.git.submoduleForeach(
|
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.
|
// 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.
|
* Removes includeIf entries that point to git-credentials-*.config files.
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import * as urlHelper from './url-helper.js'
|
||||||
import {randomUUID} from 'crypto'
|
import {randomUUID} from 'crypto'
|
||||||
import {IGitCommandManager} from './git-command-manager.js'
|
import {IGitCommandManager} from './git-command-manager.js'
|
||||||
import {IGitSourceSettings} from './git-source-settings.js'
|
import {IGitSourceSettings} from './git-source-settings.js'
|
||||||
|
import {escapeSingleQuote} from './shell-escape.js'
|
||||||
|
|
||||||
const IS_WINDOWS = process.platform === 'win32'
|
const IS_WINDOWS = process.platform === 'win32'
|
||||||
const SSH_COMMAND_KEY = 'core.sshCommand'
|
const SSH_COMMAND_KEY = 'core.sshCommand'
|
||||||
|
|
@ -215,14 +216,14 @@ class GitAuthHelper {
|
||||||
if (this.settings.sshKey) {
|
if (this.settings.sshKey) {
|
||||||
// Configure core.sshCommand
|
// Configure core.sshCommand
|
||||||
await this.git.submoduleForeach(
|
await this.git.submoduleForeach(
|
||||||
`git config --local '${SSH_COMMAND_KEY}' '${this.sshCommand}'`,
|
`git config --local '${SSH_COMMAND_KEY}' '${escapeSingleQuote(this.sshCommand)}'`,
|
||||||
this.settings.nestedSubmodules
|
this.settings.nestedSubmodules
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// Configure HTTPS instead of SSH
|
// Configure HTTPS instead of SSH
|
||||||
for (const insteadOfValue of this.insteadOfValues) {
|
for (const insteadOfValue of this.insteadOfValues) {
|
||||||
await this.git.submoduleForeach(
|
await this.git.submoduleForeach(
|
||||||
`git config --local --add '${this.insteadOfKey}' '${insteadOfValue}'`,
|
`git config --local --add '${escapeSingleQuote(this.insteadOfKey)}' '${escapeSingleQuote(insteadOfValue)}'`,
|
||||||
this.settings.nestedSubmodules
|
this.settings.nestedSubmodules
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -536,7 +537,7 @@ class GitAuthHelper {
|
||||||
const pattern = regexpHelper.escape(configKey)
|
const pattern = regexpHelper.escape(configKey)
|
||||||
await this.git.submoduleForeach(
|
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.
|
// 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
|
true
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
18
src/shell-escape.ts
Normal file
18
src/shell-escape.ts
Normal file
|
|
@ -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, "'\\''")
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue