Skip to content

Commit 9e2cfeb

Browse files
fix(plugin-git): guard revisions against argument injection
1 parent fd8b1c6 commit 9e2cfeb

4 files changed

Lines changed: 59 additions & 8 deletions

File tree

plugins/git/src/node/git.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,14 @@ export async function tryGit(cwd: string, args: string[]): Promise<string | null
4141
}
4242
}
4343

44+
/**
45+
* Client-supplied revisions must not start with `-`, which Git would parse as
46+
* an option before it treats the value as a revision.
47+
*/
48+
export function isSafeRevision(rev: string): boolean {
49+
return rev.length > 0 && !rev.startsWith('-')
50+
}
51+
4452
/** Resolve the repository root for `cwd`, or `null` when `cwd` is outside a repo. */
4553
export async function resolveRepoRoot(cwd: string): Promise<string | null> {
4654
return tryGit(cwd, ['rev-parse', '--show-toplevel'])

plugins/git/src/rpc/functions/log.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { defineRpcFunction } from 'devframe'
2-
import { RECORD, splitClean, tryGit, UNIT } from '../../node/git.ts'
2+
import { isSafeRevision, RECORD, splitClean, tryGit, UNIT } from '../../node/git.ts'
33
import { getGitContext } from '../context.ts'
44

55
export interface Commit {
@@ -108,8 +108,11 @@ export const log = defineRpcFunction({
108108
`--skip=${skip}`,
109109
`--pretty=format:${FORMAT}`,
110110
]
111-
if (ref)
112-
command.push(ref)
111+
if (ref) {
112+
if (!isSafeRevision(ref))
113+
return { isRepo: true, commits: [], limit, skip, hasMore: false }
114+
command.push('--end-of-options', ref)
115+
}
113116

114117
const raw = await tryGit(git.cwd, command)
115118
// `null` happens on a repo with no commits yet — treat as empty.

plugins/git/src/rpc/functions/show.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { GitContext } from '../context.ts'
22
import { defineRpcFunction } from 'devframe'
3-
import { splitClean, tryGit, UNIT } from '../../node/git.ts'
3+
import { isSafeRevision, splitClean, tryGit, UNIT } from '../../node/git.ts'
44
import { getGitContext } from '../context.ts'
55

66
/** Hard cap on the returned patch text to keep payloads bounded. */
@@ -102,7 +102,10 @@ function parseNumstat(raw: string): CommitFile[] {
102102
}
103103

104104
async function readCommit(git: GitContext, hash: string, includePatch: boolean): Promise<CommitDetail> {
105-
const meta = await tryGit(git.cwd, ['show', '-s', `--format=${SHOW_FORMAT}`, hash])
105+
if (!isSafeRevision(hash))
106+
return { ...EMPTY_DETAIL, isRepo: true }
107+
108+
const meta = await tryGit(git.cwd, ['show', '-s', `--format=${SHOW_FORMAT}`, '--end-of-options', hash])
106109
if (meta == null)
107110
return { ...EMPTY_DETAIL, isRepo: true }
108111

@@ -122,15 +125,15 @@ async function readCommit(git: GitContext, hash: string, includePatch: boolean):
122125
] = meta.split(UNIT)
123126

124127
// `--root` so the initial commit reports its full tree as additions.
125-
const numstat = await tryGit(git.cwd, ['diff-tree', '--no-commit-id', '--numstat', '-r', '--root', hash])
128+
const numstat = await tryGit(git.cwd, ['diff-tree', '--no-commit-id', '--numstat', '-r', '--root', '--end-of-options', hash])
126129
const files = numstat ? parseNumstat(numstat) : []
127130
const totalAdditions = files.reduce((sum, f) => sum + f.additions, 0)
128131
const totalDeletions = files.reduce((sum, f) => sum + f.deletions, 0)
129132

130133
let patch: string | null = null
131134
let truncated = false
132135
if (includePatch) {
133-
const raw = await tryGit(git.cwd, ['diff-tree', '-p', '--no-commit-id', '-r', '--root', hash])
136+
const raw = await tryGit(git.cwd, ['diff-tree', '-p', '--no-commit-id', '-r', '--root', '--end-of-options', hash])
134137
if (raw != null) {
135138
if (raw.length > PATCH_CHAR_LIMIT) {
136139
patch = raw.slice(0, PATCH_CHAR_LIMIT)

plugins/git/test/git.test.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
import type { CommitResult, GitBranches, GitDiff, GitLog, GitStatus } from '../src/index'
1+
import type { CommitDetail, CommitResult, GitBranches, GitDiff, GitLog, GitStatus } from '../src/index'
2+
import { existsSync } from 'node:fs'
3+
import { join } from 'node:path'
24
import { createRpcClient } from 'devframe/rpc/client'
35
import { collectStaticRpcDump } from 'devframe/rpc/dump'
46
import { createWsRpcChannel } from 'devframe/rpc/transports/ws-client'
@@ -83,6 +85,41 @@ describe('@devframes/plugin-git', () => {
8385
expect(tail.hasMore).toBe(false)
8486
})
8587

88+
it('treats dashed log refs as invalid revisions instead of Git options', async () => {
89+
const rpc = bootRpc(server.port)
90+
const marker = join(repo.dir, 'log-injected.txt')
91+
92+
const log = await rpc.$call('git:log', { ref: `--output=${marker}` }) as GitLog
93+
94+
expect(log.isRepo).toBe(true)
95+
expect(log.commits).toEqual([])
96+
expect(log.hasMore).toBe(false)
97+
expect(existsSync(marker)).toBe(false)
98+
})
99+
100+
it('treats dashed show hashes as invalid revisions instead of Git options', async () => {
101+
const rpc = bootRpc(server.port)
102+
const marker = join(repo.dir, 'show-injected.txt')
103+
104+
const detail = await rpc.$call('git:show', { hash: `--output=${marker}` }) as CommitDetail
105+
106+
expect(detail.isRepo).toBe(true)
107+
expect(detail.found).toBe(false)
108+
expect(existsSync(marker)).toBe(false)
109+
})
110+
111+
it('returns commit details for a valid hash', async () => {
112+
const rpc = bootRpc(server.port)
113+
const log = await rpc.$call('git:log', { limit: 1 }) as GitLog
114+
115+
const detail = await rpc.$call('git:show', { hash: log.commits[0].hash }) as CommitDetail
116+
117+
expect(detail.isRepo).toBe(true)
118+
expect(detail.found).toBe(true)
119+
expect(detail.hash).toBe(log.commits[0].hash)
120+
expect(detail.files.map(file => file.path)).toContain('a.txt')
121+
})
122+
86123
it('lists local branches with the current one first', async () => {
87124
const rpc = bootRpc(server.port)
88125
const result = await rpc.$call('git:branches', {}) as GitBranches

0 commit comments

Comments
 (0)