aboutsummaryrefslogtreecommitdiff
path: root/cmd/auth/gitauth/gitauth.go
blob: 6128889f056ad91350b61bf19ed9a96eb3223ec6 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
// Copyright 2019 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

// gitauth uses 'git credential' to implement the GOAUTH protocol described in
// https://golang.org/issue/26232. It expects an absolute path to the working
// directory for the 'git' command as the first command-line argument.
//
// Example GOAUTH usage:
//
//	export GOAUTH="gitauth $HOME"
//
// See https://git-scm.com/docs/gitcredentials or run 'man gitcredentials' for
// information on how to configure 'git credential'.
package main

import (
	"bytes"
	"fmt"
	exec "golang.org/x/sys/execabs"
	"log"
	"net/http"
	"net/url"
	"os"
	"path/filepath"
	"strings"
)

func main() {
	if len(os.Args) < 2 || !filepath.IsAbs(os.Args[1]) {
		fmt.Fprintf(os.Stderr, "usage: %s WORKDIR [URL]", os.Args[0])
		os.Exit(2)
	}

	log.SetPrefix("gitauth: ")

	if len(os.Args) != 3 {
		// No explicit URL was passed on the command line, but 'git credential'
		// provides no way to enumerate existing credentials.
		// Wait for a request for a specific URL.
		return
	}

	u, err := url.ParseRequestURI(os.Args[2])
	if err != nil {
		log.Fatalf("invalid request URI (%v): %q\n", err, os.Args[1])
	}

	var (
		prefix     *url.URL
		lastHeader http.Header
		lastStatus = http.StatusUnauthorized
	)
	for lastStatus == http.StatusUnauthorized {
		cmd := exec.Command("git", "credential", "fill")

		// We don't want to execute a 'git' command in an arbitrary directory, since
		// that opens up a number of config-injection attacks (for example,
		// https://golang.org/issue/29230). Instead, we have the user configure a
		// directory explicitly on the command line.
		cmd.Dir = os.Args[1]

		cmd.Stdin = strings.NewReader(fmt.Sprintf("url=%s\n", u))
		cmd.Stderr = os.Stderr
		out, err := cmd.Output()
		if err != nil {
			log.Fatalf("'git credential fill' failed: %v\n", err)
		}

		prefix = new(url.URL)
		var username, password string
		lines := strings.Split(string(out), "\n")
		for _, line := range lines {
			frags := strings.SplitN(line, "=", 2)
			if len(frags) != 2 {
				continue // Ignore unrecognized response lines.
			}
			switch strings.TrimSpace(frags[0]) {
			case "protocol":
				prefix.Scheme = frags[1]
			case "host":
				prefix.Host = frags[1]
			case "path":
				prefix.Path = frags[1]
			case "username":
				username = frags[1]
			case "password":
				password = frags[1]
			case "url":
				// Write to a local variable instead of updating prefix directly:
				// if the url field is malformed, we don't want to invalidate
				// information parsed from the protocol, host, and path fields.
				u, err := url.ParseRequestURI(frags[1])
				if err == nil {
					prefix = u
				} else {
					log.Printf("malformed URL from 'git credential fill' (%v): %q\n", err, frags[1])
					// Proceed anyway: we might be able to parse the prefix from other fields of the response.
				}
			}
		}

		// Double-check that the URL Git gave us is a prefix of the one we requested.
		if !strings.HasPrefix(u.String(), prefix.String()) {
			log.Fatalf("requested a credential for %q, but 'git credential fill' provided one for %q\n", u, prefix)
		}

		// Send a HEAD request to try to detect whether the credential is valid.
		// If the user just typed in a correct password and has caching enabled,
		// we don't want to nag them for it again the next time they run a 'go' command.
		req, err := http.NewRequest("HEAD", u.String(), nil)
		if err != nil {
			log.Fatalf("internal error constructing HTTP HEAD request: %v\n", err)
		}
		req.SetBasicAuth(username, password)
		lastHeader = req.Header
		resp, err := http.DefaultClient.Do(req)
		if err != nil {
			log.Printf("HTTPS HEAD request failed to connect: %v\n", err)
			// Couldn't verify the credential, but we have no evidence that it is invalid either.
			// Proceed, but don't update git's credential cache.
			break
		}
		lastStatus = resp.StatusCode

		if resp.StatusCode != http.StatusOK {
			log.Printf("%s: %v %s\n", u, resp.StatusCode, http.StatusText(resp.StatusCode))
		}

		if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusUnauthorized {
			// We learned something about the credential: it either worked or it was invalid.
			// Approve or reject the credential (on a best-effort basis)
			// so that the git credential helper can update its cache as appropriate.
			action := "approve"
			if resp.StatusCode != http.StatusOK {
				action = "reject"
			}
			cmd = exec.Command("git", "credential", action)
			cmd.Stderr = os.Stderr
			cmd.Stdout = os.Stderr
			cmd.Stdin = bytes.NewReader(out)
			_ = cmd.Run()
		}
	}

	// Write out the credential in the format expected by the 'go' command.
	fmt.Printf("%s\n\n", prefix)
	lastHeader.Write(os.Stdout)
	fmt.Println()
}