Run git without a shell

A repository browser pastes request values into a git command line for the shell, so a semicolon starts a second command and a leading dash becomes a git option. Run git directly, with each value as one argument.

Requires Node.js 22 or newer. No package installation or server needed. To work in a container instead, open the folder with VS Code Dev Containers or GitHub Codespaces: .devcontainer/devcontainer.json uses the Dev Containers Node.js 24 image, pinned by digest.
1. Unzip all files into one folder.
2. In that folder, run: node --test exercise.test.mjs
3. Edit git-view.mjs, then run the same command again.

The starter deliberately fails some tests. A successful repair passes every case, including cases that catch common wrong fixes. The browser runs this same cases.mjs with the same fixtures; locally Node runs your code directly. If you completed the optional first stage in the browser, Download my work adds your input as stage1-input.json and the tests include it. Only run code you wrote or reviewed.

1. Reproduce the injection
Run the starter. In the separator case the shell ends git’s command at the semicolon and runs touch; the spaces case fails too, because the shell splits the file name into two arguments.

2. Drop the shell
Replace runner.exec with runner.execFile("git", [view, ref, "--", path]). Each array element reaches git as one argument, so no character in a value is special, and no quoting is needed.

3. Close the option channel
Values before "--" can still be read as options. Refuse a ref that starts with "-", and allow only the views log, blame and diff, because the subcommand also comes from the request.

4. Keep real file names working
Spaces, apostrophes, double quotes, non-ASCII names and a file called -notes.md must all reach git unchanged. "--" is what makes the leading dash safe, so do not refuse it.

SUPPLIED CONTRACT
runGit(request, runner) is async. request is {view, ref, path} from the query string. runner is a model of Node.js’s child_process (QuickJS has no processes, so nothing is executed): await runner.execFile(file, args) starts one program with an array of arguments and no shell, and await runner.exec(commandLine) starts /bin/sh -c commandLine; both resolve to {code, stdout}. The grader splits a command line the way a POSIX shell would and reads git’s arguments the way git would, so it can show every command that would start and which values git takes as options. Rules: view must be log, blame or diff; ref must be a non-empty string that does not start with "-"; path must be a non-empty string, and any name is allowed, including spaces, quotes, non-ASCII characters and a leading dash. Run git through execFile with the arguments [view, ref, "--", path] and return {status: 200, output: stdout}. (With Git 2.24 or later, [view, "--end-of-options", ref, "--", path] also keeps the ref from being read as an option, and the grader accepts it.) Return {status: 400} without running anything when a rule fails.

REFERENCE
reference.mjs contains an explained solution: log main -- README.md, blame v1.2.0 -- src/app.js and diff release/2.4 -- src/server.js run as git with four arguments through execFile. docs/release notes.md, docs/it’s-done.md, docs/say "hi".md, docs/café-menü.md and -notes.md each arrive as one path after "--". A path or ref containing ; | && $( ) backticks, a newline, a quote that would close single quotes, or > is passed to git as literal text, so no second command runs and no file is written. --output=public/log.txt and --contents=/etc/passwd as the ref, the checkout subcommand, the view --exec-path=/tmp and an empty path are refused with 400 before anything starts.

SCOPE
The runner is a model: the grader parses command lines and git arguments rather than starting processes, and it covers the POSIX shell features the cases use, not every shell dialect. In a real service, also run git with least privilege against a read-only copy of the repository, set a timeout and an output limit, and keep stderr away from clients. Since Git 2.24, git also accepts --end-of-options, which ends option parsing in commands where "--" separates revisions from paths; refusing a leading dash works with any version. Other programs treat "--" differently: check each tool’s documentation before relying on it.

NEXT TASK
Where does your code start another program, and does any of those calls pass a single command string, or let a request value appear before "--"?

SOURCE
https://cheatsheetseries.owasp.org/cheatsheets/OS_Command_Injection_Defense_Cheat_Sheet.html
