Skip to content

Commit

Permalink
creds/creds.go: reject CR bytes in credential data
Browse files Browse the repository at this point in the history
As reported in CVE-2024-52006, when Git writes credential request keys
and values to a Git credential helper, it does not remove any bare
carriage return (CR) control characters included in the values, which
may allow a malicious server to insert spurious keys and values into
the credential request if the helper happens to parse bare CR characters
as line delimiters.

A similar problem due to bare line feed (LF) characters in credential
request values was reported in CVE-2020-5260, and was addressed in Git by
revising the credential_write_item() function of the credential.c source
file to check for line feed characters before writing a value.  This
change from commit git/git@9a6bbee was
then released in Git version 2.17.4 (and other patch releases):

  https://github.com/git/git/blob/v2.47.1/credential.c#L391-L392

Because some credential helpers may also accept bare carriage return
characters as line delimiters, future versions of Git will now also
check for those in the credential_write_item() function.  However, this
behaviour may be disabled by setting a new "credential.protectProtocol"
Git configuration option to a value equivalent to "false".

Because Git LFS may be used with older versions of Git which do not have
this additional protection, we can not assume that the git-credential(1)
command we execute will, when appropriate, detect and reject bare
carriage return characters in the values we send to it.

We therefore adopt similar measures as future versions of Git will take,
and revise the buffer() method of the Creds structure in our "creds"
package (which we refactored from a bufferCreds() function in a prior
commit in this PR) so it returns an error if any of the values it
processes have an embedded carriage return character, unless the
"credential.protectProtocol" Git configuration option is set to a value
equivalent to "false".

To support the new Git configuration option we add a protectProtocol
field to our commandCredentialHelper structure and populate it from
the value of the "credential.protectProtocol" option (which may also
be scoped to a specific URL as a "credential.<url>.protectProtocol"
option).  The protectProtocol field defaults to "true", which aligns
with how Git will implement this new option.

We then pass the protectProtocol field's value as a new, additional
argument to the buffer() method of the Creds structure.  If the value
of the argument is "true", the method checks for carriage return
characters in each value it will write to its buffer.

To verify these changes we expand the TestCredsBufferProtect() test
function, which we added in a prior commit in this PR, so that it
checks three additional conditions.  First, it tests whether the
buffer() method always returns an error when one of the values passed
to it contains a line feed character, even if the method's new
protectProtocol argument is set to "false".  Next, the test function
checks that when the protectProtocol argument is set to "true" the
buffer() method returns an error if any of the values it is given
contain a carriage return character.  Last, the test function confirms
that if the protectProtocol argument is set to "false" then the buffer()
method will not return an error, regardless of whether one of the
values passed to it contains a carriage return character.

We also add a new "credentials rejected with carriage return" test to
our t/t-credentials-protect.sh test script, and expand the "credentials
rejected with line feed" test that we introduced with that script in a
prior commit in this PR.

In the case of the existing test from that prior commit, we now verify
that even when the "credential.protectProtocol" Git configuration option
is set to "false", an error is reported and no Git LFS object is pushed
if a value which would be passed to a "git credential fill" command
contains a bare line feed character.

In our new "credentials rejected with carriage return" test we first
perform a check similar to the initial one made by the "credentials
rejected with line feed" test.  This check confirms that in the default
case an error is reported and no Git LFS object is pushed if a value
which would be passed to a "git credential fill" command contains a
bare carriage return character.

Unlike the test for embedded line feed characters, though, our new test
then checks that when the "credential.protectProtocol" option is set
to "false", no error is reported and a push of a Git LFS object succeeds
despite the presence of a bare carriage return character in the values
passed to the "git credential fill" command.

As with the existing "credentials rejected with line feed" test, in our
new test we again use the "localhost" hostname in the URL which contains
a URL-encoded control character.  If we used the 127.0.0.1 address on
which our lfstest-gitserver listens for requests, then Git LFS would
detect that the scheme and the host subcomponent of the URL for the Git
LFS API matched those of the URL of the current Git remote, and would
then use the Git remote's URL when querying for credentials.  As that
URL does not contain a URL-encoded carriage return character, our test
would not achieve its purpose, so we instead use the "localhost" alias
in the test's Git LFS configuration.

Because we make use of the "localhost" hostname, our new test's
final check will only succeed in pushing a Git LFS object if our
git-credential-lfstest helper program can locate the appropriate
credentials.  Therefore, unlike the existing "credentials rejected
with line feed" test, a credential record file associated with the
"localhost" hostname must exist.

As explained in the previous commit where we added that test, though,
it is partly for this reason that the t/t-credentials-protect.sh test
script already copies the record file associated with the 127.0.0.1
address to create a record file for the "localhost" hostname, before
running any tests.

The presence of this record file also helps ensure that our tests will
fail in the case of a future regression in the checks made by the
buffer() method for bare line feed or carriage return characters.

Finally, we have to make a small change to our lfstest-gitserver test
utility in order for our new test's final check to succeed.  The
lfstest-gitserver program provides a basic implementation of the Git LFS
Batch API, and when the Git LFS client sends an initial request to it
to upload an object, the program replies with a JSON response that
includes the URL to be used to transfer the object's data.

