Skip to content

security: add SECURITY.md, drop the /bin/sh -c footgun, document the real threat model - #295

Open
metif12 wants to merge 1 commit into
vlang:masterfrom
metif12:security/audit-and-policy
Open

metif12 wants to merge 1 commit into
vlang:masterfrom
metif12:security/audit-and-policy

Conversation

@metif12

@metif12 metif12 commented Oct 11, 2026

Copy link
Copy Markdown

Summary

Audit of the reported command-injection surface, done against the current tree rather than the 2023 reports, which cite a src/ layout that no longer exists.

  git/git.v
- pub fn Git.exec_shell(command string) os.Result {
- 	return os.exec(['/bin/sh', '-c', command])
- }
+ // NOTE: there is deliberately no `exec_shell` wrapper here. Handing a
+ // concatenated command string to `/bin/sh -c` is how command injection gets
+ // introduced, and it is never needed: every caller below builds an argument
+ // vector and passes it to `os.exec`, so arguments are never re-parsed by a
+ // shell. Add an argument-array helper rather than a string one.

Plus SECURITY.md, which the reporter of #246 asked for in 2023 and which the repository still does not have.

Evidence

The 2023 report's cited files do not exist. src/repo_routes.v, src/commit_routes.v, and src/repo_service.v are all gone; the tree was restructured and the helpers are now in git/git.v and repo/repo.v. So the report cannot be acted on as written and has to be re-derived.

Every external command runs through argv. All four os.exec call sites in git/git.v pass an array:

pub fn Git.exec(args []string) os.Result {
	mut git_args := ['git']
	git_args << args
	return os.exec(git_args)
}
// Git.exec_in_dir_with_env runs `git -C <dir> <args...>` without going through a
// shell, with `extra_env` added on top of the inherited environment. Use this
// instead of building a shell string when any argument or environment value is
// user-controlled (file paths, branch names, commit messages, author names),
// since arguments are passed directly to git and never re-parsed by /bin/sh.

split_command (git/git.v:218) is purely lexical — word splitting and unquoting, with no command substitution, variable expansion, or globbing — and its result goes to argv.

exec_shell had zero callers. A repository-wide search for exec_shell returns exactly one match, its own definition:

D:\MyProjects\gitly\git\git.v:
  Line 33: pub fn Git.exec_shell(command string) os.Result {

Private-repository visibility (#247) is already fixed. repo/repo.v:1796:

fn (app &App) can_read_repo(ctx Context, repo Repo) bool {
	if repo.is_public {
		return true
	}
	return ctx.logged_in && app.user_has_repo_read_access(ctx.user.id, repo)
}

That check is called at 43 sites across every repo-scoped read route, including tree() in repo/repo_routes.v:753, which is the route the report described (/:username/:repo_name). Regression tests already exist in api/endpoints_test.v:919 (test_api_v1_private_repo_visibility_from_other_user, which asserts 404 for a private repo from another authenticated user, and 200 for the owner) and pr_authorization_test.v.

Build still passes after the removal:

$ v -d sqlite -o gitly.exe .
EXIT=0
True

Merge Danger

Door: Two-way. exec_shell had no callers, so removing it cannot change behaviour; the rest is comments and a new file. Reverting is clean.

Blast Radius: Small, but one thing deserves scrutiny.

I deliberately did not fix the residual issue I found, and named it in the code instead. Repo.git() guards against & and ;, which reads like a shell guard but is not one, because there is no shell. The actual residual risk is argument injection: a ref name beginning with - can still be interpreted by git as an option, and split_command splits on whitespace, so a ref containing a space becomes several arguments. The callers that interpolate a caller-supplied ref are pr_routes.v:859 / pr_routes.v:886 (log ${base}..${head}, diff ${base}...${head}) and repo/branch.v:67.

Fixing that properly means moving those callers onto Git.exec_in_dir_with_env with an argument array and a -- separator, which is a behavioural change I cannot test here beyond a compile. It is a separate change and should be reviewed on its own merits, not smuggled into a cleanup.

…real threat model

Audit of the reported command injection attack surface, done against the
current tree rather than the 2023 reports, which cite a `src/` layout that no
longer exists.

Findings:

- Every external command now runs through an argument vector. `os.exec` is
  called with an array in all four sites in `git/git.v`, and
  `Git.exec_in_dir_with_env` sets `p.set_args(...)` directly. Nothing
  concatenates user input into a string that is later handed to a shell.
- `split_command` is purely lexical: it word-splits and unquotes, and does no
  command substitution, variable expansion, or globbing. Its result is passed
  as argv, so it cannot introduce shell metacharacters.
- `Git.exec_shell` was the one remaining `/bin/sh -c` wrapper and had zero
  callers anywhere in the repository. Removed, with a note in `git/git.v`
  telling future authors to add an argument-array helper instead of a string
  one, so the footgun does not get reintroduced.
- `Repo.git()` guards against `&` and `;`, which suggests a shell is involved.
  It is not. Replaced with a comment stating the guard is not a security
  control, and naming the actual residual risk: argument injection. A ref
  beginning with `-` can still be read by git as an option, and `split_command`
  breaks a ref containing a space into several arguments. The callers that
  interpolate a caller-supplied ref are in `pr_routes.v` and `repo/branch.v`.
  Behaviour is deliberately unchanged here; fixing it means moving those to
  `Git.exec_in_dir_with_env` with a `--` separator, which is a larger change.

Private-repository visibility (vlang#247) is already enforced. `App.can_read_repo`
in `repo/repo.v` returns 404 for a private repo unless the requester is logged
in and has at least reporter access, and every repo-scoped read route calls it
before running a git command or returning repo data. `repo/repo_routes.v`
`tree()` is one of those callers. Regression tests already exist in
`api/endpoints_test.v` and `pr_authorization_test.v`.

SECURITY.md is new. The reporter of vlang#246 asked for a security policy in 2023
and the repository still has none, so this is the part of that report that is
still open. It covers private disclosure, the supported-versions position, the
scope of untrusted input, and the known boundaries that are deliberate design
decisions rather than oversights, so a future report can be triaged against
them instead of rediscovering them.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant