Files
kandev/apps/backend/internal/worktree/submodule_hardening_test.go
T
João Salavisa ac8401b709 fix: harden submodule init against malicious .gitmodules (#1802)
* fix: harden submodule init against malicious .gitmodules

initSubmodules ran a bare `git submodule update --init --recursive` in a
worktree whose HEAD is attacker-controlled during a fork-PR review task,
with no protocol restriction, no hooks disable, and no cmd.Env. Attacker
`.gitmodules` could pick a command-executing transport (`ext::sh -c`,
CVE-2018-17456), a local `file://` path, or a plain-http URL (cloud
metadata SSRF).

Harden the single sink so all call sites benefit:
- protocol.allow=never default-denies every transport; only https/ssh are
  re-enabled (blocks file://, git://, plain http:// SSRF).
- protocol.ext.allow=never hard-pins the RCE transport off; on the command
  line it outranks any ambient protocol.ext.allow=always.
- core.hooksPath=/dev/null neutralizes attacker-planted hooks.
- GIT_TERMINAL_PROMPT=0 stops attacker URLs from hanging on a prompt.

Residual: attacker-chosen https/ssh URLs are still fetched (transports real
submodules require); fully closing that needs gating submodule init off for
untrusted worktrees, tracked separately.

* fix: address submodule hardening review feedback

- Reuse newNonInteractiveGitCmd so submodule init inherits the full
  non-interactive env set (GIT_SSH_COMMAND=ssh -oBatchMode=yes, SSH/GCM
  askpass suppression, WaitDelay). Prevents an attacker ssh:// submodule
  URL from hanging the review runner on host-key/passphrase prompts until
  the context deadline (GIT_TERMINAL_PROMPT=0 alone only covers git's HTTP
  prompt, not SSH).
- Correct the file:// comment: command-line -c outranks repo/global git
  config, so ambient protocol.file.allow=always does NOT re-enable file
  submodules here. This is intentional for untrusted trees; a trusted
  opt-in must be an explicit per-repo -c, not implicit host config.
- Add a file:// URL variant to the transport-block test alongside the
  bare-path form (CVE-2022-39253 classification differs by git version).

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-07-19 16:12:56 +01:00

185 lines
6.9 KiB
Go

package worktree
import (
"context"
"fmt"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
)
// buildMaliciousRepo writes an attacker-controlled .gitmodules + gitlink,
// commits it, then creates a checked-out worktree pointing at that commit —
// exactly mirroring production's `git worktree add <path> <pr.HeadBranch>`
// during a fork-PR review task. The returned worktree path is what
// production passes to initSubmodules, so tests can drive the real sink.
func buildMaliciousRepo(t *testing.T, submoduleURL, submodulePath string) (worktreePath string) {
t.Helper()
repo := t.TempDir()
runGit(t, repo, "init", "-b", "main")
runGit(t, repo, "config", "user.email", "attacker@evil.example")
runGit(t, repo, "config", "user.name", "Attacker")
runGit(t, repo, "config", "commit.gpgsign", "false")
gitmodules := fmt.Sprintf("[submodule \"evil\"]\n\tpath = %s\n\turl = %s\n", submodulePath, submoduleURL)
if err := os.WriteFile(filepath.Join(repo, ".gitmodules"), []byte(gitmodules), 0644); err != nil {
t.Fatalf("write .gitmodules: %v", err)
}
runGit(t, repo, "add", ".gitmodules")
// Register a gitlink (mode 160000) at the submodule path so
// `git submodule update --init` treats it as a submodule to fetch. The
// commit hash need not exist remotely; the clone (the sink) is attempted
// before the checkout of the gitlink commit.
fakeCommit := "0000000000000000000000000000000000000001"
runGit(t, repo, "update-index", "--add", "--cacheinfo",
fmt.Sprintf("160000,%s,%s", fakeCommit, submodulePath))
runGit(t, repo, "commit", "-m", "totally normal PR, please review")
wt := filepath.Join(t.TempDir(), "review-wt")
runGit(t, repo, "worktree", "add", wt, "-b", "pr-head", "main")
return wt
}
// TestInitSubmodules_BlocksExtRCE asserts the ext:: command-executing
// transport can no longer execute, even when an ambient/global git config
// tries to re-enable it (the hardened command-line -c outranks it).
func TestInitSubmodules_BlocksExtRCE(t *testing.T) {
marker := filepath.Join(t.TempDir(), "pwned")
payload := "ext::sh -c touch% " + marker // CVE-2018-17456 arg encoding
wt := buildMaliciousRepo(t, payload, "evil")
// Simulate a host that would otherwise allow ext:: (older git, or an
// operator/global protocol.ext.allow=always). Pre-fix this would RCE.
t.Setenv("GIT_CONFIG_COUNT", "1")
t.Setenv("GIT_CONFIG_KEY_0", "protocol.ext.allow")
t.Setenv("GIT_CONFIG_VALUE_0", "always")
m := &Manager{logger: newTestLogger()}
m.initSubmodules(context.Background(), wt)
if _, err := os.Stat(marker); err == nil {
t.Fatalf("SECURITY REGRESSION: ext:: submodule executed attacker command (marker created)")
}
}
// TestInitSubmodules_BlocksFileTransport asserts local-path submodules are
// denied by default (no explicit per-repo opt-in), closing the local-path /
// path-traversal surface for untrusted trees. Exercises both the bare-path
// form and the explicit file:// URL form — the latter is the shape an
// attacker's .gitmodules is most likely to use, and on some git versions the
// two take different transport-classification code paths (CVE-2022-39253).
func TestInitSubmodules_BlocksFileTransport(t *testing.T) {
// A real local repo to point at; the point is that the transport is
// refused, not that the target is missing.
target := t.TempDir()
runGit(t, target, "init", "-b", "main")
runGit(t, target, "config", "user.email", "a@b.c")
runGit(t, target, "config", "user.name", "a")
runGit(t, target, "config", "commit.gpgsign", "false")
if err := os.WriteFile(filepath.Join(target, "x.txt"), []byte("x\n"), 0644); err != nil {
t.Fatal(err)
}
runGit(t, target, "add", ".")
runGit(t, target, "commit", "-m", "init")
for _, url := range []string{target, "file://" + target} {
wt := buildMaliciousRepo(t, url, "evil")
m := &Manager{logger: newTestLogger()}
m.initSubmodules(context.Background(), wt)
if _, err := os.Stat(filepath.Join(wt, "evil", "x.txt")); err == nil {
t.Fatalf("SECURITY REGRESSION: local submodule %q initialized despite protocol.allow=never", url)
}
}
}
// TestInitSubmodules_BlocksPlainHTTPFetch asserts that a plain http:// (not
// https) submodule URL — the classic cloud-metadata SSRF vector, e.g.
// http://169.254.169.254/… — is refused. Only https and ssh are allowlisted,
// so protocol.allow=never denies http. The pre-fix code fetched it (see the
// PoC report); this test locks the fix in.
//
// The remaining residual is an attacker-chosen *https* (or ssh) URL, which is
// the transport real submodules use and cannot be blanket-denied without also
// breaking legitimate submodules. Closing that for untrusted (fork-PR)
// worktrees requires gating submodule init off entirely — see the report.
func TestInitSubmodules_BlocksPlainHTTPFetch(t *testing.T) {
var hit int32
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.StoreInt32(&hit, 1)
w.WriteHeader(http.StatusNotFound)
})}
defer func() { _ = srv.Close() }()
go func() { _ = srv.Serve(ln) }()
url := fmt.Sprintf("http://%s/evil.git", ln.Addr().String())
wt := buildMaliciousRepo(t, url, "evil")
m := &Manager{logger: newTestLogger()}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
m.initSubmodules(ctx, wt)
time.Sleep(200 * time.Millisecond)
if atomic.LoadInt32(&hit) == 1 {
t.Fatalf("SECURITY REGRESSION: plain http:// submodule URL was fetched (SSRF); expected protocol.allow=never to block it")
}
}
// TestNewSubmoduleUpdateCmd_HasHardeningFlags asserts the sink command
// carries every hardening flag and env, at the sink so all call sites
// benefit. This is the structural guard against silent regressions.
func TestNewSubmoduleUpdateCmd_HasHardeningFlags(t *testing.T) {
dir := filepath.Join(t.TempDir(), "wt")
m := &Manager{logger: newTestLogger()}
cmd := m.newSubmoduleUpdateCmd(context.Background(), dir)
joined := strings.Join(cmd.Args, " ")
wantFlags := []string{
"-c protocol.allow=never",
"-c protocol.https.allow=always",
"-c protocol.ssh.allow=always",
"-c protocol.ext.allow=never",
"-c core.hooksPath=" + os.DevNull,
"submodule update --init --recursive",
}
for _, want := range wantFlags {
if !strings.Contains(joined, want) {
t.Errorf("submodule command missing %q; got: %s", want, joined)
}
}
// The full non-interactive env set must be present so an attacker ssh://
// (or http) submodule URL cannot hang the runner on a prompt.
wantEnv := []string{
"GIT_TERMINAL_PROMPT=0",
"GIT_SSH_COMMAND=ssh -oBatchMode=yes",
"SSH_ASKPASS=/bin/false",
"GCM_INTERACTIVE=Never",
}
haveEnv := make(map[string]bool, len(cmd.Env))
for _, e := range cmd.Env {
haveEnv[e] = true
}
for _, want := range wantEnv {
if !haveEnv[want] {
t.Errorf("submodule command env missing %q; got: %v", want, cmd.Env)
}
}
if cmd.Dir != dir {
t.Errorf("cmd.Dir = %q, want %q", cmd.Dir, dir)
}
}