From 8a93190060325fe8ed68ef1c413247f4665afe43 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Thu, 13 Aug 2026 15:14:31 +0200 Subject: [PATCH 1/8] 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. --- CHANGELOG.md | 8 + executor.go | 15 ++ internal/flags/flags.go | 19 ++ setup.go | 2 + taskfile/node_base.go | 21 +- taskfile/node_http.go | 8 +- taskfile/node_http_auth.go | 141 ++++++++++++ taskfile/node_http_auth_test.go | 236 ++++++++++++++++++++ taskfile/reader.go | 19 +- taskrc/ast/taskrc.go | 28 +++ taskrc/taskrc_test.go | 58 +++++ website/src/latest/docs/reference/config.md | 55 +++++ website/src/latest/docs/remote-taskfiles.md | 5 + website/src/public/schema-taskrc.json | 22 ++ 14 files changed, 628 insertions(+), 9 deletions(-) create mode 100644 taskfile/node_http_auth.go create mode 100644 taskfile/node_http_auth_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 413c8f7f12..e5c9037705 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/executor.go b/executor.go index 2ed4463beb..5bd3857c3c 100644 --- a/executor.go +++ b/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 { diff --git a/internal/flags/flags.go b/internal/flags/flags.go index 9e43d4a943..7f04fe2136 100644 --- a/internal/flags/flags.go +++ b/internal/flags/flags.go @@ -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 != "" { diff --git a/setup.go b/setup.go index e92848417a..d3e05aa6f2 100644 --- a/setup.go +++ b/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), ) diff --git a/taskfile/node_base.go b/taskfile/node_base.go index 2d81dded51..7d552e5ae6 100644 --- a/taskfile/node_base.go +++ b/taskfile/node_base.go @@ -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 + } +} diff --git a/taskfile/node_http.go b/taskfile/node_http.go index e8cbecba2d..3041f07d18 100644 --- a/taskfile/node_http.go +++ b/taskfile/node_http.go @@ -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 diff --git a/taskfile/node_http_auth.go b/taskfile/node_http_auth.go new file mode 100644 index 0000000000..fb8530d47f --- /dev/null +++ b/taskfile/node_http_auth.go @@ -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 +} diff --git a/taskfile/node_http_auth_test.go b/taskfile/node_http_auth_test.go new file mode 100644 index 0000000000..423adcc5bc --- /dev/null +++ b/taskfile/node_http_auth_test.go @@ -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 +} diff --git a/taskfile/reader.go b/taskfile/reader.go index fc5d6d30af..5ceec70ecc 100644 --- a/taskfile/reader.go +++ b/taskfile/reader.go @@ -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 { diff --git a/taskrc/ast/taskrc.go b/taskrc/ast/taskrc.go index 895b8f7ee8..b0db1a2667 100644 --- a/taskrc/ast/taskrc.go +++ b/taskrc/ast/taskrc.go @@ -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 +} diff --git a/taskrc/taskrc_test.go b/taskrc/taskrc_test.go index dde9f9c58c..7f61d41564 100644 --- a/taskrc/taskrc_test.go +++ b/taskrc/taskrc_test.go @@ -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) +} diff --git a/website/src/latest/docs/reference/config.md b/website/src/latest/docs/reference/config.md index ff6941178c..26b8033339 100644 --- a/website/src/latest/docs/reference/config.md +++ b/website/src/latest/docs/reference/config.md @@ -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 ` | +| 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: '' diff --git a/website/src/latest/docs/remote-taskfiles.md b/website/src/latest/docs/remote-taskfiles.md index 4d54918e61..613376c8ce 100644 --- a/website/src/latest/docs/remote-taskfiles.md +++ b/website/src/latest/docs/remote-taskfiles.md @@ -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) diff --git a/website/src/public/schema-taskrc.json b/website/src/public/schema-taskrc.json index d12f4460bc..9f4069d838 100644 --- a/website/src/public/schema-taskrc.json +++ b/website/src/public/schema-taskrc.json @@ -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 From 8d3a20ef33449de59f0c978bc0275cb0a471afd4 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Thu, 13 Aug 2026 18:06:12 +0200 Subject: [PATCH 2/8] chore(remote): trim the remote.auth comments --- internal/flags/flags.go | 8 +++---- taskfile/node_base.go | 3 +-- taskfile/node_http_auth.go | 41 +++++++++++---------------------- taskfile/node_http_auth_test.go | 13 ++++------- taskrc/ast/taskrc.go | 7 +++--- 5 files changed, 26 insertions(+), 46 deletions(-) diff --git a/internal/flags/flags.go b/internal/flags/flags.go index 7f04fe2136..1a3a791e6a 100644 --- a/internal/flags/flags.go +++ b/internal/flags/flags.go @@ -166,8 +166,7 @@ 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. + // No flag: a token on the command line is visible to any process listing it. RemoteAuth = remoteAuth(config) // Gentle force experiment will override the force flag and add a new force-all flag @@ -316,9 +315,8 @@ 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. +// remoteAuth flattens the configured entries into a lookup by host, the last +// entry winning as it does when configuration files are merged. func remoteAuth(config *taskrcast.TaskRC) map[string]map[string]string { if config == nil || len(config.Remote.Auth) == 0 { return nil diff --git a/taskfile/node_base.go b/taskfile/node_base.go index 7d552e5ae6..9a8cafa7fd 100644 --- a/taskfile/node_base.go +++ b/taskfile/node_base.go @@ -77,8 +77,7 @@ func WithCertKey(certKey string) NodeOption { } } -// WithAuthHeaders sets the HTTP headers to send when the node's host matches -// one of the configured ones. +// WithAuthHeaders sets the HTTP headers to send, keyed by host. func WithAuthHeaders(authHeaders HostHeaders) NodeOption { return func(node *baseNode) { node.authHeaders = authHeaders diff --git a/taskfile/node_http_auth.go b/taskfile/node_http_auth.go index fb8530d47f..9048b83f7b 100644 --- a/taskfile/node_http_auth.go +++ b/taskfile/node_http_auth.go @@ -11,11 +11,9 @@ import ( ) // 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. +// Taskfile from it. Values may reference environment variables. 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 @@ -23,15 +21,11 @@ type authTransport struct { } 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. + // Re-checked per request: a redirect goes 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) @@ -39,10 +33,8 @@ func (t *authTransport) RoundTrip(req *http.Request) (*http.Response, error) { 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. +// authenticatedClient resolves the headers on each read, not when the node is +// built, so that a run served from the cache needs no credentials. func (node *HTTPNode) authenticatedClient() (*http.Client, error) { headers, err := resolveAuthHeaders(node.authHeaders, node.url.Host) if err != nil { @@ -54,8 +46,7 @@ func (node *HTTPNode) authenticatedClient() (*http.Client, error) { 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 +// withAuthHeaders copies rather than mutates: 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 @@ -67,9 +58,8 @@ func withAuthHeaders(client *http.Client, host string, headers map[string]string 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. +// resolveAuthHeaders returns the expanded headers configured for host, or nil +// when no entry matches. func resolveAuthHeaders(hostHeaders HostHeaders, host string) (map[string]string, error) { var headers map[string]string for pattern, patternHeaders := range hostHeaders { @@ -96,10 +86,9 @@ func resolveAuthHeaders(hostHeaders HostHeaders, host string) (map[string]string 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 `$$`. +// expandEnv replaces ${VAR} and $VAR references; `$$` is a literal dollar +// sign. An undefined variable is an error, not an empty header that would only +// surface as an opaque 401. func expandEnv(value string) (string, error) { var missing []string expanded := os.Expand(value, func(name string) string { @@ -119,9 +108,8 @@ func expandEnv(value string) (string, error) { 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. +// validateHeaderName reports the offending header by name, where the transport +// would only refuse the request. func validateHeaderName(name string) error { if name == "" { return fmt.Errorf("header name cannot be empty") @@ -134,8 +122,7 @@ func validateHeaderName(name string) error { 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. +// hostMatches compares exactly, port included, as trusted hosts do. func hostMatches(pattern, host string) bool { return pattern == host } diff --git a/taskfile/node_http_auth_test.go b/taskfile/node_http_auth_test.go index 423adcc5bc..03b81444b7 100644 --- a/taskfile/node_http_auth_test.go +++ b/taskfile/node_http_auth_test.go @@ -128,9 +128,8 @@ func TestWithAuthHeadersDoesNotMutateTheDefaultClient(t *testing.T) { 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. +// Both requests must carry the headers: RemoteExists probes with HEAD before +// ReadContext issues the GET. 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) { @@ -158,8 +157,7 @@ func TestHTTPNodeAuthHeaders(t *testing.T) { //nolint:paralleltest // t.Setenv c 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. +// A server bouncing the request must not get the credentials forwarded to it. func TestHTTPNodeAuthHeadersNotSentOnRedirect(t *testing.T) { t.Parallel() @@ -191,9 +189,8 @@ func TestHTTPNodeAuthHeadersNotSentOnRedirect(t *testing.T) { } } -// 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. +// A node must build without the credentials it would need to download, so that +// cached and offline runs do not require 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{ diff --git a/taskrc/ast/taskrc.go b/taskrc/ast/taskrc.go index b0db1a2667..7975446d3a 100644 --- a/taskrc/ast/taskrc.go +++ b/taskrc/ast/taskrc.go @@ -83,10 +83,9 @@ func (t *TaskRC) Merge(other *TaskRC) { 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. +// mergeAuth unions both lists by host. An entry from other replaces the one +// for the same host as a whole, so a closer file can drop a header rather than +// inherit it. func mergeAuth(base, other []RemoteAuth) []RemoteAuth { if len(other) == 0 { return base From a41df4a127b950bfe98d29cbe44e27e5e5779380 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Thu, 20 Aug 2026 16:50:43 +0200 Subject: [PATCH 3/8] fix(remote): report a 401 instead of a missing Taskfile RemoteExists treated every non-200 as an absent file, so a server refusing the credentials ended up as "No Taskfile found", sending the user to check the URL rather than the token. A 401 now stops the search and reports the status code; the default names need the same credentials, so trying them would only add rejected requests. A 403 is left alone: it is also what a server without directory listing answers for a readable directory. That message being correct, the expansion no longer needs to refuse an undefined variable: os.ExpandEnv is inlined and expandEnv is gone. The `$$` escape goes with it, so a literal value can no longer hold a `$` followed by a name; a secret carried in an environment variable is unaffected, as os.Expand never rescans what it substituted. Header names are validated with httpguts.ValidHeaderFieldName, the table net/http itself uses, rather than a denylist that let X-Foo(bar) through. golang.org/x/net was already in the module graph, so tidy only moves it to the direct block. Finally, node_http_auth.go becomes http_auth.go: the node_ prefix is for files defining a Node type, and this one holds the auth concern of HTTPNode plus hostMatches, which reader.go uses for trusted hosts. --- CHANGELOG.md | 7 ++ go.mod | 2 +- taskfile/{node_http_auth.go => http_auth.go} | 51 +++-------- ...de_http_auth_test.go => http_auth_test.go} | 35 ++++---- taskfile/taskfile.go | 7 ++ taskfile/taskfile_test.go | 84 +++++++++++++++++++ website/src/latest/docs/reference/config.md | 7 +- 7 files changed, 133 insertions(+), 60 deletions(-) rename taskfile/{node_http_auth.go => http_auth.go} (62%) rename taskfile/{node_http_auth_test.go => http_auth_test.go} (86%) create mode 100644 taskfile/taskfile_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index e5c9037705..bf2940f7b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,13 @@ where it would leak into error messages and the confirmation prompt (#2329 by @vmaerten). +### 🐛 Fixes + +- Fixed a remote Taskfile whose server refuses the credentials being reported as + a missing Taskfile. A `401` now stops the search and reports the status code, + instead of retrying every default Taskfile name and concluding that no + Taskfile exists (#2329 by @vmaerten). + ### 📦 Package API - Bumped the minimum Go version to 1.26. Task follows Go's two-latest support diff --git a/go.mod b/go.mod index c6fd21f5bb..c6d9e8eb5b 100644 --- a/go.mod +++ b/go.mod @@ -28,6 +28,7 @@ require ( github.com/stretchr/testify v1.11.1 github.com/zeebo/xxh3 v1.1.0 go.yaml.in/yaml/v3 v3.0.4 + golang.org/x/net v0.58.0 golang.org/x/sync v0.22.0 golang.org/x/term v0.45.0 mvdan.cc/sh/moreinterp v0.0.0-20260817215856-d6550df7ed8d @@ -121,7 +122,6 @@ require ( go.opentelemetry.io/otel/trace v1.45.0 // indirect golang.org/x/crypto v0.55.0 // indirect golang.org/x/exp v0.0.0-20260718201538-764159d718ef // indirect - golang.org/x/net v0.58.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.41.0 // indirect diff --git a/taskfile/node_http_auth.go b/taskfile/http_auth.go similarity index 62% rename from taskfile/node_http_auth.go rename to taskfile/http_auth.go index 9048b83f7b..b187106049 100644 --- a/taskfile/node_http_auth.go +++ b/taskfile/http_auth.go @@ -7,7 +7,8 @@ import ( "net/http" "os" "slices" - "strings" + + "golang.org/x/net/http/httpguts" ) // HostHeaders maps a host to the HTTP headers to send when fetching a remote @@ -33,8 +34,8 @@ func (t *authTransport) RoundTrip(req *http.Request) (*http.Response, error) { return t.base.RoundTrip(req) } -// authenticatedClient resolves the headers on each read, not when the node is -// built, so that a run served from the cache needs no credentials. +// authenticatedClient resolves on each read, not at build time, so a cached +// run needs no credentials. func (node *HTTPNode) authenticatedClient() (*http.Client, error) { headers, err := resolveAuthHeaders(node.authHeaders, node.url.Host) if err != nil { @@ -58,8 +59,7 @@ func withAuthHeaders(client *http.Client, host string, headers map[string]string return &authenticated } -// resolveAuthHeaders returns the expanded headers configured for host, or nil -// when no entry matches. +// resolveAuthHeaders returns the expanded headers for host, or nil if none. func resolveAuthHeaders(hostHeaders HostHeaders, host string) (map[string]string, error) { var headers map[string]string for pattern, patternHeaders := range hostHeaders { @@ -77,47 +77,16 @@ func resolveAuthHeaders(hostHeaders HostHeaders, host string) (map[string]string 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 + resolved[name] = os.ExpandEnv(headers[name]) } return resolved, nil } -// expandEnv replaces ${VAR} and $VAR references; `$$` is a literal dollar -// sign. An undefined variable is an error, not an empty header that would only -// surface as an opaque 401. -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 reports the offending header by name, where the transport -// would only refuse the request. +// validateHeaderName names the offending header; ReadContext discards the +// transport's own error. 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) + if !httpguts.ValidHeaderFieldName(name) { + return fmt.Errorf("invalid header name %q", name) } return nil } diff --git a/taskfile/node_http_auth_test.go b/taskfile/http_auth_test.go similarity index 86% rename from taskfile/node_http_auth_test.go rename to taskfile/http_auth_test.go index 03b81444b7..30891cbc51 100644 --- a/taskfile/node_http_auth_test.go +++ b/taskfile/http_auth_test.go @@ -55,22 +55,28 @@ func TestResolveAuthHeaders(t *testing.T) { //nolint:paralleltest // t.Setenv ca want: map[string]string{"Authorization": "Bearer s3cret"}, }, { - name: "escaped dollar sign", - hostHeaders: HostHeaders{"gitlab.com": {"PRIVATE-TOKEN": "lit$$eral"}}, + name: "undefined environment variable expands to nothing", + hostHeaders: HostHeaders{"gitlab.com": {"PRIVATE-TOKEN": "${TASK_TEST_UNSET}"}}, //nolint:gosec // an env var reference, not a credential host: "gitlab.com", - want: map[string]string{"PRIVATE-TOKEN": "lit$eral"}, + want: map[string]string{"PRIVATE-TOKEN": ""}, }, { - name: "undefined environment variable", - hostHeaders: HostHeaders{"gitlab.com": {"PRIVATE-TOKEN": "${TASK_TEST_UNSET}"}}, //nolint:gosec // an env var reference, not a credential + name: "header name with a space", + hostHeaders: HostHeaders{"gitlab.com": {"PRIVATE TOKEN": "token"}}, host: "gitlab.com", - wantErr: `remote auth for host "gitlab.com": header "PRIVATE-TOKEN": environment variable $TASK_TEST_UNSET is not set`, + wantErr: `remote auth for host "gitlab.com": invalid header name "PRIVATE TOKEN"`, }, { - name: "invalid header name", - hostHeaders: HostHeaders{"gitlab.com": {"PRIVATE TOKEN": "token"}}, + name: "header name outside the HTTP token grammar", + hostHeaders: HostHeaders{"gitlab.com": {"X-Foo(bar)": "token"}}, host: "gitlab.com", - wantErr: `remote auth for host "gitlab.com": header name "PRIVATE TOKEN" contains invalid characters`, + wantErr: `remote auth for host "gitlab.com": invalid header name "X-Foo(bar)"`, + }, + { + name: "empty header name", + hostHeaders: HostHeaders{"gitlab.com": {"": "token"}}, + host: "gitlab.com", + wantErr: `remote auth for host "gitlab.com": invalid header name ""`, }, } @@ -194,18 +200,17 @@ func TestHTTPNodeAuthHeadersNotSentOnRedirect(t *testing.T) { 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 + "gitlab.com": {"PRIVATE-TOKEN": "${TASK_TEST_LAZY}"}, //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`) + // Defined only after the node was built: the value must still be picked up. + t.Setenv("TASK_TEST_LAZY", "s3cret") - t.Setenv("TASK_TEST_UNSET", "s3cret") - client, err := node.authenticatedClient() + headers, err := resolveAuthHeaders(node.authHeaders, node.url.Host) require.NoError(t, err) - assert.IsType(t, &authTransport{}, client.Transport) + assert.Equal(t, map[string]string{"PRIVATE-TOKEN": "s3cret"}, headers) } type roundTripperFunc func(*http.Request) (*http.Response, error) diff --git a/taskfile/taskfile.go b/taskfile/taskfile.go index 4251a20528..00e25c679c 100644 --- a/taskfile/taskfile.go +++ b/taskfile/taskfile.go @@ -66,6 +66,13 @@ func RemoteExists(ctx context.Context, u url.URL, client *http.Client) (*url.URL return &u, nil } + // The default names need the same credentials, so trying them would only + // add rejected requests. A 403 is left alone: it is also what a server + // without directory listing answers for a readable directory. + if resp.StatusCode == http.StatusUnauthorized { + return nil, errors.TaskfileFetchFailedError{URI: u.Redacted(), HTTPStatusCode: resp.StatusCode} + } + // If the request was not successful, append the default Taskfile names to // the URL and return the URL of the first successful request for _, taskfile := range DefaultTaskfiles { diff --git a/taskfile/taskfile_test.go b/taskfile/taskfile_test.go new file mode 100644 index 0000000000..79eb2cddca --- /dev/null +++ b/taskfile/taskfile_test.go @@ -0,0 +1,84 @@ +package taskfile + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/go-task/task/v3/errors" +) + +// alwaysStatus answers every request with the given status. +func alwaysStatus(t *testing.T, status int) (*url.URL, *int) { + t.Helper() + var requests int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + w.WriteHeader(status) + })) + t.Cleanup(srv.Close) + return mustParse(t, srv.URL), &requests +} + +func TestRemoteExistsUnauthorized(t *testing.T) { + t.Parallel() + + u, requests := alwaysStatus(t, http.StatusUnauthorized) + _, err := RemoteExists(t.Context(), *u, http.DefaultClient) + + var fetchErr errors.TaskfileFetchFailedError + require.ErrorAs(t, err, &fetchErr) + assert.Equal(t, http.StatusUnauthorized, fetchErr.HTTPStatusCode) + assert.Equal(t, 1, *requests) +} + +// A 403 is ambiguous, so it keeps the existing behaviour. +func TestRemoteExistsForbiddenEverywhere(t *testing.T) { + t.Parallel() + + u, requests := alwaysStatus(t, http.StatusForbidden) + _, err := RemoteExists(t.Context(), *u, http.DefaultClient) + + var notFoundErr errors.TaskfileNotFoundError + assert.ErrorAs(t, err, ¬FoundErr) + assert.Greater(t, *requests, 1) +} + +func TestRemoteExistsForbiddenDirectoryWithReadableTaskfile(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/Taskfile.yml" { + w.WriteHeader(http.StatusForbidden) + return + } + w.Header().Set("Content-Type", "text/yaml") + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + found, err := RemoteExists(t.Context(), *mustParse(t, srv.URL), http.DefaultClient) + require.NoError(t, err) + assert.Equal(t, "/Taskfile.yml", found.Path) +} + +func TestRemoteExistsNotFound(t *testing.T) { + t.Parallel() + + u, _ := alwaysStatus(t, http.StatusNotFound) + _, err := RemoteExists(t.Context(), *u, http.DefaultClient) + + var notFoundErr errors.TaskfileNotFoundError + assert.ErrorAs(t, err, ¬FoundErr) +} + +func mustParse(t *testing.T, rawURL string) *url.URL { + t.Helper() + parsed, err := url.Parse(rawURL) + require.NoError(t, err) + return parsed +} diff --git a/website/src/latest/docs/reference/config.md b/website/src/latest/docs/reference/config.md index 26b8033339..83c66f334c 100644 --- a/website/src/latest/docs/reference/config.md +++ b/website/src/latest/docs/reference/config.md @@ -326,9 +326,10 @@ 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. +environment variables with `${VAR}` or `$VAR`, read when Task contacts the host. +An undefined variable expands to nothing, so the header is sent empty and the +server rejects it — prefer an environment variable over a literal value, which +cannot contain a `$` followed by a name. The header your server expects depends on the service: From 1b7e67d1f93e55fd36ca05a48c4f86885cda23e9 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Thu, 20 Aug 2026 18:12:26 +0200 Subject: [PATCH 4/8] refactor(remote): carry the auth headers as taskfile.HostHeaders map[string]map[string]string named neither key. The type already existed in taskfile; package task reaches it through setup.go, so only an import was missing. Callers keep passing a plain map literal, which stays assignable to a named map type. --- executor.go | 7 ++++--- internal/flags/flags.go | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/executor.go b/executor.go index 5bd3857c3c..49a367eeda 100644 --- a/executor.go +++ b/executor.go @@ -13,6 +13,7 @@ import ( "github.com/go-task/task/v3/internal/logger" "github.com/go-task/task/v3/internal/output" "github.com/go-task/task/v3/internal/sort" + "github.com/go-task/task/v3/taskfile" "github.com/go-task/task/v3/taskfile/ast" ) @@ -36,7 +37,7 @@ type ( Download bool Offline bool TrustedHosts []string - RemoteAuth map[string]map[string]string + RemoteAuth taskfile.HostHeaders Timeout time.Duration CacheExpiryDuration time.Duration RemoteCacheDir string @@ -280,12 +281,12 @@ func (o *trustedHostsOption) ApplyToExecutor(e *Executor) { // 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 { +func WithRemoteAuth(remoteAuth taskfile.HostHeaders) ExecutorOption { return &remoteAuthOption{remoteAuth} } type remoteAuthOption struct { - remoteAuth map[string]map[string]string + remoteAuth taskfile.HostHeaders } func (o *remoteAuthOption) ApplyToExecutor(e *Executor) { diff --git a/internal/flags/flags.go b/internal/flags/flags.go index 1a3a791e6a..a6b249b5ab 100644 --- a/internal/flags/flags.go +++ b/internal/flags/flags.go @@ -16,6 +16,7 @@ import ( "github.com/go-task/task/v3/experiments" "github.com/go-task/task/v3/internal/env" "github.com/go-task/task/v3/internal/sort" + "github.com/go-task/task/v3/taskfile" "github.com/go-task/task/v3/taskfile/ast" "github.com/go-task/task/v3/taskrc" taskrcast "github.com/go-task/task/v3/taskrc/ast" @@ -79,7 +80,7 @@ var ( Download bool Offline bool TrustedHosts []string - RemoteAuth map[string]map[string]string + RemoteAuth taskfile.HostHeaders ClearCache bool Timeout time.Duration CacheExpiryDuration time.Duration @@ -317,11 +318,11 @@ func (o *flagsOption) ApplyToExecutor(e *task.Executor) { // remoteAuth flattens the configured entries into a lookup by host, the last // entry winning as it does when configuration files are merged. -func remoteAuth(config *taskrcast.TaskRC) map[string]map[string]string { +func remoteAuth(config *taskrcast.TaskRC) taskfile.HostHeaders { if config == nil || len(config.Remote.Auth) == 0 { return nil } - byHost := make(map[string]map[string]string, len(config.Remote.Auth)) + byHost := make(taskfile.HostHeaders, len(config.Remote.Auth)) for _, auth := range config.Remote.Auth { byHost[auth.Host] = auth.Headers } From d10460ab612e723d26fc269828df27a7ba7ebd38 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sun, 23 Aug 2026 12:22:38 +0200 Subject: [PATCH 5/8] docs(remote): document remote.auth under next instead of latest The rebase landed these additions in the frozen copy served for the released version, because the commits predated the split into next and latest. --- website/src/latest/docs/reference/config.md | 56 --------------------- website/src/latest/docs/remote-taskfiles.md | 5 -- website/src/next/docs/reference/config.md | 56 +++++++++++++++++++++ website/src/next/docs/remote-taskfiles.md | 5 ++ 4 files changed, 61 insertions(+), 61 deletions(-) diff --git a/website/src/latest/docs/reference/config.md b/website/src/latest/docs/reference/config.md index 83c66f334c..ff6941178c 100644 --- a/website/src/latest/docs/reference/config.md +++ b/website/src/latest/docs/reference/config.md @@ -300,58 +300,6 @@ 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`, read when Task contacts the host. -An undefined variable expands to nothing, so the header is sent empty and the -server rejects it — prefer an environment variable over a literal value, which -cannot contain a `$` followed by a name. - -The header your server expects depends on the service: - -| Service | Header | -| ----------- | -------------------------------------- | -| GitLab API | `PRIVATE-TOKEN` (or `JOB-TOKEN` in CI) | -| GitHub API | `Authorization: Bearer ` | -| 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` @@ -406,10 +354,6 @@ remote: trusted-hosts: - github.com - gitlab.com - auth: - - host: gitlab.com - headers: - PRIVATE-TOKEN: ${GITLAB_TOKEN} cacert: '' cert: '' cert-key: '' diff --git a/website/src/latest/docs/remote-taskfiles.md b/website/src/latest/docs/remote-taskfiles.md index 613376c8ce..4d54918e61 100644 --- a/website/src/latest/docs/remote-taskfiles.md +++ b/website/src/latest/docs/remote-taskfiles.md @@ -171,11 +171,6 @@ 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) diff --git a/website/src/next/docs/reference/config.md b/website/src/next/docs/reference/config.md index ff6941178c..83c66f334c 100644 --- a/website/src/next/docs/reference/config.md +++ b/website/src/next/docs/reference/config.md @@ -300,6 +300,58 @@ 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`, read when Task contacts the host. +An undefined variable expands to nothing, so the header is sent empty and the +server rejects it — prefer an environment variable over a literal value, which +cannot contain a `$` followed by a name. + +The header your server expects depends on the service: + +| Service | Header | +| ----------- | -------------------------------------- | +| GitLab API | `PRIVATE-TOKEN` (or `JOB-TOKEN` in CI) | +| GitHub API | `Authorization: Bearer ` | +| 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 +406,10 @@ remote: trusted-hosts: - github.com - gitlab.com + auth: + - host: gitlab.com + headers: + PRIVATE-TOKEN: ${GITLAB_TOKEN} cacert: '' cert: '' cert-key: '' diff --git a/website/src/next/docs/remote-taskfiles.md b/website/src/next/docs/remote-taskfiles.md index 4d54918e61..613376c8ce 100644 --- a/website/src/next/docs/remote-taskfiles.md +++ b/website/src/next/docs/remote-taskfiles.md @@ -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) From 433e2bb2ee25d4991ec7ac6e50993847e74f3d50 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sun, 23 Aug 2026 12:24:58 +0200 Subject: [PATCH 6/8] docs(remote): add the remote.auth schema to next-schema-taskrc.json Same next/latest split as the docs: schema.json and schema-taskrc.json are the frozen copies served for the released version. --- website/src/public/next-schema-taskrc.json | 22 ++++++++++++++++++++++ website/src/public/schema-taskrc.json | 22 ---------------------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/website/src/public/next-schema-taskrc.json b/website/src/public/next-schema-taskrc.json index d12f4460bc..9f4069d838 100644 --- a/website/src/public/next-schema-taskrc.json +++ b/website/src/public/next-schema-taskrc.json @@ -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 diff --git a/website/src/public/schema-taskrc.json b/website/src/public/schema-taskrc.json index 9f4069d838..d12f4460bc 100644 --- a/website/src/public/schema-taskrc.json +++ b/website/src/public/schema-taskrc.json @@ -49,28 +49,6 @@ "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 From 8fc344f6ccb12af85c1270ee7039374d80128781 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sun, 23 Aug 2026 12:25:36 +0200 Subject: [PATCH 7/8] test(remote): drop the RemoteExists status tests --- taskfile/taskfile_test.go | 84 --------------------------------------- 1 file changed, 84 deletions(-) delete mode 100644 taskfile/taskfile_test.go diff --git a/taskfile/taskfile_test.go b/taskfile/taskfile_test.go deleted file mode 100644 index 79eb2cddca..0000000000 --- a/taskfile/taskfile_test.go +++ /dev/null @@ -1,84 +0,0 @@ -package taskfile - -import ( - "net/http" - "net/http/httptest" - "net/url" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/go-task/task/v3/errors" -) - -// alwaysStatus answers every request with the given status. -func alwaysStatus(t *testing.T, status int) (*url.URL, *int) { - t.Helper() - var requests int - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - requests++ - w.WriteHeader(status) - })) - t.Cleanup(srv.Close) - return mustParse(t, srv.URL), &requests -} - -func TestRemoteExistsUnauthorized(t *testing.T) { - t.Parallel() - - u, requests := alwaysStatus(t, http.StatusUnauthorized) - _, err := RemoteExists(t.Context(), *u, http.DefaultClient) - - var fetchErr errors.TaskfileFetchFailedError - require.ErrorAs(t, err, &fetchErr) - assert.Equal(t, http.StatusUnauthorized, fetchErr.HTTPStatusCode) - assert.Equal(t, 1, *requests) -} - -// A 403 is ambiguous, so it keeps the existing behaviour. -func TestRemoteExistsForbiddenEverywhere(t *testing.T) { - t.Parallel() - - u, requests := alwaysStatus(t, http.StatusForbidden) - _, err := RemoteExists(t.Context(), *u, http.DefaultClient) - - var notFoundErr errors.TaskfileNotFoundError - assert.ErrorAs(t, err, ¬FoundErr) - assert.Greater(t, *requests, 1) -} - -func TestRemoteExistsForbiddenDirectoryWithReadableTaskfile(t *testing.T) { - t.Parallel() - - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/Taskfile.yml" { - w.WriteHeader(http.StatusForbidden) - return - } - w.Header().Set("Content-Type", "text/yaml") - w.WriteHeader(http.StatusOK) - })) - defer srv.Close() - - found, err := RemoteExists(t.Context(), *mustParse(t, srv.URL), http.DefaultClient) - require.NoError(t, err) - assert.Equal(t, "/Taskfile.yml", found.Path) -} - -func TestRemoteExistsNotFound(t *testing.T) { - t.Parallel() - - u, _ := alwaysStatus(t, http.StatusNotFound) - _, err := RemoteExists(t.Context(), *u, http.DefaultClient) - - var notFoundErr errors.TaskfileNotFoundError - assert.ErrorAs(t, err, ¬FoundErr) -} - -func mustParse(t *testing.T, rawURL string) *url.URL { - t.Helper() - parsed, err := url.Parse(rawURL) - require.NoError(t, err) - return parsed -} From bff4f97d7e5b98690e121e6d28ef457a586989f3 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sun, 23 Aug 2026 12:35:55 +0200 Subject: [PATCH 8/8] refactor(remote): template header values instead of expanding ${VAR} MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aligns the syntax with the rest of Task, and lets functions compose: a Basic credential no longer needs its base64 computed by hand. The strict expansion this replaces was already gone, so nothing is lost by the switch. Only functions resolve — the configuration file is read before any Taskfile, so {{.VAR}} has nothing to read and produces an empty header. That is documented next to the option. --- CHANGELOG.md | 8 ++--- taskfile/http_auth.go | 11 ++++-- taskfile/http_auth_test.go | 39 ++++++++++++++++++---- website/src/next/docs/reference/config.md | 32 ++++++++++++++---- website/src/public/next-schema-taskrc.json | 2 +- 5 files changed, 71 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf2940f7b3..2969fa6375 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,10 @@ ### 🚀 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). + remote Taskfile, configured per host. Header values support templating + functions, e.g. `{{env "GITLAB_TOKEN"}}`. This keeps the credential out of the + include URL, where it would leak into error messages and the confirmation + prompt (#2329 by @vmaerten). ### 🐛 Fixes diff --git a/taskfile/http_auth.go b/taskfile/http_auth.go index b187106049..7159452bc6 100644 --- a/taskfile/http_auth.go +++ b/taskfile/http_auth.go @@ -5,14 +5,15 @@ import ( "fmt" "maps" "net/http" - "os" "slices" "golang.org/x/net/http/httpguts" + + "github.com/go-task/task/v3/internal/templater" ) // HostHeaders maps a host to the HTTP headers to send when fetching a remote -// Taskfile from it. Values may reference environment variables. +// Taskfile from it. Values are templated, but no variables are available. type HostHeaders map[string]map[string]string type authTransport struct { @@ -72,12 +73,16 @@ func resolveAuthHeaders(hostHeaders HostHeaders, host string) (map[string]string return nil, nil } + cache := &templater.Cache{} 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) } - resolved[name] = os.ExpandEnv(headers[name]) + resolved[name] = templater.Replace(headers[name], cache) + } + if err := cache.Err(); err != nil { + return nil, fmt.Errorf(`remote auth for host %q: %w`, host, err) } return resolved, nil } diff --git a/taskfile/http_auth_test.go b/taskfile/http_auth_test.go index 30891cbc51..7092cabf1a 100644 --- a/taskfile/http_auth_test.go +++ b/taskfile/http_auth_test.go @@ -41,25 +41,52 @@ func TestResolveAuthHeaders(t *testing.T) { //nolint:paralleltest // t.Setenv ca 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 + name: "environment variable", + hostHeaders: HostHeaders{"gitlab.com": {"PRIVATE-TOKEN": `{{env "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"}}, + hostHeaders: HostHeaders{"gitlab.com": {"Authorization": `Bearer {{env "TASK_TEST_TOKEN"}}`}}, host: "gitlab.com", env: map[string]string{"TASK_TEST_TOKEN": "s3cret"}, want: map[string]string{"Authorization": "Bearer s3cret"}, }, { name: "undefined environment variable expands to nothing", - hostHeaders: HostHeaders{"gitlab.com": {"PRIVATE-TOKEN": "${TASK_TEST_UNSET}"}}, //nolint:gosec // an env var reference, not a credential + hostHeaders: HostHeaders{"gitlab.com": {"PRIVATE-TOKEN": `{{env "TASK_TEST_UNSET"}}`}}, //nolint:gosec // an env var reference, not a credential host: "gitlab.com", want: map[string]string{"PRIVATE-TOKEN": ""}, }, + { + name: "functions compose, so Basic auth needs no manual base64", + hostHeaders: HostHeaders{"gitlab.com": {"Authorization": `Basic {{ printf "%s:%s" (env "TASK_TEST_USER") (env "TASK_TEST_TOKEN") | b64enc }}`}}, + host: "gitlab.com", + env: map[string]string{"TASK_TEST_USER": "alice", "TASK_TEST_TOKEN": "s3cret"}, + want: map[string]string{"Authorization": "Basic YWxpY2U6czNjcmV0"}, + }, + { + // The .taskrc is read before any Taskfile, so no variable exists. + name: "a variable reference resolves to nothing", + hostHeaders: HostHeaders{"gitlab.com": {"PRIVATE-TOKEN": "{{.TASK_TEST_TOKEN}}"}}, //nolint:gosec // a template, not a credential + host: "gitlab.com", + env: map[string]string{"TASK_TEST_TOKEN": "s3cret"}, + want: map[string]string{"PRIVATE-TOKEN": ""}, + }, + { + name: "a literal value is left untouched", + hostHeaders: HostHeaders{"gitlab.com": {"PRIVATE-TOKEN": "p$ssw0rd"}}, //nolint:gosec // a test fixture + host: "gitlab.com", + want: map[string]string{"PRIVATE-TOKEN": "p$ssw0rd"}, + }, + { + name: "malformed template", + hostHeaders: HostHeaders{"gitlab.com": {"PRIVATE-TOKEN": `{{env "TASK_TEST_TOKEN"`}}, //nolint:gosec // a template, not a credential + host: "gitlab.com", + wantErr: `remote auth for host "gitlab.com": template: :1: unclosed action`, + }, { name: "header name with a space", hostHeaders: HostHeaders{"gitlab.com": {"PRIVATE TOKEN": "token"}}, @@ -152,7 +179,7 @@ func TestHTTPNodeAuthHeaders(t *testing.T) { //nolint:paralleltest // t.Setenv c 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 + mustHost(t, srv.URL): {"PRIVATE-TOKEN": `{{env "TASK_TEST_TOKEN"}}`}, //nolint:gosec // an env var reference, not a credential }), ) require.NoError(t, err) @@ -200,7 +227,7 @@ func TestHTTPNodeAuthHeadersNotSentOnRedirect(t *testing.T) { 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_LAZY}"}, //nolint:gosec // an env var reference, not a credential + "gitlab.com": {"PRIVATE-TOKEN": `{{env "TASK_TEST_LAZY"}}`}, //nolint:gosec // an env var reference, not a credential }), ) require.NoError(t, err) diff --git a/website/src/next/docs/reference/config.md b/website/src/next/docs/reference/config.md index 83c66f334c..5552779c84 100644 --- a/website/src/next/docs/reference/config.md +++ b/website/src/next/docs/reference/config.md @@ -312,10 +312,10 @@ remote: auth: - host: gitlab.com headers: - PRIVATE-TOKEN: ${GITLAB_TOKEN} + PRIVATE-TOKEN: '{{env "GITLAB_TOKEN"}}' - host: artifacts.example.com:8443 headers: - Authorization: Bearer ${ARTIFACTS_TOKEN} + Authorization: 'Bearer {{env "ARTIFACTS_TOKEN"}}' ``` This is the recommended way to authenticate a remote Taskfile. Unlike a @@ -326,10 +326,28 @@ 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`, read when Task contacts the host. -An undefined variable expands to nothing, so the header is sent empty and the -server rejects it — prefer an environment variable over a literal value, which -cannot contain a `$` followed by a name. +[templating functions](./templating.md), evaluated when Task contacts the host. +Values starting with `{{` must be quoted, as YAML would otherwise read them as a +mapping. An undefined environment variable expands to nothing, so the header is +sent empty and the server rejects it with a `401`. + +Functions compose, so an `Authorization` header needs no manual encoding: + +```yaml +remote: + auth: + - host: artifacts.example.com + headers: + Authorization: 'Basic {{ printf "%s:%s" (env "USER") (env "PASS") | b64enc }}' +``` + +::: warning + +Only functions are available here — `{{.GITLAB_TOKEN}}` and other variable +references resolve to nothing. The configuration file is read before any +Taskfile, so no variable exists yet. Use `{{env "GITLAB_TOKEN"}}` instead. + +::: The header your server expects depends on the service: @@ -409,7 +427,7 @@ remote: auth: - host: gitlab.com headers: - PRIVATE-TOKEN: ${GITLAB_TOKEN} + PRIVATE-TOKEN: '{{env "GITLAB_TOKEN"}}' cacert: '' cert: '' cert-key: '' diff --git a/website/src/public/next-schema-taskrc.json b/website/src/public/next-schema-taskrc.json index 9f4069d838..55a02c60df 100644 --- a/website/src/public/next-schema-taskrc.json +++ b/website/src/public/next-schema-taskrc.json @@ -62,7 +62,7 @@ }, "headers": { "type": "object", - "description": "Headers to send. Values may reference environment variables with ${VAR} or $VAR.", + "description": "Headers to send. Values support templating functions, e.g. {{env \"GITLAB_TOKEN\"}}.", "additionalProperties": { "type": "string" }