Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- Match multi-level credential path scopes only at slash boundaries, preventing
similarly prefixed groups or repositories from receiving the wrong token.
- Match Git's credential URL parsing and decoding behavior, including literal
plus signs, single-pass percent decoding, and query or fragment boundaries.
- Keep passwords and malformed credential lines out of debug logs, and set
debug log file permissions to `0600` on POSIX systems.

## [1.1.2] - 2026-09-03

### Fixed
Expand Down
37 changes: 31 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,10 @@ See the complete, sanitized [`examples/gitconfig`](examples/gitconfig) file:
[credential "https://git.example.com/"]
helper = readonly --file ~/.git-credentials-work

[credential "https://gitlab.example.com/"]
helper = readonly --file ~/.git-credentials-work
useHttpPath = true

[credential]
helper = readonly
```
Expand All @@ -97,15 +101,26 @@ file names as needed.

### Credential file examples

When `useHttpPath = true`, include a GitHub account or organization path in
each credential URL. An owner-only entry matches every repository belonging to
that owner, while a full repository path matches only that repository.
Git removes the HTTP(S) path before invoking external helpers unless
[`credential.useHttpPath`](https://git-scm.com/docs/gitcredentials#Documentation/gitcredentials.txt-credentialuseHttpPath)
is enabled. Git's built-in `credential-store` then compares a supplied path
exactly. This helper intentionally extends that behavior with slash-delimited
path scopes: `group/subgroup` matches both itself and descendants such as
`group/subgroup/project.git`, but it does not match `group/subgroup-backup`.
A trailing slash on a credential path is optional. Non-exact scope matches are
rejected when either path contains a `.` or `..` segment, including a
percent-encoded form that remains after Git's URL decoding.

Credential lines are checked from top to bottom, like `credential-store`, and
the first match wins. Put full repository paths before subgroup paths, and put
subgroup paths before broader organization or account paths.

`~/.git-credentials-work`:

```text
https://example-user:organization-token@github.com/example-org
https://example-user:repository-token@github.com/example-org/private-repository.git
https://example-user:organization-token@github.com/example-org
https://example-user:subgroup-token@gitlab.example.com/group/subgroup
https://example-user:work-token@git.example.com
```

Expand All @@ -117,8 +132,11 @@ The default personal file may contain:
https://example-user:personal-token@github.com/example-user
```

