diff -urN b/creds/creds.go a/creds/creds.go --- b/creds/creds.go 2023-12-13 19:56:25.000000000 +0100 +++ a/creds/creds.go 2025-01-17 08:55:10.175959181 +0100 @@ -53,11 +53,20 @@ // as input. type Creds map[string][]string -func bufferCreds(c Creds) *bytes.Buffer { +func (c Creds) buffer(protectProtocol bool) (*bytes.Buffer, error) { buf := new(bytes.Buffer) for k, v := range c { for _, item := range v { + if strings.Contains(item, "\n") { + return nil, errors.Errorf(tr.Tr.Get("credential value for %s contains newline: %q", k, item)) + } + if protectProtocol && strings.Contains(item, "\r") { + return nil, errors.Errorf(tr.Tr.Get("credential value for %s contains carriage return: %q\nIf this is intended, set `credential.protectProtocol=false`", k, item)) + } + if strings.Contains(item, string(rune(0))) { + return nil, errors.Errorf(tr.Tr.Get("credential value for %s contains null byte: %q", k, item)) + } buf.Write([]byte(k)) buf.Write([]byte("=")) buf.Write([]byte(item)) @@ -65,7 +74,7 @@ } } - return buf + return buf, nil } type CredentialHelperContext struct { @@ -153,6 +162,9 @@ helpers = append(helpers, ctxt.askpassCredHelper) } } + + ctxt.commandCredHelper.protectProtocol = ctxt.urlConfig.Bool("credential", rawurl, "protectProtocol", true) + return CredentialHelperWrapper{CredentialHelper: NewCredentialHelpers(append(helpers, ctxt.commandCredHelper)), Input: input, Url: u} } @@ -292,7 +304,8 @@ } type commandCredentialHelper struct { - SkipPrompt bool + SkipPrompt bool + protectProtocol bool } func (h *commandCredentialHelper) Fill(creds Creds) (Creds, error) { @@ -323,7 +336,10 @@ if err != nil { return nil, errors.New(tr.Tr.Get("failed to find `git credential %s`: %v", subcommand, err)) } - cmd.Stdin = bufferCreds(input) + cmd.Stdin, err = input.buffer(h.protectProtocol) + if err != nil { + return nil, errors.New(tr.Tr.Get("invalid input to `git credential %s`: %v", subcommand, err)) + } cmd.Stdout = output /* There is a reason we don't read from stderr here: diff -urN b/creds/creds_test.go a/creds/creds_test.go --- b/creds/creds_test.go 2023-12-13 19:56:25.000000000 +0100 +++ a/creds/creds_test.go 2025-01-17 08:55:21.318023782 +0100 @@ -1,12 +1,89 @@ package creds import ( + "bytes" "errors" + "slices" + "strings" "testing" "github.com/stretchr/testify/assert" ) +func assertCredsLinesMatch(t *testing.T, expected []string, buf *bytes.Buffer) { + actual := strings.SplitAfter(buf.String(), "\n") + + slices.Sort(expected) + slices.Sort(actual) + + assert.Equal(t, expected, actual) +} +func TestCredsBufferFormat(t *testing.T) { + creds := make(Creds) + + expected := []string{""} + + buf, err := creds.buffer(true) + assert.NoError(t, err) + assertCredsLinesMatch(t, expected, buf) + + creds["protocol"] = []string{"https"} + creds["host"] = []string{"example.com"} + + expected = []string{"host=example.com\n", "protocol=https\n", ""} + + buf, err = creds.buffer(true) + assert.NoError(t, err) + assertCredsLinesMatch(t, expected, buf) + + creds["wwwauth[]"] = []string{"Basic realm=test", "Negotiate"} + + expected = append(expected, "wwwauth[]=Basic realm=test\n", "wwwauth[]=Negotiate\n") + buf, err = creds.buffer(true) + assert.NoError(t, err) + assertCredsLinesMatch(t, expected, buf) +} + +func TestCredsBufferProtect(t *testing.T) { + creds := make(Creds) + + // Always disallow LF characters + creds["protocol"] = []string{"https"} + creds["host"] = []string{"one.example.com\nhost=two.example.com"} + + buf, err := creds.buffer(false) + assert.Error(t, err) + assert.Nil(t, buf) + + buf, err = creds.buffer(true) + assert.Error(t, err) + assert.Nil(t, buf) + + // Disallow CR characters unless protocol protection disabled + creds["host"] = []string{"one.example.com\rhost=two.example.com"} + + expected := []string{"", "protocol=https\n", "host=one.example.com\rhost=two.example.com\n"} + + buf, err = creds.buffer(false) + assert.NoError(t, err) + assertCredsLinesMatch(t, expected, buf) + + buf, err = creds.buffer(true) + assert.Error(t, err) + assert.Nil(t, buf) + + // Always disallow null bytes + creds["host"] = []string{"one.example.com\x00host=two.example.com"} + + buf, err = creds.buffer(false) + assert.Error(t, err) + assert.Nil(t, buf) + + buf, err = creds.buffer(true) + assert.Error(t, err) + assert.Nil(t, buf) +} + type testCredHelper struct { fillErr error approveErr error diff -urN b/t/cmd/lfstest-gitserver.go a/t/cmd/lfstest-gitserver.go --- b/t/cmd/lfstest-gitserver.go 2023-12-13 19:56:25.000000000 +0100 +++ a/t/cmd/lfstest-gitserver.go 2025-01-16 14:33:23.825991696 +0100 @@ -27,6 +27,7 @@ "net/http" "net/http/httptest" "net/textproto" + "net/url" "os" "os/exec" "regexp" @@ -252,6 +253,7 @@ } func lfsUrl(repo, oid string, redirect bool) string { + repo = url.QueryEscape(repo) if redirect { return server.URL + "/redirect307/objects/" + oid + "?r=" + repo } diff -urN b/t/t-credentials-protect.sh a/t/t-credentials-protect.sh --- b/t/t-credentials-protect.sh 1970-01-01 01:00:00.000000000 +0100 +++ a/t/t-credentials-protect.sh 2025-01-16 14:03:23.597029590 +0100 @@ -0,0 +1,146 @@ +#!/usr/bin/env bash + +. "$(dirname "$0")/testlib.sh" + +ensure_git_version_isnt $VERSION_LOWER "2.3.0" + +export CREDSDIR="$REMOTEDIR/creds-credentials-protect" +setup_creds + +# Copy the default record file for the test credential helper to match the +# hostname used in the Git LFS configurations of the tests. +cp "$CREDSDIR/127.0.0.1" "$CREDSDIR/localhost" + +begin_test "credentials rejected with line feed" +( + set -e + + reponame="protect-linefeed" + setup_remote_repo "$reponame" + clone_repo "$reponame" "$reponame" + + contents="a" + contents_oid=$(calc_oid "$contents") + + git lfs track "*.dat" + printf "%s" "$contents" >a.dat + git add .gitattributes a.dat + git commit -m "add a.dat" + + # Using localhost instead of 127.0.0.1 in the LFS API URL ensures this URL + # is used when filling credentials rather than the Git remote URL, which + # would otherwise be used since it would have the same scheme and hostname. + gitserver="$(echo "$GITSERVER" | sed 's/127\.0\.0\.1/localhost/')" + testreponame="test%0a$reponame" + git config lfs.url "$gitserver/$testreponame.git/info/lfs" + + GIT_TRACE=1 git lfs push origin main 2>&1 | tee push.log + if [ "0" -eq "${PIPESTATUS[0]}" ]; then + echo >&2 "fatal: expected 'git lfs push' to fail ..." + exit 1 + fi + grep "batch response: Git credentials for $gitserver.* not found" push.log + grep "credential value for path contains newline" push.log + refute_server_object "$testreponame" "$contents_oid" + + git config credential.protectProtocol false + + GIT_TRACE=1 git lfs push origin main 2>&1 | tee push.log + if [ "0" -eq "${PIPESTATUS[0]}" ]; then + echo >&2 "fatal: expected 'git lfs push' to fail ..." + exit 1 + fi + grep "batch response: Git credentials for $gitserver.* not found" push.log + grep "credential value for path contains newline" push.log + refute_server_object "$testreponame" "$contents_oid" +) +end_test + +begin_test "credentials rejected with carriage return" +( + set -e + + reponame="protect-return" + setup_remote_repo "$reponame" + clone_repo "$reponame" "$reponame" + + contents="a" + contents_oid=$(calc_oid "$contents") + + git lfs track "*.dat" + printf "%s" "$contents" >a.dat + git add .gitattributes a.dat + git commit -m "add a.dat" + + # Using localhost instead of 127.0.0.1 in the LFS API URL ensures this URL + # is used when filling credentials rather than the Git remote URL, which + # would otherwise be used since it would have the same scheme and hostname. + gitserver="$(echo "$GITSERVER" | sed 's/127\.0\.0\.1/localhost/')" + testreponame="test%0d$reponame" + git config lfs.url "$gitserver/$testreponame.git/info/lfs" + + GIT_TRACE=1 git lfs push origin main 2>&1 | tee push.log + if [ "0" -eq "${PIPESTATUS[0]}" ]; then + echo >&2 "fatal: expected 'git lfs push' to fail ..." + exit 1 + fi + grep "batch response: Git credentials for $gitserver.* not found" push.log + grep "credential value for path contains carriage return" push.log + refute_server_object "$testreponame" "$contents_oid" + + git config credential.protectProtocol false + + git lfs push origin main 2>&1 | tee push.log + if [ "0" -ne "${PIPESTATUS[0]}" ]; then + echo >&2 "fatal: expected 'git lfs push' to succeed ..." + exit 1 + fi + [ $(grep -c "Uploading LFS objects: 100% (1/1)" push.log) -eq 1 ] + assert_server_object "$testreponame" "$contents_oid" +) +end_test + +begin_test "credentials rejected with null byte" +( + set -e + + reponame="protect-null" + setup_remote_repo "$reponame" + clone_repo "$reponame" "$reponame" + + contents="a" + contents_oid=$(calc_oid "$contents") + + git lfs track "*.dat" + printf "%s" "$contents" >a.dat + git add .gitattributes a.dat + git commit -m "add a.dat" + + # Using localhost instead of 127.0.0.1 in the LFS API URL ensures this URL + # is used when filling credentials rather than the Git remote URL, which + # would otherwise be used since it would have the same scheme and hostname. + gitserver="$(echo "$GITSERVER" | sed 's/127\.0\.0\.1/localhost/')" + testreponame="test%00$reponame" + git config lfs.url "$gitserver/$testreponame.git/info/lfs" + + GIT_TRACE=1 git lfs push origin main 2>&1 | tee push.log + if [ "0" -eq "${PIPESTATUS[0]}" ]; then + echo >&2 "fatal: expected 'git lfs push' to fail ..." + exit 1 + fi + grep "batch response: Git credentials for $gitserver.* not found" push.log + grep "credential value for path contains null byte" push.log + refute_server_object "$testreponame" "$contents_oid" + + git config credential.protectProtocol false + + GIT_TRACE=1 git lfs push origin main 2>&1 | tee push.log + if [ "0" -eq "${PIPESTATUS[0]}" ]; then + echo >&2 "fatal: expected 'git lfs push' to fail ..." + exit 1 + fi + grep "batch response: Git credentials for $gitserver.* not found" push.log + grep "credential value for path contains null byte" push.log + refute_server_object "$testreponame" "$contents_oid" +) +end_test diff -urN b/t/testhelpers.sh a/t/testhelpers.sh --- b/t/testhelpers.sh 2023-12-13 19:56:25.000000000 +0100 +++ a/t/testhelpers.sh 2025-01-16 14:15:19.240279305 +0100 @@ -557,6 +557,14 @@ fi } + +setup_creds() { + mkdir -p "$CREDSDIR" + write_creds_file "user:pass" "$CREDSDIR/127.0.0.1" + write_creds_file ":pass" "$CREDSDIR/--$certpath" + write_creds_file ":pass" "$CREDSDIR/--$keypath" +} + # setup initializes the clean, isolated environment for integration tests. setup() { cd "$ROOTDIR" @@ -613,10 +621,7 @@ # setup the git credential password storage local certpath="$(echo "$LFS_CLIENT_CERT_FILE" | tr / -)" local keypath="$(echo "$LFS_CLIENT_KEY_FILE_ENCRYPTED" | tr / -)" - mkdir -p "$CREDSDIR" - write_creds_file "user:pass" "$CREDSDIR/127.0.0.1" - write_creds_file ":pass" "$CREDSDIR/--$certpath" - write_creds_file ":pass" "$CREDSDIR/--$keypath" + setup_creds echo "#" echo "# HOME: $HOME"