Files
kandev/apps/backend/internal/worktree/submodule.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

110 lines
4.5 KiB
Go

package worktree
import (
"context"
"os"
"os/exec"
"strings"
"go.uber.org/zap"
)
// hardenedSubmoduleConfig are the `-c` git config overrides applied when
// initializing submodules. Submodule init parses .gitmodules from the
// worktree's HEAD, which for a fork-PR review task is fully
// attacker-controlled. A bare `git submodule update --init --recursive`
// would let that .gitmodules pick the transport git uses to fetch each
// submodule — including command-executing transports (`ext::sh -c …`,
// CVE-2018-17456) and local `file://` paths. These overrides lock the
// transport down before any attacker content is read:
//
// - protocol.allow=never default-deny every transport…
// - protocol.https.allow=always …then re-enable only https…
// - protocol.ssh.allow=always …and ssh (the transports real
// submodules use).
// - protocol.ext.allow=never hard-pin the command-executing
// transport off. Passed on the command
// line it outranks any ambient/global
// `protocol.ext.allow=always`, so it can
// never be re-enabled out from under us.
//
// file:// and git:// fall through to protocol.allow=never and are denied.
// Because these are command-line `-c` overrides, they outrank repo/global
// config files — so a host with `protocol.file.allow=always` in .git/config
// or ~/.gitconfig will NOT re-enable file submodules here. That is
// intentional: the worktree content is untrusted, so we do not want ambient
// config to widen the transport surface. If a Kandev deployment ever needs
// local-path submodules for trusted repos, that must be a deliberate
// per-repo opt-in that appends `-c protocol.file.allow=always`, not an
// implicit reliance on host git config.
var hardenedSubmoduleConfig = []string{
"protocol.allow=never",
"protocol.https.allow=always",
"protocol.ssh.allow=always",
"protocol.ext.allow=never",
// Neutralize attacker-planted hooks (CVE-2018-11235 / CVE-2024-32002
// class): point core.hooksPath at an empty location so no hook in the
// submodule's .git/modules tree can execute during checkout.
"core.hooksPath=" + os.DevNull,
}
// newSubmoduleUpdateCmd builds the hardened `git submodule update --init
// --recursive` command for dir. It reuses newNonInteractiveGitCmd so the
// submodule fetch inherits the full non-interactive env set (GIT_SSH_COMMAND
// with BatchMode, SSH/GCM askpass suppression, GIT_TERMINAL_PROMPT=0) and
// WaitDelay — critical because an attacker `ssh://…` submodule URL would
// otherwise hang the review runner on host-key/passphrase prompts until the
// context deadline. Kept separate from initSubmodules so tests can assert the
// hardening flags/env are present at the sink.
func (m *Manager) newSubmoduleUpdateCmd(ctx context.Context, dir string) *exec.Cmd {
args := make([]string, 0, len(hardenedSubmoduleConfig)*2+4)
for _, c := range hardenedSubmoduleConfig {
args = append(args, "-c", c)
}
args = append(args, "submodule", "update", "--init", "--recursive")
return m.newNonInteractiveGitCmd(ctx, dir, args...)
}
// getSubmodulePaths returns the paths of all submodules registered in HEAD.
// It reads from the git object store (git ls-tree), so it works in --no-checkout worktrees.
// Returns nil (not an error) if there are no submodules.
func getSubmodulePaths(ctx context.Context, dir string) ([]string, error) {
cmd := exec.CommandContext(ctx, "git", "ls-tree", "-r", "HEAD")
cmd.Dir = dir
output, err := runGitCmdOutput(ctx, cmd)
if err != nil {
return nil, err
}
var paths []string
for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") {
if line == "" {
continue
}
// Format: "<mode> <type> <hash>\t<path>"
// Submodules have mode 160000 and type "commit".
if strings.HasPrefix(line, "160000 ") {
if _, path, ok := strings.Cut(line, "\t"); ok {
paths = append(paths, path)
}
}
}
return paths, nil
}
// initSubmodules runs "git submodule update --init --recursive" in the given directory.
// Failures are non-fatal: submodule URLs may be unreachable (private repos,
// missing credentials), but the worktree is still usable for non-submodule files.
func (m *Manager) initSubmodules(ctx context.Context, dir string) {
cmd := m.newSubmoduleUpdateCmd(ctx, dir)
output, err := runGitCmdCombinedOutput(ctx, cmd)
if err != nil {
m.logger.Warn("git submodule update --init failed (non-fatal)",
zap.String("dir", dir),
zap.String("output", string(output)),
zap.Error(err))
return
}
m.logger.Debug("initialized submodules in worktree", zap.String("dir", dir))
}