Credential files contain plaintext secrets. Never commit them, percent-encode
special characters in usernames and tokens, and restrict their permissions:
Credential files use the official
[`git-credential-store` storage format](https://git-scm.com/docs/git-credential-store#_storage_format):
one credential URL per line, without comments or blank lines. They contain
plaintext secrets. Never commit them, percent-encode special characters in
usernames and tokens, and restrict their permissions:

```shell
chmod 600 ~/.git-credentials ~/.git-credentials-work
Expand Down Expand Up @@ -158,5 +176,12 @@ git config --show-origin --show-scope \
## Documentation

- [Git credential storage](https://git-scm.com/book/en/v2/Git-Tools-Credential-Storage#_a_custom_credential_cache)
- [`git credential` input/output format](https://git-scm.com/docs/git-credential#_inputoutput_format)
- [`git-credential-store` storage and lookup behavior](https://git-scm.com/docs/git-credential-store#_storage_format)
- [`credential.helper` configuration](https://git-scm.com/docs/gitcredentials#Documentation/gitcredentials.txt-credentialhelper)
- [`credential.useHttpPath` configuration](https://git-scm.com/docs/gitcredentials#Documentation/gitcredentials.txt-credentialuseHttpPath)
- [Git credential-context matching](https://git-scm.com/docs/gitcredentials#Documentation/gitcredentials.txt-CREDENTIALCONTEXTS)
- [Git's exact credential field matcher](https://github.com/git/git/blob/v2.55.0/credential.c#L81-L92)
- [Git's `credential-store` lookup loop](https://github.com/git/git/blob/v2.55.0/builtin/credential-store.c#L13-L47)
- [Git's credential URL parser](https://github.com/git/git/blob/v2.55.0/credential.c#L585-L653)
- [Git's credential URL decoder](https://github.com/git/git/blob/v2.55.0/url.c#L44-L102)
5 changes: 5 additions & 0 deletions examples/gitconfig
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@
[credential "https://git.example.com/"]
helper = readonly --file ~/.git-credentials-work

# Optional GitLab host demonstrating a multi-level group credential scope.
[credential "https://gitlab.example.com/"]
helper = readonly --file ~/.git-credentials-work
useHttpPath = true

# General personal credential fallback. Keep this after scoped helpers.
[credential]
helper = readonly
Expand Down
183 changes: 138 additions & 45 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import (
"fmt"
"io"
"log"
"net/url"
"os"
"os/user"
"strings"
Expand Down Expand Up @@ -36,10 +35,11 @@ func main() {
if err != nil {
log.Fatal(err)
}
logOut, err := os.OpenFile(logFile, os.O_CREATE|os.O_APPEND|os.O_WRONLY, os.ModePerm)
logOut, err := openDebugLog(logFile)
if err != nil {
log.Fatal(err)
}
defer logOut.Close()
log.SetOutput(logOut)
} else {
log.SetOutput(io.Discard)
Expand All @@ -61,13 +61,13 @@ func main() {
if err != nil {
log.Fatalf("get stdin failed, err=%v", err)
}
log.Printf("get req success: %#v", req)
logCredentialMetadata("get request", req)
credential := getCredential(req, credFile)
if credential == nil {
// credential not found
os.Exit(1)
}
log.Printf("get credential success: %#v", credential)
logCredentialMetadata("get credential success", credential)
fmt.Printf("username=%s\npassword=%s\n", credential.username, credential.password)
case "erase", "store":
log.Printf("ignore action=%v", action)
Expand All @@ -85,6 +85,23 @@ type credential struct {
path string
}

func openDebugLog(path string) (*os.File, error) {
logOut, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
if err != nil {
return nil, err
}
if err := logOut.Chmod(0o600); err != nil {
logOut.Close()
return nil, err
}
return logOut, nil
}

func logCredentialMetadata(event string, credential *credential) {
log.Printf("%s: protocol=%q,host=%q,path=%q,username=%q",
event, credential.protocol, credential.host, credential.path, credential.username)
}

func (c *credential) match(req *credential) bool {
if c == nil || req == nil {
return false
Expand All @@ -102,24 +119,51 @@ func (c *credential) match(req *credential) bool {
if req.path != "" {
pathMatch := matchCredentialPath(c.path, req.path)
match = match && pathMatch
log.Printf("match path: req.path=%v,config.path=%v,result=%v",
log.Printf("match path: req.path=%q,config.path=%q,result=%v",
req.path, c.path, match)
}
return match
}

func matchCredentialPath(configPath, requestPath string) bool {
// Keep Git's exact-match behavior, including the encoded-root path "/",
// before normalizing optional trailing separators for scope matching.
if configPath != "" && configPath == requestPath {
return true
}

configPath = strings.TrimRight(configPath, "/")
requestPath = strings.TrimRight(requestPath, "/")

// Preserve the standard credential-store behavior for repository-specific
// entries before trying the owner/organization shorthand supported here.
if configPath == "" || requestPath == "" {
return false
}

if configPath == requestPath {
return true
}

requestOwner, _, hasSeparator := strings.Cut(requestPath, "/")
return hasSeparator && requestOwner != "" && configPath == requestOwner
// Scope matching is an extension to credential-store's exact path match.
// Refuse ambiguous dot segments so a path cannot escape a matched scope.
if hasDotPathSegment(configPath) || hasDotPathSegment(requestPath) {
return false
}

return len(requestPath) > len(configPath) &&
strings.HasPrefix(requestPath, configPath) &&
requestPath[len(configPath)] == '/'
}

func hasDotPathSegment(path string) bool {
// Git's decoder may preserve escapes before a literal ':', while the URL
// consumer can still normalize them. Use a fully decoded safety view so an
// encoded dot or slash cannot hide a path traversal from scope matching.
for _, segment := range strings.Split(decodePercentEscapes(path), "/") {
if segment == "." || segment == ".." {
return true
}
}
return false
}

func parseGitCredentialRequest(r io.Reader) (*credential, error) {
Expand Down Expand Up @@ -157,52 +201,38 @@ func parseGitCredentialRequest(r io.Reader) (*credential, error) {
}

func parseCredential(line string) *credential {
fields := strings.SplitN(line, "://", 2)
if len(fields) != 2 {
protoEnd := strings.Index(line, "://")
if protoEnd <= 0 {
// malformed line, ignore
return nil
}
proto := fields[0]
rest := fields[1]
proto := line[:protoEnd]
rest := line[protoEnd+3:]

fields = strings.SplitN(rest, "@", 2)
if len(fields) != 2 {
// malformed line, ignore
return nil
hostEnd := strings.IndexAny(rest, "/?#")
if hostEnd < 0 {
hostEnd = len(rest)
}

auth := fields[0]
credFields := strings.SplitN(auth, ":", 2)
if len(credFields) != 2 {
at := strings.IndexByte(rest, '@')
colon := strings.IndexByte(rest, ':')
if at < 0 || hostEnd <= at || colon < 0 || at <= colon {
// malformed line, ignore
return nil
}
username, err := url.QueryUnescape(credFields[0])
if err != nil {
return nil
}
password, err := url.QueryUnescape(credFields[1])
if err != nil {
return nil
}
username := decodeCredentialURLComponent(rest[:colon])
password := decodeCredentialURLComponent(rest[colon+1 : at])
host := decodeCredentialURLComponent(rest[at+1 : hostEnd])

hostAndPath := fields[1]
hostFields := strings.SplitN(hostAndPath, "/", 2)
if len(hostFields) != 1 && len(hostFields) != 2 {
// malformed line, ignore
return nil
}
host, err := url.QueryUnescape(hostFields[0])
if err != nil {
return nil
var path string
if hostEnd < len(rest) {
rawPath := strings.TrimLeft(rest[hostEnd:], "/")
path = decodeCredentialURLComponent(rawPath)
path = trimCredentialURLPath(path)
}

var path string
if len(hostFields) == 2 {
path, err = url.QueryUnescape(hostFields[1])
if err != nil {
return nil
}
if strings.Contains(username, "\n") || strings.Contains(password, "\n") ||
strings.Contains(host, "\n") || strings.Contains(path, "\n") {
return nil
}

return &credential{
Expand All @@ -214,6 +244,67 @@ func parseCredential(line string) *credential {
}
}

func trimCredentialURLPath(path string) string {
trimmed := strings.TrimRight(path, "/")
if path != "" && trimmed == "" {
return "/"
}
return trimmed
}

// decodeCredentialURLComponent follows Git's url_decode_mem: a prefix before
// the first literal ':' is preserved as a possible URL scheme; the remainder
// decodes valid, non-NUL percent escapes exactly once and leaves '+' and
// malformed escapes unchanged.
func decodeCredentialURLComponent(value string) string {
colon := strings.IndexByte(value, ':')
if colon <= 0 {
return decodePercentEscapes(value)
}

var decoded strings.Builder
decoded.Grow(len(value))
decoded.WriteString(value[:colon])
decoded.WriteString(decodePercentEscapes(value[colon:]))
return decoded.String()
}

func decodePercentEscapes(value string) string {
var decoded strings.Builder
decoded.Grow(len(value))

for i := 0; i < len(value); i++ {
if value[i] == '%' && i+2 < len(value) {
high, highOK := hexValue(value[i+1])
low, lowOK := hexValue(value[i+2])
if highOK && lowOK {
unescaped := high<<4 | low
if unescaped != 0 {
decoded.WriteByte(unescaped)
i += 2
continue
}
}
}
decoded.WriteByte(value[i])
}

return decoded.String()
}

func hexValue(value byte) (byte, bool) {
switch {
case value >= '0' && value <= '9':
return value - '0', true
case value >= 'a' && value <= 'f':
return value - 'a' + 10, true
case value >= 'A' && value <= 'F':
return value - 'A' + 10, true
default:
return 0, false
}
}

func getCredential(req *credential, credFile string) *credential {
credPath, err := expandHomeDir(credFile)
if err != nil {
Expand All @@ -228,11 +319,13 @@ func getCredential(req *credential, credFile string) *credential {
defer file.Close()

scanner := bufio.NewScanner(file)
lineNumber := 0
for scanner.Scan() {
lineNumber++
line := scanner.Text()
cred := parseCredential(line)
if cred == nil {
log.Printf("err malformed credential line: %s", line)
log.Printf("ignore malformed credential at line %d", lineNumber)
continue
}
if cred.match(req) {
Expand Down
Loading
Loading