Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ Add a step like this to your workflow:
# Default: false
dry_run: true

# If true, allow custom git transports / scheme:: remote-helper URLs in argument inputs.
# Keep false unless you need a custom remote helper and fully trust those inputs.
# Default: false
allow_unsafe_git_protocols: false

# Arguments for the git fetch command. If set to false, the action won't fetch the repo.
# For more info as to why fetching is usually recommended, please see the "Performance on large repos" FAQ.
# Default: --tags --force
Expand Down Expand Up @@ -111,10 +116,16 @@ Multiple options let you provide the `git` arguments that you want the action to
What does this mean for you? It means that strings that contain a lot of nested quotes may be parsed incorrectly, and that specific ways of declaring arguments may not be supported by these libraries. If you're having issues with your argument strings you can check whether they're being parsed correctly either by [enabling debug logging](https://docs.github.com/en/actions/managing-workflow-runs/enabling-debug-logging) for your workflow runs or by testing it directly with `string-argv` ([RunKit demo](https://npm.runkit.com/string-argv)): if each argument and option is parsed correctly you'll see an array where every string is an option or value.

Remote-helper overrides (`--upload-pack`, `--receive-pack`, `--exec`, and abbreviations of those) are rejected on every token, including values after `-u` / `-m`: they can make git run an arbitrary Git transport program during fetch/pull/push.
Remote-helper URL forms (`ext::…` and other `scheme::` tokens) are rejected for the same reason.
Git child processes are also limited to the `https`, `http`, `ssh`, `file`, and `git` transports (`GIT_ALLOW_PROTOCOL`) unless you set [`allow_unsafe_git_protocols`](#allow-unsafe-git-protocols) to `true` (only for trusted custom remotes/helpers).
Message-from-file flags (`-F`, `--file`, abbreviations such as `--fi`, and short-option clusters that include `F` such as `-aF`) are rejected: they can embed arbitrary runner filesystem contents into a tag or commit message and, with a push, into the repository history.
Unmatched `'` / `"` quotes are also rejected: `string-argv` can otherwise split on an odd quote and turn part of a value into extra flags (for example a branch name like `fix'--force` becoming `fix` plus `--force`).
Do not interpolate untrusted data (for example values from `github.event.*`, `github.head_ref`, or repository content that contributors can edit) into `fetch`, `pull`, `push`, `tag`, `tag_push`, or `commit` without sanitizing them first. When the branch name is dynamic, prefer the default `push: true` with [`new_branch`](#creating-a-new-branch) instead of embedding the ref in a custom `push` string.

### Allow unsafe git protocols

Set `allow_unsafe_git_protocols: true` only if you need a custom remote helper or a transport outside the default allowlist (`https`, `http`, `ssh`, `file`, `git`). This disables both the `GIT_ALLOW_PROTOCOL` restriction and the rejection of `scheme::` tokens in git argument inputs. It does **not** re-enable blocked options such as `--upload-pack` or `-F`/`--file`. Treat this like a break-glass setting: only enable it with fully trusted, non-interpolated argument strings.

### Adding files

The action adds files using a regular `git add` command, so you can put every kind of argument in the `add` option. For example, if you want to force-add a file: `./path/to/file.txt --force`.
Expand Down
5 changes: 5 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ inputs:
description: Arguments for the git push --tags command (any additional argument will be added after --tags)
required: false

allow_unsafe_git_protocols:
description: 'If true, disables the transport protocol allowlist (https/http/ssh/file/git) and allows scheme:: remote-helper URLs in git argument inputs. Keep false unless you need a custom remote helper and fully trust those inputs.'
required: false
default: 'false'

# Input not required from the user
github_token:
description: The token used to make requests to the GitHub API. It's NOT used to make commits and should not be changed.
Expand Down
2 changes: 1 addition & 1 deletion lib/index.js

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions src/io.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {assertValidBranchName, getUserInfo, parseInputArray} from './util';

export interface InputTypes {
add: string;
allow_unsafe_git_protocols: boolean;
author_name: string;
author_email: string;
commit: string | undefined;
Expand Down Expand Up @@ -155,6 +156,13 @@ export async function checkInputs() {
);
// #endregion

// #region allow_unsafe_git_protocols
if (getInput('allow_unsafe_git_protocols', true))
core.warning(
'allow_unsafe_git_protocols is enabled: transport allowlist and scheme:: remote-helper URL checks are disabled. Only use this with fully trusted git argument inputs.',
);
// #endregion

// #region fetch
if (getInput('fetch')) {
let value: string | boolean;
Expand Down
56 changes: 36 additions & 20 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,30 @@ import {
const baseDir = path.join(process.cwd(), getInput('cwd') || '');
const git = simpleGit({baseDir});

/** Env for git child processes; restricts transports unless opt-out is set. */
function gitChildEnv(extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {...process.env, ...extra};
if (!getInput('allow_unsafe_git_protocols', true)) {
env.GIT_ALLOW_PROTOCOL = 'https:http:ssh:file:git';
env.GIT_PROTOCOL_FROM_USER = '0';
}
return env;
}

function parseGitArgs(string: string) {
return matchGitArgs(string, {
allowUnsafeGitProtocols: getInput('allow_unsafe_git_protocols', true),
});
}

const exitErrors: Error[] = [];

core.info(`Running in ${baseDir}`);
(async () => {
await checkInputs();

git.env(gitChildEnv());

const dryRun = getInput('dry_run', true);

core.startGroup('Internal logs');
Expand Down Expand Up @@ -63,7 +81,7 @@ core.info(`Running in ${baseDir}`);

core.info('> Checking for uncommitted changes in the git working tree...');
const changedFiles = (await git.diffSummary(['--cached'])).files.length;
const allowEmpty = matchGitArgs(getInput('commit') || '').includes(
const allowEmpty = parseGitArgs(getInput('commit') || '').includes(
'--allow-empty',
);
// continue if there are any changes or if the allow-empty commit argument is included
Expand Down Expand Up @@ -110,7 +128,7 @@ core.info(`Running in ${baseDir}`);
if (fetchOption) {
core.info('> Fetching repo...');
await git.fetch(
matchGitArgs(fetchOption === true ? '' : fetchOption),
parseGitArgs(fetchOption === true ? '' : fetchOption),
log,
);
} else core.info('> Not fetching repo.');
Expand Down Expand Up @@ -143,7 +161,7 @@ core.info(`Running in ${baseDir}`);
core.info('> Creating commit...');
const data = await git.commit(
getInput('message'),
matchGitArgs(getInput('commit') || ''),
parseGitArgs(getInput('commit') || ''),
);
log(undefined, data);
// simple-git can resolve with an empty SHA when no commit was created
Expand All @@ -166,7 +184,7 @@ core.info(`Running in ${baseDir}`);
);

await git
.tag(matchGitArgs(getInput('tag') || ''), (err, data?) => {
.tag(parseGitArgs(getInput('tag') || ''), (err, data?) => {
if (data) setOutput('tagged', 'true');
return log(err, data);
})
Expand Down Expand Up @@ -220,7 +238,7 @@ core.info(`Running in ${baseDir}`);
core.info('> Pushing tags to repo...');

await git
.pushTags('origin', matchGitArgs(getInput('tag_push') || ''))
.pushTags('origin', parseGitArgs(getInput('tag_push') || ''))
.then(data => {
setOutput('tag_pushed', 'true');
return log(null, data);
Expand Down Expand Up @@ -352,7 +370,7 @@ async function pullFromRemote(
core.debug(`Current git pull arguments: ${pullOption}`);
await git
.fetch(undefined, log)
.pull(undefined, undefined, matchGitArgs(pullOption), log);
.pull(undefined, undefined, parseGitArgs(pullOption), log);

core.info('> Checking for conflicts...');
const status = await git.status(undefined, log);
Expand Down Expand Up @@ -404,7 +422,7 @@ async function pushCommit(pushOption: true | string) {
await git.push(
undefined,
undefined,
matchGitArgs(pushOption),
parseGitArgs(pushOption),
(err, data?) => {
if (data) setOutput('pushed', 'true');
return log(err, data);
Expand All @@ -425,8 +443,8 @@ async function add(

for (const args of parsed) {
const gitArgs = dryRun
? ['--dry-run', ...matchGitArgs(args)]
: matchGitArgs(args);
? ['--dry-run', ...parseGitArgs(args)]
: parseGitArgs(args);
res.push(
// Push the result of every git command (which are executed in order) to the array
// If any of them fails, the whole function will return a Promise rejection
Expand Down Expand Up @@ -490,10 +508,9 @@ async function assertGitlinksWithTempIndex(
if (fs.existsSync(indexPath)) {
fs.copyFileSync(indexPath, tmpIndex);
} else {
const gitSeed = simpleGit({baseDir}).env({
...process.env,
GIT_INDEX_FILE: tmpIndex,
});
const gitSeed = simpleGit({baseDir}).env(
gitChildEnv({GIT_INDEX_FILE: tmpIndex}),
);
const headResolves = await git
.raw(['rev-parse', '--verify', 'HEAD'])
.then(() => true)
Expand All @@ -505,14 +522,13 @@ async function assertGitlinksWithTempIndex(
}
}

const gitTmp = simpleGit({baseDir}).env({
...process.env,
GIT_INDEX_FILE: tmpIndex,
});
const gitTmp = simpleGit({baseDir}).env(
gitChildEnv({GIT_INDEX_FILE: tmpIndex}),
);

for (const args of addArgGroups) {
await gitTmp
.add(matchGitArgs(args), (err, data) =>
.add(parseGitArgs(args), (err, data) =>
log(ignoreErrors === 'all' ? null : err, data),
)
.catch((e: Error) => {
Expand Down Expand Up @@ -556,8 +572,8 @@ async function remove(

for (const args of parsed) {
const gitArgs = dryRun
? ['--dry-run', ...matchGitArgs(args)]
: matchGitArgs(args);
? ['--dry-run', ...parseGitArgs(args)]
: parseGitArgs(args);
res.push(
// Push the result of every git command (which are executed in order) to the array
// If any of them fails, the whole function will return a Promise rejection
Expand Down
31 changes: 30 additions & 1 deletion src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,24 @@ function assertBalancedQuotes(input: string): void {
}
}

/**
* Git remote-helper URL form (`ext::command`, `hg::…`, etc.).
* @see https://git-scm.com/docs/gitremote-helpers
*/
const REMOTE_HELPER_URL = /^[A-Za-z0-9+.-]+::/;

function isRemoteHelperUrl(arg: string): boolean {
return REMOTE_HELPER_URL.test(arg);
}

export type MatchGitArgsOptions = {
/**
* When true, allow `scheme::` remote-helper URL tokens.
* Does not disable `--upload-pack` / `-F` denylists.
*/
allowUnsafeGitProtocols?: boolean;
};

/**
* Matches the given string to an array of arguments.
* The parsing is made by `string-argv`: if your way of using argument is not supported, the issue is theirs!
Expand Down Expand Up @@ -356,15 +374,21 @@ function assertBalancedQuotes(input: string): void {
* @throws If the args include unmatched quotes
* @throws If the args include a blocked remote-helper override (`--upload-pack`, `--receive-pack`, `--exec`, or abbreviations) on any token, including values after `-u` / `-m`
* @throws If the args include a blocked message-from-file flag (`-F`, `--file`, abbreviations, or short-option clusters containing `F`)
* @throws If the args include a `scheme::` remote-helper URL (unless `allowUnsafeGitProtocols`)
*/
export function matchGitArgs(string: string) {
export function matchGitArgs(
string: string,
options: MatchGitArgsOptions = {},
) {
assertBalancedQuotes(string);

const parsed = parseArgsStringToArgv(string);
core.debug(`Git args parsed:
- Original: ${string}
- Parsed: ${JSON.stringify(parsed)}`);

const allowUnsafe = options.allowUnsafeGitProtocols === true;

let skipNext = false;
for (const arg of parsed) {
// Remote-helper overrides are rejected on every token, including values
Expand All @@ -385,6 +409,11 @@ export function matchGitArgs(string: string) {
`Git argument '${arg}' is not allowed: reading a tag/commit message from a file (-F/--file) can exfiltrate runner filesystem contents into git history.`,
);
}
if (!allowUnsafe && isRemoteHelperUrl(arg)) {
throw new Error(
`Git argument '${arg}' is not allowed: remote-helper URLs (scheme::…) can execute arbitrary commands on the runner. Set allow_unsafe_git_protocols to true only if you fully trust this input.`,
);
}

skipNext = consumesFollowingArgument(arg);
}
Expand Down
2 changes: 2 additions & 0 deletions test/integration/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ function parseGitHubOutput(filePath: string): Record<string, string> {

export interface ActionInputs {
add?: string;
allow_unsafe_git_protocols?: string;
author_name?: string;
author_email?: string;
commit?: string;
Expand Down Expand Up @@ -214,6 +215,7 @@ export function runAction(
// Mirror action.yml defaults that matter when spawning lib/ directly.
cwd: '.',
add: '.',
allow_unsafe_git_protocols: 'false',
default_author: 'github_actor',
dry_run: 'false',
pathspec_error_handling: 'ignore',
Expand Down
42 changes: 42 additions & 0 deletions test/util.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,48 @@ describe('matchGitArgs', () => {
/message from a file/,
);
});

it('rejects scheme:: remote-helper URL tokens (PoC form)', () => {
expect(() => matchGitArgs('ext::sh -c touch\\ /tmp/pwned')).toThrow(
/remote-helper URLs/,
);
expect(() => matchGitArgs('ext::touch /tmp/pwned')).toThrow(
/allow_unsafe_git_protocols/,
);
expect(() => matchGitArgs('evil::anything')).toThrow(/remote-helper URLs/);
expect(() => matchGitArgs('origin evil::x --force')).toThrow(
/remote-helper URLs/,
);
expect(() => matchGitArgs("'ext::sh -c touch /tmp/pwned'")).toThrow(
/remote-helper URLs/,
);
});

it('allows scheme:: tokens when allowUnsafeGitProtocols is true', () => {
expect(
matchGitArgs('ext::sh -c true', {allowUnsafeGitProtocols: true}),
).toStrictEqual(['ext::sh', '-c', 'true']);
expect(
matchGitArgs('evil::anything', {allowUnsafeGitProtocols: true}),
).toStrictEqual(['evil::anything']);
expect(
matchGitArgs("'ext::sh -c true'", {allowUnsafeGitProtocols: true}),
).toStrictEqual(['ext::sh -c true']);
});

it('still rejects --upload-pack when allowUnsafeGitProtocols is true', () => {
expect(() =>
matchGitArgs('--upload-pack=/bin/sh', {allowUnsafeGitProtocols: true}),
).toThrow(/not allowed/);
});

it('allows :: inside option values via skipNext', () => {
expect(matchGitArgs('-m "foo::bar"')).toStrictEqual(['-m', 'foo::bar']);
expect(matchGitArgs('--message foo::bar')).toStrictEqual([
'--message',
'foo::bar',
]);
});
});

describe('pickGitIdentityConfig', () => {
Expand Down