Skip to content

Commit 24989c8

Browse files
committed
fix: credential helper fallback for git < 2.46
Git versions before 2.46 (including Azure Linux 3's 2.45.4 and Ubuntu Noble's 2.43) do not announce capability[]=authtype. The credential helper was unconditionally using authtype/credential (v2 protocol), which git silently discarded, causing authentication failures. Detect whether git announced the authtype capability and fall back to username/password for pre-2.46 git. For header auth with non-Basic types (e.g., Bearer), return an error since there is no v1 equivalent. Also fix readPayload to handle blank lines (input termination) and unknown keys (silent discard) per the git credential protocol spec.
1 parent 728ffdb commit 24989c8

File tree

2 files changed

+320
-11
lines changed

2 files changed

+320
-11
lines changed

cmd/frontend/git_credential_helper.go

Lines changed: 43 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"io"
1010
"os"
1111
"path/filepath"
12+
"slices"
1213
"strings"
1314
)
1415

@@ -142,10 +143,17 @@ func readPayload(r io.Reader) gitPayload {
142143
var payload gitPayload
143144
for sc.Scan() {
144145
line := sc.Text()
145-
k, v, ok := strings.Cut(line, "=")
146146

147+
// A blank line terminates the credential protocol input.
148+
if line == "" {
149+
break
150+
}
151+
152+
k, v, ok := strings.Cut(line, "=")
147153
if !ok {
148-
exit1("improper payload from git")
154+
// Per the git credential protocol, lines without '=' are malformed.
155+
// Skip them gracefully rather than crashing.
156+
continue
149157
}
150158

151159
switch k {
@@ -180,7 +188,9 @@ func readPayload(r io.Reader) gitPayload {
180188
case keyStateArr:
181189
payload.state = append(payload.state, v)
182190
default:
183-
exit1(fmt.Sprintf("unknown key: %q", k))
191+
// Per the git credential protocol spec, unrecognized
192+
// attributes should be silently discarded.
193+
continue
184194
}
185195
}
186196

@@ -241,19 +251,41 @@ func handleSecretHeader(b []byte, payload *gitPayload) (string, error) {
241251
return "", fmt.Errorf("improperly formatted auth header")
242252
}
243253

244-
payload.authtype = authtype
245-
payload.credential = credential
254+
if slices.Contains(payload.capability, "authtype") {
255+
payload.authtype = authtype
256+
payload.credential = credential
257+
} else if strings.EqualFold(authtype, authTypeBasic) {
258+
// Pre-2.46 git does not support authtype/credential protocol fields.
259+
// For Basic auth, we can decode the credential and use username/password.
260+
decoded, err := base64.StdEncoding.DecodeString(credential)
261+
if err != nil {
262+
return "", fmt.Errorf("could not decode basic auth credential: %w", err)
263+
}
264+
user, pass, _ := strings.Cut(string(decoded), ":")
265+
payload.username = user
266+
payload.password = pass
267+
} else {
268+
// Non-basic auth (Bearer, etc.) requires git 2.46+ credential protocol v2.
269+
return "", fmt.Errorf("header auth type %q requires git 2.46+ (capability authtype not announced by git)", authtype)
270+
}
246271

247272
return printPayload(payload), nil
248273
}
249274

250275
func handleSecretToken(token []byte, payload *gitPayload) (string, error) {
251-
var buf bytes.Buffer
252-
buf.WriteString("x-access-token:")
253-
buf.Write(token)
254-
255-
payload.authtype = authTypeBasic
256-
payload.credential = base64.StdEncoding.EncodeToString(buf.Bytes())
276+
if slices.Contains(payload.capability, "authtype") {
277+
var buf bytes.Buffer
278+
buf.WriteString("x-access-token:")
279+
buf.Write(token)
280+
281+
payload.authtype = authTypeBasic
282+
payload.credential = base64.StdEncoding.EncodeToString(buf.Bytes())
283+
} else {
284+
// Pre-2.46 git does not support authtype/credential protocol fields.
285+
// Fall back to username/password which maps to HTTP Basic auth.
286+
payload.username = "x-access-token"
287+
payload.password = string(token)
288+
}
257289

258290
return printPayload(payload), nil
259291
}
Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
package main
2+
3+
import (
4+
"encoding/base64"
5+
"strings"
6+
"testing"
7+
)
8+
9+
func TestHandleSecretToken(t *testing.T) {
10+
t.Run("without authtype capability", func(t *testing.T) {
11+
// Pre-2.46 git does not announce capability[]=authtype.
12+
// The credential helper must respond with username/password,
13+
// otherwise git silently discards the response and auth fails.
14+
payload := &gitPayload{
15+
protocol: "https",
16+
host: "github.com",
17+
}
18+
19+
resp, err := handleSecretToken([]byte("ghp_abc123"), payload)
20+
if err != nil {
21+
t.Fatalf("unexpected error: %v", err)
22+
}
23+
24+
assertFieldEquals(t, resp, keyUsername, "x-access-token")
25+
assertFieldEquals(t, resp, keyPassword, "ghp_abc123")
26+
assertFieldAbsent(t, resp, keyAuthtype)
27+
assertFieldAbsent(t, resp, keyCredential)
28+
})
29+
30+
t.Run("with authtype capability", func(t *testing.T) {
31+
// Git 2.46+ announces capability[]=authtype.
32+
// The credential helper should use the v2 protocol fields.
33+
payload := &gitPayload{
34+
protocol: "https",
35+
host: "github.com",
36+
capability: []string{"authtype"},
37+
}
38+
39+
resp, err := handleSecretToken([]byte("ghp_abc123"), payload)
40+
if err != nil {
41+
t.Fatalf("unexpected error: %v", err)
42+
}
43+
44+
assertFieldEquals(t, resp, keyAuthtype, authTypeBasic)
45+
46+
expectedCred := base64.StdEncoding.EncodeToString([]byte("x-access-token:ghp_abc123"))
47+
assertFieldEquals(t, resp, keyCredential, expectedCred)
48+
assertFieldAbsent(t, resp, keyUsername)
49+
assertFieldAbsent(t, resp, keyPassword)
50+
})
51+
}
52+
53+
func TestHandleSecretHeader(t *testing.T) {
54+
t.Run("without authtype capability bearer", func(t *testing.T) {
55+
// Bearer tokens cannot be expressed via username/password.
56+
// On pre-2.46 git, this should return an error since there's
57+
// no way to pass an arbitrary Authorization header.
58+
payload := &gitPayload{
59+
protocol: "https",
60+
host: "github.com",
61+
}
62+
63+
_, err := handleSecretHeader([]byte("Bearer eyJhbGciOi"), payload)
64+
if err == nil {
65+
t.Fatal("expected error for Bearer auth without authtype capability, got nil")
66+
}
67+
})
68+
69+
t.Run("without authtype capability basic", func(t *testing.T) {
70+
// Basic auth headers can be decoded into username/password
71+
// for pre-2.46 git compatibility.
72+
payload := &gitPayload{
73+
protocol: "https",
74+
host: "github.com",
75+
}
76+
77+
cred := base64.StdEncoding.EncodeToString([]byte("myuser:mypass"))
78+
resp, err := handleSecretHeader([]byte("Basic "+cred), payload)
79+
if err != nil {
80+
t.Fatalf("unexpected error: %v", err)
81+
}
82+
83+
assertFieldEquals(t, resp, keyUsername, "myuser")
84+
assertFieldEquals(t, resp, keyPassword, "mypass")
85+
assertFieldAbsent(t, resp, keyAuthtype)
86+
assertFieldAbsent(t, resp, keyCredential)
87+
})
88+
89+
t.Run("with authtype capability", func(t *testing.T) {
90+
// Git 2.46+ supports authtype/credential for arbitrary auth schemes.
91+
payload := &gitPayload{
92+
protocol: "https",
93+
host: "github.com",
94+
capability: []string{"authtype"},
95+
}
96+
97+
resp, err := handleSecretHeader([]byte("Bearer eyJhbGciOi"), payload)
98+
if err != nil {
99+
t.Fatalf("unexpected error: %v", err)
100+
}
101+
102+
assertFieldEquals(t, resp, keyAuthtype, "Bearer")
103+
assertFieldEquals(t, resp, keyCredential, "eyJhbGciOi")
104+
assertFieldAbsent(t, resp, keyUsername)
105+
assertFieldAbsent(t, resp, keyPassword)
106+
})
107+
108+
t.Run("malformed header", func(t *testing.T) {
109+
payload := &gitPayload{
110+
protocol: "https",
111+
host: "github.com",
112+
capability: []string{"authtype"},
113+
}
114+
115+
_, err := handleSecretHeader([]byte("nospaceshere"), payload)
116+
if err == nil {
117+
t.Fatal("expected error for malformed header, got nil")
118+
}
119+
})
120+
}
121+
122+
func TestReadPayload(t *testing.T) {
123+
t.Run("valid input", func(t *testing.T) {
124+
input := "protocol=https\nhost=github.com\npath=foo/bar\n"
125+
payload := readPayload(strings.NewReader(input))
126+
127+
if payload.protocol != "https" {
128+
t.Errorf("expected protocol 'https', got %q", payload.protocol)
129+
}
130+
if payload.host != "github.com" {
131+
t.Errorf("expected host 'github.com', got %q", payload.host)
132+
}
133+
if payload.path != "foo/bar" {
134+
t.Errorf("expected path 'foo/bar', got %q", payload.path)
135+
}
136+
})
137+
138+
t.Run("with capabilities", func(t *testing.T) {
139+
input := "protocol=https\nhost=github.com\ncapability[]=authtype\ncapability[]=state\n"
140+
payload := readPayload(strings.NewReader(input))
141+
142+
if len(payload.capability) != 2 {
143+
t.Fatalf("expected 2 capabilities, got %d", len(payload.capability))
144+
}
145+
if payload.capability[0] != "authtype" {
146+
t.Errorf("expected capability 'authtype', got %q", payload.capability[0])
147+
}
148+
if payload.capability[1] != "state" {
149+
t.Errorf("expected capability 'state', got %q", payload.capability[1])
150+
}
151+
})
152+
153+
t.Run("unknown keys are skipped", func(t *testing.T) {
154+
// Per git credential protocol spec, unrecognized attributes
155+
// should be silently discarded.
156+
input := "protocol=https\nfuture_key=some_value\nhost=github.com\n"
157+
payload := readPayload(strings.NewReader(input))
158+
159+
if payload.protocol != "https" {
160+
t.Errorf("expected protocol 'https', got %q", payload.protocol)
161+
}
162+
if payload.host != "github.com" {
163+
t.Errorf("expected host 'github.com', got %q", payload.host)
164+
}
165+
})
166+
167+
t.Run("blank line terminates input", func(t *testing.T) {
168+
// A blank line terminates the credential protocol input.
169+
input := "protocol=https\nhost=github.com\n\npath=should-not-be-read\n"
170+
payload := readPayload(strings.NewReader(input))
171+
172+
if payload.protocol != "https" {
173+
t.Errorf("expected protocol 'https', got %q", payload.protocol)
174+
}
175+
if payload.host != "github.com" {
176+
t.Errorf("expected host 'github.com', got %q", payload.host)
177+
}
178+
if payload.path != "" {
179+
t.Errorf("expected path to be empty (after blank line), got %q", payload.path)
180+
}
181+
})
182+
}
183+
184+
func TestGenerateResponse(t *testing.T) {
185+
t.Run("token without capability", func(t *testing.T) {
186+
payload := &gitPayload{
187+
protocol: "https",
188+
host: "github.com",
189+
}
190+
191+
resp, err := generateResponse(payload, []byte("my-secret-token"), kindToken)
192+
if err != nil {
193+
t.Fatalf("unexpected error: %v", err)
194+
}
195+
196+
assertFieldEquals(t, resp, keyUsername, "x-access-token")
197+
assertFieldEquals(t, resp, keyPassword, "my-secret-token")
198+
})
199+
200+
t.Run("token with capability", func(t *testing.T) {
201+
payload := &gitPayload{
202+
protocol: "https",
203+
host: "github.com",
204+
capability: []string{"authtype"},
205+
}
206+
207+
resp, err := generateResponse(payload, []byte("my-secret-token"), kindToken)
208+
if err != nil {
209+
t.Fatalf("unexpected error: %v", err)
210+
}
211+
212+
assertFieldEquals(t, resp, keyAuthtype, authTypeBasic)
213+
expectedCred := base64.StdEncoding.EncodeToString([]byte("x-access-token:my-secret-token"))
214+
assertFieldEquals(t, resp, keyCredential, expectedCred)
215+
})
216+
217+
t.Run("header with capability", func(t *testing.T) {
218+
payload := &gitPayload{
219+
protocol: "https",
220+
host: "github.com",
221+
capability: []string{"authtype"},
222+
}
223+
224+
resp, err := generateResponse(payload, []byte("Bearer abc123"), kindHeader)
225+
if err != nil {
226+
t.Fatalf("unexpected error: %v", err)
227+
}
228+
229+
assertFieldEquals(t, resp, keyAuthtype, "Bearer")
230+
assertFieldEquals(t, resp, keyCredential, "abc123")
231+
})
232+
233+
t.Run("unknown kind", func(t *testing.T) {
234+
payload := &gitPayload{
235+
protocol: "https",
236+
host: "github.com",
237+
}
238+
239+
_, err := generateResponse(payload, []byte("secret"), "unknown")
240+
if err == nil {
241+
t.Fatal("expected error for unknown kind, got nil")
242+
}
243+
})
244+
}
245+
246+
// assertFieldEquals checks that a credential protocol response contains
247+
// a specific key=value pair.
248+
func assertFieldEquals(t *testing.T, resp, key, expected string) {
249+
t.Helper()
250+
251+
prefix := key + "="
252+
for _, line := range strings.Split(resp, "\n") {
253+
if strings.HasPrefix(line, prefix) {
254+
got := strings.TrimPrefix(line, prefix)
255+
if got != expected {
256+
t.Errorf("field %q: expected %q, got %q", key, expected, got)
257+
}
258+
return
259+
}
260+
}
261+
262+
t.Errorf("field %q not found in response:\n%s", key, resp)
263+
}
264+
265+
// assertFieldAbsent checks that a credential protocol response does NOT
266+
// contain a specific key.
267+
func assertFieldAbsent(t *testing.T, resp, key string) {
268+
t.Helper()
269+
270+
prefix := key + "="
271+
for _, line := range strings.Split(resp, "\n") {
272+
if strings.HasPrefix(line, prefix) {
273+
t.Errorf("field %q should be absent but found: %s", key, line)
274+
return
275+
}
276+
}
277+
}

0 commit comments

Comments
 (0)