These per-object URLs are generated by the lfstest-gitserver program's
internal lfsUrl() function, which creates a URL that includes an "r"
query parameter whose value is the set of URL path segments from the
initial Batch API request.  However, the lfsUrl() function does not
URL-encode the "r" query parameter's value, which has been URL-decoded
from the URL of the original API request.

Because our test makes a request to the Git LFS API with a URL-encoded
carriage return character in the URL path of the request, without any
changes to the lfsUrl() function the per-object URL the client receives
back would contain a bare carriage return character.  (Technically,
the character would be formatted in the JSON response as a "\r" escape
sequence, which would then be interpreted as a carriage return character
when the JSON was parsed by the Git LFS client.)  The per-object URL
would then in turn be rejected by the Parse() function of the standard
Go library's "net/url" package as containing a bare control character,
resulting in an error that would prevent the Git LFS client from
completing the transfer of the object.

To avoid this problem, we simply revise the lfsUrl() function of our
lfstest-gitserver test utility to correctly URL-encode the value it
sets for the "r" query parameter.
  • Loading branch information
chrisd8088 committed Dec 3, 2024
1 parent 0345b6f commit 4423696
Show file tree
Hide file tree
Showing 4 changed files with 93 additions and 7 deletions.
13 changes: 10 additions & 3 deletions creds/creds.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ func (c Creds) IsMultistage() bool {
return slices.Contains([]string{"1", "true"}, FirstEntryForKey(c, "continue"))
}

func (c Creds) buffer() (*bytes.Buffer, error) {
func (c Creds) buffer(protectProtocol bool) (*bytes.Buffer, error) {
buf := new(bytes.Buffer)

buf.Write([]byte("capability[]=authtype\n"))
Expand All @@ -68,6 +68,9 @@ func (c Creds) buffer() (*bytes.Buffer, error) {
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))
}

buf.Write([]byte(k))
buf.Write([]byte("="))
Expand Down Expand Up @@ -172,6 +175,9 @@ func (ctxt *CredentialHelperContext) GetCredentialHelper(helper CredentialHelper
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}
}

Expand Down Expand Up @@ -311,7 +317,8 @@ func (a *AskPassCredentialHelper) args(prompt string) []string {
}

type commandCredentialHelper struct {
SkipPrompt bool
SkipPrompt bool
protectProtocol bool
}

func (h *commandCredentialHelper) Fill(creds Creds) (Creds, error) {
Expand Down Expand Up @@ -342,7 +349,7 @@ func (h *commandCredentialHelper) exec(subcommand string, input Creds) (Creds, e
if err != nil {
return nil, errors.New(tr.Tr.Get("failed to find `git credential %s`: %v", subcommand, err))
}
cmd.Stdin, err = input.buffer()
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))
}
Expand Down
30 changes: 26 additions & 4 deletions creds/creds_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ func TestCredsBufferFormat(t *testing.T) {

expected := []string{"capability[]=authtype\n", "capability[]=state\n"}

buf, err := creds.buffer()
buf, err := creds.buffer(true)
assert.NoError(t, err)
assertCredsLinesMatch(t, expected, buf)

Expand All @@ -35,7 +35,7 @@ func TestCredsBufferFormat(t *testing.T) {
expectedPrefix := strings.Join(expected, "")
expected = append(expected, "protocol=https\n", "host=example.com\n")

buf, err = creds.buffer()
buf, err = creds.buffer(true)
assert.NoError(t, err)
assert.True(t, strings.HasPrefix(buf.String(), expectedPrefix))
assertCredsLinesMatch(t, expected, buf)
Expand All @@ -45,7 +45,7 @@ func TestCredsBufferFormat(t *testing.T) {
expected = append(expected, "wwwauth[]=Basic realm=test\n")
expected = append(expected, "wwwauth[]=Negotiate\n")

buf, err = creds.buffer()
buf, err = creds.buffer(true)
assert.NoError(t, err)
assert.True(t, strings.HasPrefix(buf.String(), expectedPrefix))
assertCredsLinesMatch(t, expected, buf)
Expand All @@ -58,7 +58,29 @@ func TestCredsBufferProtect(t *testing.T) {
creds["protocol"] = []string{"https"}
creds["host"] = []string{"one.example.com\nhost=two.example.com"}

buf, err := creds.buffer()
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{
"capability[]=authtype\n",
"capability[]=state\n",
"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)
}
Expand Down
2 changes: 2 additions & 0 deletions t/cmd/lfstest-gitserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (
"net/http"
"net/http/httptest"
"net/textproto"
"net/url"
"os"
"os/exec"
"regexp"
Expand Down Expand Up @@ -257,6 +258,7 @@ func lfsHandler(w http.ResponseWriter, r *http.Request, id string) {
}

func lfsUrl(repo, oid string, redirect bool) string {
repo = url.QueryEscape(repo)
if redirect {
return server.URL + "/redirect307/objects/" + oid + "?r=" + repo
}
Expand Down
55 changes: 55 additions & 0 deletions t/t-credentials-protect.sh
Original file line number Diff line number Diff line change
Expand Up @@ -42,5 +42,60 @@ begin_test "credentials rejected with line feed"
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

0 comments on commit 4423696

Please sign in to comment.