fix: validate denylisted git args on every token (#784)

Short-option clusters such as -Sm were treated as consuming the next
argument, which let --pathspec-from-file reach git and leak file
contents into workflow logs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Federico Grandi
2026-08-19 14:47:41 +00:00
committed by GitHub
co-authored by Cursor
parent b0ca32735a
commit 3446398e33
4 changed files with 76 additions and 90 deletions
Generated
+1 -1
View File
File diff suppressed because one or more lines are too long
+8 -61
View File
@@ -256,30 +256,12 @@ const DANGEROUS_PATHSPEC_FILE_OPTIONS: ReadonlyArray<{
{canonical: 'pathspec-file-nul', minPrefix: 'pathspec-fi'},
];
/**
* Long options whose next argv token is a value, not another option.
* Used so literals like `-m '-F'` are not treated as a message-file flag.
*/
const LONG_OPTIONS_WITH_SEPARATE_ARG: ReadonlyArray<{
canonical: string;
minPrefix: string;
}> = [
{canonical: 'message', minPrefix: 'mes'},
{canonical: 'local-user', minPrefix: 'local-'},
{canonical: 'cleanup', minPrefix: 'cleanup'},
{canonical: 'file', minPrefix: 'fi'},
{canonical: 'pathspec-from-file', minPrefix: 'pathspec-fr'},
{canonical: 'upload-pack', minPrefix: 'upl'},
{canonical: 'receive-pack', minPrefix: 'rece'},
{canonical: 'exec', minPrefix: 'e'},
];
/**
* Short options that take a value (glued or as the following argv token).
* Used so glued values after `-m` (e.g. `-m-F`) are not treated as `-F`.
* `-u` is intentionally omitted: it only takes a key-id for `git tag`, while
* `git fetch` (`--update-head-ok`) and `git push` (`--set-upstream`) treat it
* as a flag. Tag signing still uses `--local-user` in
* `LONG_OPTIONS_WITH_SEPARATE_ARG`.
* as a flag.
*/
const SHORT_OPTIONS_WITH_ARG = new Set(['m', 'F']);
@@ -290,11 +272,6 @@ function getLongOptionName(arg: string): string | undefined {
return (eq === -1 ? body : body.slice(0, eq)).toLowerCase();
}
function longOptionHasInlineValue(arg: string): boolean {
if (!arg.startsWith('--') || arg === '--') return false;
return arg.slice(2).includes('=');
}
function matchesLongOptionPrefix(
arg: string,
options: ReadonlyArray<{canonical: string; minPrefix: string}>,
@@ -342,28 +319,6 @@ function isDangerousPathspecFileOption(arg: string): boolean {
return matchesLongOptionPrefix(arg, DANGEROUS_PATHSPEC_FILE_OPTIONS);
}
/**
* Whether this token causes Git to treat the next argv element as a value
* (so that value must not be classified as an option).
*/
function consumesFollowingArgument(arg: string): boolean {
if (arg.startsWith('--') && arg !== '--') {
if (longOptionHasInlineValue(arg)) return false;
return matchesLongOptionPrefix(arg, LONG_OPTIONS_WITH_SEPARATE_ARG);
}
if (!arg.startsWith('-') || arg.startsWith('--')) return false;
const body = arg.slice(1);
for (let i = 0; i < body.length; i++) {
const ch = body[i];
if (SHORT_OPTIONS_WITH_ARG.has(ch)) {
// Glued value after the option letter → no separate following argv.
return i === body.length - 1;
}
}
return false;
}
/**
* Conservative argument-boundary check for quotes before `string-argv` runs.
* Not every rejected form would become extra argv words.
@@ -446,9 +401,9 @@ export type MatchGitArgsOptions = {
* @returns An array, if there's no match it'll be empty
* @throws If the args include unmatched quotes, or a closing quote glued to following text
* @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 blocked pathspec-from-file flag (`--pathspec-from-file`, `--pathspec-file-nul`, or abbreviations)
* @throws If the args include a `scheme::` remote-helper URL (unless `allowUnsafeGitProtocols`)
* @throws If the args include a blocked message-from-file flag (`-F`, `--file`, abbreviations, or short-option clusters containing `F`) on any token, including values after `-m` / `--message`
* @throws If the args include a blocked pathspec-from-file flag (`--pathspec-from-file`, `--pathspec-file-nul`, or abbreviations) on any token, including values after `-m` / `--message`
* @throws If the args include a `scheme::` remote-helper URL on any token (unless `allowUnsafeGitProtocols`)
*/
export function matchGitArgs(
string: string,
@@ -463,21 +418,15 @@ export function matchGitArgs(
const allowUnsafe = options.allowUnsafeGitProtocols === true;
let skipNext = false;
// All denylists run on every token. Skipping the next argv after a guessed
// value-taking option (e.g. treating `-Sm` as `-m`) disagrees with Git's
// cluster parser and smuggles `--pathspec-from-file` / `scheme::` through.
for (const arg of parsed) {
// Remote-helper overrides are rejected on every token, including values
// after `-m` / `--message`. `-u` must not skip `--upl=` / `--upload-pack`.
if (isDangerousRemoteHelperOption(arg)) {
throw new Error(
`Git argument '${neutralizeLogString(arg)}' is not allowed: overriding the remote helper (--upload-pack, --receive-pack, --exec) can execute arbitrary commands on the runner.`,
);
}
if (skipNext) {
skipNext = false;
continue;
}
if (isDangerousMessageFileOption(arg)) {
throw new Error(
`Git argument '${neutralizeLogString(arg)}' is not allowed: reading a tag/commit message from a file (-F/--file) can exfiltrate runner filesystem contents into git history.`,
@@ -493,8 +442,6 @@ export function matchGitArgs(
`Git argument '${neutralizeLogString(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);
}
return parsed;
+15
View File
@@ -559,6 +559,21 @@ describe('action integration', () => {
expect(gitRevParse(f.local, 'HEAD')).toBe(before);
});
it('rejects commit when a short-option cluster precedes the same options', () => {
const f = fixture!;
const dummyPath = writeDummyOutsideClone(f);
writeFile(f.local, 'commit-cluster-args.txt', 'changed\n');
const before = gitRevParse(f.local, 'HEAD');
const result = runAction(f, {
commit: `-Sm --pathspec-from-file=${dummyPath} -Sm --pathspec-file-nul`,
push: 'false',
});
expectBlockedWithoutDisclosure(result);
expect(gitRevParse(f.local, 'HEAD')).toBe(before);
});
it('rejects a YAML array element with the same options', () => {
const f = fixture!;
const dummyPath = writeDummyOutsideClone(f);
+52 -28
View File
@@ -332,20 +332,19 @@ describe('matchGitArgs', () => {
expect(() => matchGitArgs('-F../secrets')).toThrow(/message from a file/);
});
it('preserves -m / --message values that look like -F/--file', () => {
expect(matchGitArgs('-m "-F"')).toStrictEqual(['-m', '-F']);
expect(matchGitArgs('-m --file=/tmp/value')).toStrictEqual([
'-m',
'--file=/tmp/value',
]);
expect(matchGitArgs('--message "-F"')).toStrictEqual(['--message', '-F']);
it('rejects -F / --file even when they follow -m / --message', () => {
expect(() => matchGitArgs('-m "-F"')).toThrow(/message from a file/);
expect(() => matchGitArgs('-m --file=/tmp/value')).toThrow(
/message from a file/,
);
expect(() => matchGitArgs('--message "-F"')).toThrow(/message from a file/);
expect(() => matchGitArgs('v1.0.0 -a -m "-F"')).toThrow(
/message from a file/,
);
});
it('treats -m-F as a glued message value, not a file flag', () => {
expect(matchGitArgs('-m-F')).toStrictEqual(['-m-F']);
expect(matchGitArgs('v1.0.0 -a -m "-F"')).toStrictEqual([
'v1.0.0',
'-a',
'-m',
'-F',
]);
});
it('still rejects a real -F after a message value', () => {
@@ -387,15 +386,13 @@ describe('matchGitArgs', () => {
);
});
it('preserves -m / --message values that look like --pathspec-from-file', () => {
expect(matchGitArgs('-m "--pathspec-from-file=/x"')).toStrictEqual([
'-m',
'--pathspec-from-file=/x',
]);
expect(matchGitArgs('--message --pathspec-from-file=/x')).toStrictEqual([
'--message',
'--pathspec-from-file=/x',
]);
it('rejects --pathspec-from-file even when it follows -m / --message', () => {
expect(() => matchGitArgs('-m "--pathspec-from-file=/x"')).toThrow(
/pathspecs from a file/,
);
expect(() => matchGitArgs('--message --pathspec-from-file=/x')).toThrow(
/pathspecs from a file/,
);
});
it('still rejects a real --pathspec-from-file after a message value', () => {
@@ -404,6 +401,15 @@ describe('matchGitArgs', () => {
);
});
it('rejects --pathspec-from-file after a short-option cluster whose last letter is m', () => {
expect(() =>
matchGitArgs('-Sm --pathspec-from-file=/x -Sm --pathspec-file-nul'),
).toThrow(/pathspecs from a file/);
expect(() => matchGitArgs('-tm --pathspec-from-file=/x')).toThrow(
/pathspecs from a file/,
);
});
it('still rejects --pathspec-from-file when allowUnsafeGitProtocols is true', () => {
expect(() =>
matchGitArgs('--pathspec-from-file=/path', {
@@ -449,12 +455,30 @@ describe('matchGitArgs', () => {
).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',
]);
it('rejects scheme:: even when it follows -m / --message', () => {
expect(() => matchGitArgs('-m "foo::bar"')).toThrow(/remote-helper URLs/);
expect(() => matchGitArgs('--message foo::bar')).toThrow(
/remote-helper URLs/,
);
});
it('rejects scheme:: after a short-option cluster that used to skip the next token', () => {
expect(() => matchGitArgs('-Sm ext::sh')).toThrow(/remote-helper URLs/);
expect(() => matchGitArgs('-om ext::sh origin')).toThrow(
/remote-helper URLs/,
);
});
it('allows scheme:: after -m when allowUnsafeGitProtocols is true', () => {
expect(
matchGitArgs('-m "foo::bar"', {allowUnsafeGitProtocols: true}),
).toStrictEqual(['-m', 'foo::bar']);
expect(
matchGitArgs('--message foo::bar', {allowUnsafeGitProtocols: true}),
).toStrictEqual(['--message', 'foo::bar']);
expect(
matchGitArgs('-Sm ext::sh', {allowUnsafeGitProtocols: true}),
).toStrictEqual(['-Sm', 'ext::sh']);
});
});