mirror of
https://github.com/go-task/task.git
synced 2026-08-29 10:08:27 +02:00
feat(remote): add remote.auth to send HTTP headers when downloading Taskfiles
Authenticating a remote Taskfile so far meant putting the credential in the
include URL, where it leaks into error messages and the confirmation prompt.
`remote.auth` configures free-form headers per host instead, so the URL stays
safe to commit. Values may reference environment variables with ${VAR}.
The headers are injected by a RoundTripper rather than set on the request:
that covers the HEAD probe RemoteExists issues before the GET, and keeps a
cross-host redirect from carrying the credentials. They are resolved when the
request is about to be made, so a cached or offline run does not require a
token it will never send.
This commit is contained in:
@@ -2,6 +2,14 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- Added a `remote.auth` config option to send HTTP headers when downloading a
|
||||
remote Taskfile, configured per host. Header values may reference environment
|
||||
variables with `${VAR}`. This keeps the credential out of the include URL,
|
||||
where it would leak into error messages and the confirmation prompt (#2329 by
|
||||
@vmaerten).
|
||||
|
||||
### 📦 Package API
|
||||
|
||||
- Bumped the minimum Go version to 1.26. Task follows Go's two-latest support
|
||||
|
||||
15
executor.go
15
executor.go
@@ -36,6 +36,7 @@ type (
|
||||
Download bool
|
||||
Offline bool
|
||||
TrustedHosts []string
|
||||
RemoteAuth map[string]map[string]string
|
||||
Timeout time.Duration
|
||||
CacheExpiryDuration time.Duration
|
||||
RemoteCacheDir string
|
||||
@@ -277,6 +278,20 @@ func (o *trustedHostsOption) ApplyToExecutor(e *Executor) {
|
||||
e.TrustedHosts = o.trustedHosts
|
||||
}
|
||||
|
||||
// WithRemoteAuth configures the [Executor] with the HTTP headers to send when
|
||||
// fetching a remote Taskfile, keyed by host.
|
||||
func WithRemoteAuth(remoteAuth map[string]map[string]string) ExecutorOption {
|
||||
return &remoteAuthOption{remoteAuth}
|
||||
}
|
||||
|
||||
type remoteAuthOption struct {
|
||||
remoteAuth map[string]map[string]string
|
||||
}
|
||||
|
||||
func (o *remoteAuthOption) ApplyToExecutor(e *Executor) {
|
||||
e.RemoteAuth = o.remoteAuth
|
||||
}
|
||||
|
||||
// WithTimeout sets the [Executor]'s timeout for fetching remote taskfiles. By
|
||||
// default, the timeout is set to 10 seconds.
|
||||
func WithTimeout(timeout time.Duration) ExecutorOption {
|
||||
|
||||
@@ -79,6 +79,7 @@ var (
|
||||
Download bool
|
||||
Offline bool
|
||||
TrustedHosts []string
|
||||
RemoteAuth map[string]map[string]string
|
||||
ClearCache bool
|
||||
Timeout time.Duration
|
||||
CacheExpiryDuration time.Duration
|
||||
@@ -165,6 +166,9 @@ func init() {
|
||||
pflag.StringVar(&CACert, "cacert", getConfig(config, "REMOTE_CACERT", func() *string { return config.Remote.CACert }, ""), "Path to a custom CA certificate for HTTPS connections.")
|
||||
pflag.StringVar(&Cert, "cert", getConfig(config, "REMOTE_CERT", func() *string { return config.Remote.Cert }, ""), "Path to a client certificate for HTTPS connections.")
|
||||
pflag.StringVar(&CertKey, "cert-key", getConfig(config, "REMOTE_CERT_KEY", func() *string { return config.Remote.CertKey }, ""), "Path to a client certificate key for HTTPS connections.")
|
||||
// Configurable through the configuration file only: a token given on the
|
||||
// command line would be visible to any process listing it.
|
||||
RemoteAuth = remoteAuth(config)
|
||||
|
||||
// Gentle force experiment will override the force flag and add a new force-all flag
|
||||
if experiments.GentleForce.Enabled() {
|
||||
@@ -285,6 +289,7 @@ func (o *flagsOption) ApplyToExecutor(e *task.Executor) {
|
||||
task.WithDownload(Download),
|
||||
task.WithOffline(Offline),
|
||||
task.WithTrustedHosts(TrustedHosts),
|
||||
task.WithRemoteAuth(RemoteAuth),
|
||||
task.WithTimeout(Timeout),
|
||||
task.WithCacheExpiryDuration(CacheExpiryDuration),
|
||||
task.WithRemoteCacheDir(RemoteCacheDir),
|
||||
@@ -311,6 +316,20 @@ func (o *flagsOption) ApplyToExecutor(e *task.Executor) {
|
||||
)
|
||||
}
|
||||
|
||||
// remoteAuth flattens the configured authentication entries into a lookup by
|
||||
// host. A host declared twice in the same file keeps its last entry, which is
|
||||
// the rule the configuration files themselves follow when they are merged.
|
||||
func remoteAuth(config *taskrcast.TaskRC) map[string]map[string]string {
|
||||
if config == nil || len(config.Remote.Auth) == 0 {
|
||||
return nil
|
||||
}
|
||||
byHost := make(map[string]map[string]string, len(config.Remote.Auth))
|
||||
for _, auth := range config.Remote.Auth {
|
||||
byHost[auth.Host] = auth.Headers
|
||||
}
|
||||
return byHost
|
||||
}
|
||||
|
||||
// getConfig extracts a config value with priority: env var > taskrc config > fallback
|
||||
func getConfig[T any](config *taskrcast.TaskRC, envKey string, fieldFunc func() *T, fallback T) T {
|
||||
if envKey != "" {
|
||||
|
||||
2
setup.go
2
setup.go
@@ -58,6 +58,7 @@ func (e *Executor) getRootNode() (taskfile.Node, error) {
|
||||
taskfile.WithCACert(e.CACert),
|
||||
taskfile.WithCert(e.Cert),
|
||||
taskfile.WithCertKey(e.CertKey),
|
||||
taskfile.WithAuthHeaders(e.RemoteAuth),
|
||||
)
|
||||
if taskNotFoundError, ok := errors.AsType[errors.TaskfileNotFoundError](err); ok {
|
||||
taskNotFoundError.AskInit = true
|
||||
@@ -90,6 +91,7 @@ func (e *Executor) readTaskfile(node taskfile.Node) error {
|
||||
taskfile.WithReaderCACert(e.CACert),
|
||||
taskfile.WithReaderCert(e.Cert),
|
||||
taskfile.WithReaderCertKey(e.CertKey),
|
||||
taskfile.WithReaderAuthHeaders(e.RemoteAuth),
|
||||
taskfile.WithDebugFunc(debugFunc),
|
||||
taskfile.WithPromptFunc(promptFunc),
|
||||
)
|
||||
|
||||
@@ -7,12 +7,13 @@ type (
|
||||
// designed to be embedded in other node types so that this boilerplate code
|
||||
// does not need to be repeated.
|
||||
baseNode struct {
|
||||
parent Node
|
||||
dir string
|
||||
checksum string
|
||||
caCert string
|
||||
cert string
|
||||
certKey string
|
||||
parent Node
|
||||
dir string
|
||||
checksum string
|
||||
caCert string
|
||||
cert string
|
||||
certKey string
|
||||
authHeaders HostHeaders
|
||||
}
|
||||
)
|
||||
|
||||
@@ -75,3 +76,11 @@ func WithCertKey(certKey string) NodeOption {
|
||||
node.certKey = certKey
|
||||
}
|
||||
}
|
||||
|
||||
// WithAuthHeaders sets the HTTP headers to send when the node's host matches
|
||||
// one of the configured ones.
|
||||
func WithAuthHeaders(authHeaders HostHeaders) NodeOption {
|
||||
return func(node *baseNode) {
|
||||
node.authHeaders = authHeaders
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +106,11 @@ func (node *HTTPNode) Read() ([]byte, error) {
|
||||
}
|
||||
|
||||
func (node *HTTPNode) ReadContext(ctx context.Context) ([]byte, error) {
|
||||
url, err := RemoteExists(ctx, *node.url, node.client)
|
||||
client, err := node.authenticatedClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
url, err := RemoteExists(ctx, *node.url, client)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -115,7 +119,7 @@ func (node *HTTPNode) ReadContext(ctx context.Context) ([]byte, error) {
|
||||
return nil, errors.TaskfileFetchFailedError{URI: node.Location()}
|
||||
}
|
||||
|
||||
resp, err := node.client.Do(req.WithContext(ctx))
|
||||
resp, err := client.Do(req.WithContext(ctx))
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return nil, err
|
||||
|
||||
141
taskfile/node_http_auth.go
Normal file
141
taskfile/node_http_auth.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package taskfile
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/http"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// HostHeaders maps a host to the HTTP headers to send when fetching a remote
|
||||
// Taskfile from it. Values may reference environment variables using the
|
||||
// `${VAR}` or `$VAR` syntax.
|
||||
type HostHeaders map[string]map[string]string
|
||||
|
||||
// authTransport adds the configured headers to every request made to host.
|
||||
type authTransport struct {
|
||||
base http.RoundTripper
|
||||
host string
|
||||
headers map[string]string
|
||||
}
|
||||
|
||||
func (t *authTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
// The headers are scoped to a single host. Checking here rather than once
|
||||
// at build time is what keeps a redirect from carrying the credentials
|
||||
// somewhere else: the client sends the redirected request through this same
|
||||
// transport, and Go only strips Authorization, WWW-Authenticate and Cookie
|
||||
// on its own.
|
||||
if !hostMatches(t.host, req.URL.Host) {
|
||||
return t.base.RoundTrip(req)
|
||||
}
|
||||
// A RoundTripper must not modify the request it is given.
|
||||
req = req.Clone(req.Context())
|
||||
for name, value := range t.headers {
|
||||
req.Header.Set(name, value)
|
||||
}
|
||||
return t.base.RoundTrip(req)
|
||||
}
|
||||
|
||||
// authenticatedClient returns the node's client, wrapped so that it sends the
|
||||
// configured headers. The environment variables the headers reference are read
|
||||
// here rather than when the node is built, so that a run served from the cache
|
||||
// does not require credentials it will never send.
|
||||
func (node *HTTPNode) authenticatedClient() (*http.Client, error) {
|
||||
headers, err := resolveAuthHeaders(node.authHeaders, node.url.Host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(headers) == 0 {
|
||||
return node.client, nil
|
||||
}
|
||||
return withAuthHeaders(node.client, node.url.Host, headers), nil
|
||||
}
|
||||
|
||||
// withAuthHeaders returns a copy of client that sends headers to host. The
|
||||
// client is copied rather than mutated because buildHTTPClient returns the
|
||||
// shared http.DefaultClient when no TLS option is set.
|
||||
func withAuthHeaders(client *http.Client, host string, headers map[string]string) *http.Client {
|
||||
authenticated := *client
|
||||
authenticated.Transport = &authTransport{
|
||||
base: cmp.Or(client.Transport, http.DefaultTransport),
|
||||
host: host,
|
||||
headers: headers,
|
||||
}
|
||||
return &authenticated
|
||||
}
|
||||
|
||||
// resolveAuthHeaders returns the headers configured for host, with their
|
||||
// environment variable references expanded. It returns nil when no entry
|
||||
// matches, leaving the request unauthenticated.
|
||||
func resolveAuthHeaders(hostHeaders HostHeaders, host string) (map[string]string, error) {
|
||||
var headers map[string]string
|
||||
for pattern, patternHeaders := range hostHeaders {
|
||||
if hostMatches(pattern, host) {
|
||||
headers = patternHeaders
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(headers) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
resolved := make(map[string]string, len(headers))
|
||||
for _, name := range slices.Sorted(maps.Keys(headers)) {
|
||||
if err := validateHeaderName(name); err != nil {
|
||||
return nil, fmt.Errorf(`remote auth for host %q: %w`, host, err)
|
||||
}
|
||||
value, err := expandEnv(headers[name])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(`remote auth for host %q: header %q: %w`, host, name, err)
|
||||
}
|
||||
resolved[name] = value
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
// expandEnv replaces ${VAR} and $VAR references with the value of the
|
||||
// environment variable. An undefined variable is an error rather than an empty
|
||||
// header, which would only surface later as an opaque 401. A literal dollar
|
||||
// sign is written `$$`.
|
||||
func expandEnv(value string) (string, error) {
|
||||
var missing []string
|
||||
expanded := os.Expand(value, func(name string) string {
|
||||
if name == "$" {
|
||||
return "$"
|
||||
}
|
||||
v, ok := os.LookupEnv(name)
|
||||
if !ok {
|
||||
missing = append(missing, name)
|
||||
return ""
|
||||
}
|
||||
return v
|
||||
})
|
||||
if len(missing) > 0 {
|
||||
return "", fmt.Errorf("environment variable $%s is not set", strings.Join(missing, ", $"))
|
||||
}
|
||||
return expanded, nil
|
||||
}
|
||||
|
||||
// validateHeaderName rejects names that http.Header.Set would silently accept
|
||||
// but the transport would later refuse, so that the error names the offending
|
||||
// header instead of the request.
|
||||
func validateHeaderName(name string) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("header name cannot be empty")
|
||||
}
|
||||
if strings.ContainsFunc(name, func(r rune) bool {
|
||||
return r <= ' ' || r == ':' || r == 0x7f
|
||||
}) {
|
||||
return fmt.Errorf("header name %q contains invalid characters", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// hostMatches reports whether a host matches a configured pattern. The
|
||||
// comparison is exact and includes the port, as it does for trusted hosts.
|
||||
func hostMatches(pattern, host string) bool {
|
||||
return pattern == host
|
||||
}
|
||||
236
taskfile/node_http_auth_test.go
Normal file
236
taskfile/node_http_auth_test.go
Normal file
@@ -0,0 +1,236 @@
|
||||
package taskfile
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestResolveAuthHeaders(t *testing.T) { //nolint:paralleltest // t.Setenv cannot be used in parallel tests
|
||||
tests := []struct {
|
||||
name string
|
||||
hostHeaders HostHeaders
|
||||
host string
|
||||
env map[string]string
|
||||
want map[string]string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "no configuration",
|
||||
hostHeaders: nil,
|
||||
host: "gitlab.com",
|
||||
},
|
||||
{
|
||||
name: "host does not match",
|
||||
hostHeaders: HostHeaders{"gitlab.com": {"PRIVATE-TOKEN": "token"}},
|
||||
host: "example.com",
|
||||
},
|
||||
{
|
||||
name: "port is part of the host",
|
||||
hostHeaders: HostHeaders{"example.com": {"PRIVATE-TOKEN": "token"}},
|
||||
host: "example.com:8080",
|
||||
},
|
||||
{
|
||||
name: "literal value",
|
||||
hostHeaders: HostHeaders{"gitlab.com": {"PRIVATE-TOKEN": "token"}},
|
||||
host: "gitlab.com",
|
||||
want: map[string]string{"PRIVATE-TOKEN": "token"},
|
||||
},
|
||||
{
|
||||
name: "braced environment variable",
|
||||
hostHeaders: HostHeaders{"gitlab.com": {"PRIVATE-TOKEN": "${TASK_TEST_TOKEN}"}}, //nolint:gosec // an env var reference, not a credential
|
||||
host: "gitlab.com",
|
||||
env: map[string]string{"TASK_TEST_TOKEN": "s3cret"},
|
||||
want: map[string]string{"PRIVATE-TOKEN": "s3cret"},
|
||||
},
|
||||
{
|
||||
name: "environment variable inside a longer value",
|
||||
hostHeaders: HostHeaders{"gitlab.com": {"Authorization": "Bearer $TASK_TEST_TOKEN"}},
|
||||
host: "gitlab.com",
|
||||
env: map[string]string{"TASK_TEST_TOKEN": "s3cret"},
|
||||
want: map[string]string{"Authorization": "Bearer s3cret"},
|
||||
},
|
||||
{
|
||||
name: "escaped dollar sign",
|
||||
hostHeaders: HostHeaders{"gitlab.com": {"PRIVATE-TOKEN": "lit$$eral"}},
|
||||
host: "gitlab.com",
|
||||
want: map[string]string{"PRIVATE-TOKEN": "lit$eral"},
|
||||
},
|
||||
{
|
||||
name: "undefined environment variable",
|
||||
hostHeaders: HostHeaders{"gitlab.com": {"PRIVATE-TOKEN": "${TASK_TEST_UNSET}"}}, //nolint:gosec // an env var reference, not a credential
|
||||
host: "gitlab.com",
|
||||
wantErr: `remote auth for host "gitlab.com": header "PRIVATE-TOKEN": environment variable $TASK_TEST_UNSET is not set`,
|
||||
},
|
||||
{
|
||||
name: "invalid header name",
|
||||
hostHeaders: HostHeaders{"gitlab.com": {"PRIVATE TOKEN": "token"}},
|
||||
host: "gitlab.com",
|
||||
wantErr: `remote auth for host "gitlab.com": header name "PRIVATE TOKEN" contains invalid characters`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
for name, value := range test.env {
|
||||
t.Setenv(name, value)
|
||||
}
|
||||
headers, err := resolveAuthHeaders(test.hostHeaders, test.host)
|
||||
if test.wantErr != "" {
|
||||
require.EqualError(t, err, test.wantErr)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, test.want, headers)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthTransport(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
transport := &authTransport{
|
||||
base: roundTripperFunc(func(req *http.Request) (*http.Response, error) { return newResponse(req), nil }),
|
||||
host: "gitlab.com",
|
||||
headers: map[string]string{"PRIVATE-TOKEN": "token"},
|
||||
}
|
||||
|
||||
t.Run("sets the headers on the configured host", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
req := newRequest(t, "https://gitlab.com/api/v4/Taskfile.yml")
|
||||
resp, err := transport.RoundTrip(req)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "token", resp.Request.Header.Get("PRIVATE-TOKEN"))
|
||||
// The transport must leave the request it was given untouched.
|
||||
assert.Empty(t, req.Header.Get("PRIVATE-TOKEN"))
|
||||
})
|
||||
|
||||
t.Run("leaves any other host alone", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
req := newRequest(t, "https://example.com/Taskfile.yml")
|
||||
resp, err := transport.RoundTrip(req)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, resp.Request.Header.Get("PRIVATE-TOKEN"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestWithAuthHeadersDoesNotMutateTheDefaultClient(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := withAuthHeaders(http.DefaultClient, "gitlab.com", map[string]string{"PRIVATE-TOKEN": "token"})
|
||||
|
||||
assert.NotSame(t, http.DefaultClient, client)
|
||||
assert.Nil(t, http.DefaultClient.Transport)
|
||||
assert.IsType(t, &authTransport{}, client.Transport)
|
||||
}
|
||||
|
||||
// TestHTTPNodeAuthHeaders covers the whole download: RemoteExists probes the
|
||||
// URL with a HEAD request before ReadContext issues the GET, and both must
|
||||
// carry the headers.
|
||||
func TestHTTPNodeAuthHeaders(t *testing.T) { //nolint:paralleltest // t.Setenv cannot be used in parallel tests
|
||||
var methods []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("PRIVATE-TOKEN") != "s3cret" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
methods = append(methods, r.Method)
|
||||
w.Header().Set("Content-Type", "text/yaml")
|
||||
_, _ = w.Write([]byte("version: '3'\n"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
t.Setenv("TASK_TEST_TOKEN", "s3cret")
|
||||
node, err := NewHTTPNode(srv.URL+"/Taskfile.yml", "", true,
|
||||
WithAuthHeaders(HostHeaders{
|
||||
mustHost(t, srv.URL): {"PRIVATE-TOKEN": "${TASK_TEST_TOKEN}"}, //nolint:gosec // an env var reference, not a credential
|
||||
}),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
b, err := node.Read()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "version: '3'\n", string(b))
|
||||
assert.Equal(t, []string{"HEAD", "GET"}, methods)
|
||||
}
|
||||
|
||||
// TestHTTPNodeAuthHeadersNotSentOnRedirect guards the credentials against a
|
||||
// server that bounces the request to a host they were never meant for.
|
||||
func TestHTTPNodeAuthHeadersNotSentOnRedirect(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var received []string
|
||||
elsewhere := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
received = append(received, r.Header.Get("PRIVATE-TOKEN"))
|
||||
w.Header().Set("Content-Type", "text/yaml")
|
||||
_, _ = w.Write([]byte("version: '3'\n"))
|
||||
}))
|
||||
defer elsewhere.Close()
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, elsewhere.URL+"/Taskfile.yml", http.StatusFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
node, err := NewHTTPNode(srv.URL+"/Taskfile.yml", "", true,
|
||||
WithAuthHeaders(HostHeaders{
|
||||
mustHost(t, srv.URL): {"PRIVATE-TOKEN": "s3cret"},
|
||||
}),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = node.Read()
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, received)
|
||||
for _, header := range received {
|
||||
assert.Empty(t, header, "the token must not follow a redirect to another host")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTTPNodeAuthHeadersResolvedLazily makes sure a node can be built without
|
||||
// the credentials it would need to download: a run served from the cache, or an
|
||||
// offline one, never sends them.
|
||||
func TestHTTPNodeAuthHeadersResolvedLazily(t *testing.T) { //nolint:paralleltest // t.Setenv cannot be used in parallel tests
|
||||
node, err := NewHTTPNode("https://gitlab.com/Taskfile.yml", "", false,
|
||||
WithAuthHeaders(HostHeaders{
|
||||
"gitlab.com": {"PRIVATE-TOKEN": "${TASK_TEST_UNSET}"}, //nolint:gosec // an env var reference, not a credential
|
||||
}),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = node.authenticatedClient()
|
||||
require.EqualError(t, err, `remote auth for host "gitlab.com": header "PRIVATE-TOKEN": environment variable $TASK_TEST_UNSET is not set`)
|
||||
|
||||
t.Setenv("TASK_TEST_UNSET", "s3cret")
|
||||
client, err := node.authenticatedClient()
|
||||
require.NoError(t, err)
|
||||
assert.IsType(t, &authTransport{}, client.Transport)
|
||||
}
|
||||
|
||||
type roundTripperFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
func newRequest(t *testing.T, rawURL string) *http.Request {
|
||||
t.Helper()
|
||||
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, rawURL, nil)
|
||||
require.NoError(t, err)
|
||||
return req
|
||||
}
|
||||
|
||||
func newResponse(req *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusOK, Request: req, Header: http.Header{}}
|
||||
}
|
||||
|
||||
func mustHost(t *testing.T, rawURL string) string {
|
||||
t.Helper()
|
||||
parsed, err := url.Parse(rawURL)
|
||||
require.NoError(t, err)
|
||||
return parsed.Host
|
||||
}
|
||||
@@ -51,6 +51,7 @@ type (
|
||||
caCert string
|
||||
cert string
|
||||
certKey string
|
||||
authHeaders HostHeaders
|
||||
debugFunc DebugFunc
|
||||
promptFunc PromptFunc
|
||||
promptMutex sync.Mutex
|
||||
@@ -242,6 +243,19 @@ func (o *readerCertKeyOption) ApplyToReader(r *Reader) {
|
||||
r.certKey = o.certKey
|
||||
}
|
||||
|
||||
// WithReaderAuthHeaders sets the HTTP headers to send to each configured host.
|
||||
func WithReaderAuthHeaders(authHeaders HostHeaders) ReaderOption {
|
||||
return &readerAuthHeadersOption{authHeaders: authHeaders}
|
||||
}
|
||||
|
||||
type readerAuthHeadersOption struct {
|
||||
authHeaders HostHeaders
|
||||
}
|
||||
|
||||
func (o *readerAuthHeadersOption) ApplyToReader(r *Reader) {
|
||||
r.authHeaders = o.authHeaders
|
||||
}
|
||||
|
||||
// Read will read the Taskfile defined by the [Reader]'s [Node] and recurse
|
||||
// through any [ast.Includes] it finds, reading each included Taskfile and
|
||||
// building an [ast.TaskfileGraph] as it goes. If any errors occur, they will be
|
||||
@@ -286,7 +300,9 @@ func (r *Reader) isTrusted(uri string) bool {
|
||||
host := parsedURL.Host
|
||||
|
||||
// Check against each trusted pattern (exact match including port if provided)
|
||||
return slices.Contains(r.trustedHosts, host)
|
||||
return slices.ContainsFunc(r.trustedHosts, func(pattern string) bool {
|
||||
return hostMatches(pattern, host)
|
||||
})
|
||||
}
|
||||
|
||||
func (r *Reader) include(ctx context.Context, node Node) error {
|
||||
@@ -355,6 +371,7 @@ func (r *Reader) include(ctx context.Context, node Node) error {
|
||||
WithCACert(r.caCert),
|
||||
WithCert(r.cert),
|
||||
WithCertKey(r.certKey),
|
||||
WithAuthHeaders(r.authHeaders),
|
||||
)
|
||||
if err != nil {
|
||||
if include.Optional {
|
||||
|
||||
@@ -30,11 +30,19 @@ type Remote struct {
|
||||
CacheExpiry *time.Duration `yaml:"cache-expiry"`
|
||||
CacheDir *string `yaml:"cache-dir"`
|
||||
TrustedHosts []string `yaml:"trusted-hosts"`
|
||||
Auth []RemoteAuth `yaml:"auth"`
|
||||
CACert *string `yaml:"cacert"`
|
||||
Cert *string `yaml:"cert"`
|
||||
CertKey *string `yaml:"cert-key"`
|
||||
}
|
||||
|
||||
// RemoteAuth holds the HTTP headers to send when fetching a remote Taskfile
|
||||
// from a given host.
|
||||
type RemoteAuth struct {
|
||||
Host string `yaml:"host"`
|
||||
Headers map[string]string `yaml:"headers"`
|
||||
}
|
||||
|
||||
// Merge combines the current TaskRC with another TaskRC, prioritizing non-nil fields from the other TaskRC.
|
||||
func (t *TaskRC) Merge(other *TaskRC) {
|
||||
if other == nil {
|
||||
@@ -60,6 +68,7 @@ func (t *TaskRC) Merge(other *TaskRC) {
|
||||
slices.Sort(merged)
|
||||
t.Remote.TrustedHosts = slices.Compact(merged)
|
||||
}
|
||||
t.Remote.Auth = mergeAuth(t.Remote.Auth, other.Remote.Auth)
|
||||
t.Remote.CACert = cmp.Or(other.Remote.CACert, t.Remote.CACert)
|
||||
t.Remote.Cert = cmp.Or(other.Remote.Cert, t.Remote.Cert)
|
||||
t.Remote.CertKey = cmp.Or(other.Remote.CertKey, t.Remote.CertKey)
|
||||
@@ -73,3 +82,22 @@ func (t *TaskRC) Merge(other *TaskRC) {
|
||||
t.Failfast = cmp.Or(other.Failfast, t.Failfast)
|
||||
t.TempDir = cmp.Or(other.TempDir, t.TempDir)
|
||||
}
|
||||
|
||||
// mergeAuth unions two lists of [RemoteAuth] by host. An entry from other
|
||||
// replaces the entry for the same host as a whole, so that a closer
|
||||
// configuration file can redefine the headers of a host without inheriting the
|
||||
// ones it chose to drop.
|
||||
func mergeAuth(base, other []RemoteAuth) []RemoteAuth {
|
||||
if len(other) == 0 {
|
||||
return base
|
||||
}
|
||||
byHost := make(map[string]RemoteAuth, len(base)+len(other))
|
||||
for _, auth := range slices.Concat(base, other) {
|
||||
byHost[auth.Host] = auth
|
||||
}
|
||||
merged := slices.Collect(maps.Values(byHost))
|
||||
slices.SortFunc(merged, func(a, b RemoteAuth) int {
|
||||
return cmp.Compare(a.Host, b.Host)
|
||||
})
|
||||
return merged
|
||||
}
|
||||
|
||||
@@ -341,3 +341,61 @@ remote:
|
||||
assert.Equal(t, []string{"github.com", "gitlab.com"}, base.Remote.TrustedHosts)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetConfig_RemoteAuth(t *testing.T) { //nolint:paralleltest // cannot run in parallel
|
||||
_, _, localDir := setupDirs(t)
|
||||
|
||||
configYAML := `
|
||||
remote:
|
||||
auth:
|
||||
- host: gitlab.com
|
||||
headers:
|
||||
PRIVATE-TOKEN: ${GITLAB_TOKEN}
|
||||
- host: example.com:8080
|
||||
headers:
|
||||
Authorization: Bearer token
|
||||
`
|
||||
writeFile(t, localDir, ".taskrc.yml", configYAML)
|
||||
|
||||
cfg, err := GetConfig(localDir)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cfg)
|
||||
assert.Equal(t, []ast.RemoteAuth{
|
||||
{Host: "gitlab.com", Headers: map[string]string{"PRIVATE-TOKEN": "${GITLAB_TOKEN}"}}, //nolint:gosec // an env var reference, not a credential
|
||||
{Host: "example.com:8080", Headers: map[string]string{"Authorization": "Bearer token"}},
|
||||
}, cfg.Remote.Auth)
|
||||
}
|
||||
|
||||
func TestGetConfig_RemoteAuthMerge(t *testing.T) { //nolint:paralleltest // cannot run in parallel
|
||||
xdgConfigDir, homeDir, localDir := setupDirs(t)
|
||||
|
||||
writeFile(t, xdgConfigDir, "taskrc.yml", `
|
||||
remote:
|
||||
auth:
|
||||
- host: gitlab.com
|
||||
headers:
|
||||
PRIVATE-TOKEN: from-xdg
|
||||
X-Extra: from-xdg
|
||||
- host: example.com
|
||||
headers:
|
||||
Authorization: from-xdg
|
||||
`)
|
||||
|
||||
// The closer file redefines gitlab.com as a whole and leaves example.com
|
||||
// untouched.
|
||||
writeFile(t, homeDir, ".taskrc.yml", `
|
||||
remote:
|
||||
auth:
|
||||
- host: gitlab.com
|
||||
headers:
|
||||
JOB-TOKEN: from-home
|
||||
`)
|
||||
|
||||
cfg, err := GetConfig(localDir)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cfg)
|
||||
assert.Equal(t, []ast.RemoteAuth{
|
||||
{Host: "example.com", Headers: map[string]string{"Authorization": "from-xdg"}},
|
||||
{Host: "gitlab.com", Headers: map[string]string{"JOB-TOKEN": "from-home"}},
|
||||
}, cfg.Remote.Auth)
|
||||
}
|
||||
|
||||
@@ -300,6 +300,57 @@ task --trusted-hosts github.com,gitlab.com -t https://github.com/user/repo.git//
|
||||
task --trusted-hosts example.com:8080 -t https://example.com:8080/Taskfile.yml
|
||||
```
|
||||
|
||||
#### `remote.auth`
|
||||
|
||||
- **Type**: `array of objects`
|
||||
- **Default**: `[]` (empty list)
|
||||
- **Description**: HTTP headers to send when downloading a remote Taskfile from
|
||||
a given host
|
||||
|
||||
```yaml
|
||||
remote:
|
||||
auth:
|
||||
- host: gitlab.com
|
||||
headers:
|
||||
PRIVATE-TOKEN: ${GITLAB_TOKEN}
|
||||
- host: artifacts.example.com:8443
|
||||
headers:
|
||||
Authorization: Bearer ${ARTIFACTS_TOKEN}
|
||||
```
|
||||
|
||||
This is the recommended way to authenticate a remote Taskfile. Unlike a
|
||||
credential placed in the URL, the header never appears in your Taskfile, in the
|
||||
confirmation prompt or in an error message, so the include URL stays safe to
|
||||
commit.
|
||||
|
||||
Each entry applies to a single host, matched exactly and including the port if
|
||||
the URL has one — the same rule as
|
||||
[`remote.trusted-hosts`](#remote-trusted-hosts). Header values may reference
|
||||
environment variables with `${VAR}` or `$VAR`; write `$$` for a literal dollar
|
||||
sign. A variable is only read when Task actually contacts the host, and an
|
||||
undefined one is reported as an error instead of being sent as an empty header.
|
||||
|
||||
The header your server expects depends on the service:
|
||||
|
||||
| Service | Header |
|
||||
| ----------- | -------------------------------------- |
|
||||
| GitLab API | `PRIVATE-TOKEN` (or `JOB-TOKEN` in CI) |
|
||||
| GitHub API | `Authorization: Bearer <token>` |
|
||||
| Artifactory | `X-JFrog-Art-Api` |
|
||||
|
||||
There is no CLI flag or environment variable for this option: a token given on
|
||||
the command line would be visible to any process listing it.
|
||||
|
||||
::: warning
|
||||
|
||||
Headers are only sent to the host they are configured for. If that host answers
|
||||
with a redirect to another one, the request follows the redirect **without**
|
||||
them, and will likely fail — point the URL at the final host instead. Headers
|
||||
are also HTTP-only: a Taskfile fetched over `git` should authenticate with SSH
|
||||
or a git credential helper.
|
||||
|
||||
:::
|
||||
|
||||
#### `remote.cacert`
|
||||
|
||||
- **Type**: `string`
|
||||
@@ -354,6 +405,10 @@ remote:
|
||||
trusted-hosts:
|
||||
- github.com
|
||||
- gitlab.com
|
||||
auth:
|
||||
- host: gitlab.com
|
||||
headers:
|
||||
PRIVATE-TOKEN: ${GITLAB_TOKEN}
|
||||
cacert: ''
|
||||
cert: ''
|
||||
cert-key: ''
|
||||
|
||||
@@ -171,6 +171,11 @@ includes:
|
||||
my-remote-namespace: https://{{.TOKEN}}@raw.githubusercontent.com/my-org/my-repo/main/Taskfile.yml
|
||||
```
|
||||
|
||||
Prefer the [`remote.auth`](./reference/config.md#remote-auth) configuration
|
||||
option when the server accepts a header. A credential in the URL ends up in
|
||||
error messages and in the confirmation prompt, and the include can no longer be
|
||||
committed as-is.
|
||||
|
||||
## Special Variables
|
||||
|
||||
The file-path [special variables](../docs/reference/templating.md#file-paths)
|
||||
|
||||
@@ -49,6 +49,28 @@
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"type": "array",
|
||||
"description": "HTTP headers to send when downloading remote Taskfiles, per host.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"host": {
|
||||
"type": "string",
|
||||
"description": "Host the headers apply to, including the port if the URL has one (e.g., 'gitlab.com', 'example.com:8080')."
|
||||
},
|
||||
"headers": {
|
||||
"type": "object",
|
||||
"description": "Headers to send. Values may reference environment variables with ${VAR} or $VAR.",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["host", "headers"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
|
||||
Reference in New Issue
Block a user