From 038d598808114dd462bbb6673f9f155e71f6f066 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Thu, 13 Aug 2026 15:14:31 +0200 Subject: [PATCH 01/12] 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 | 5 + 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, 625 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 204ff5bcfb..8c91a14335 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ - Added versioned Homebrew casks (`go-task@.`) to install a specific minor version of Task (#3023 by @vmaerten). +- 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 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 e5851e1dc0d8be0b3f9dc8b7330a2d94c7d691eb Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Thu, 13 Aug 2026 18:06:12 +0200 Subject: [PATCH 02/12] 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 8533ad33b53b72b57ef8cb05391d5d00dd56eeb7 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Thu, 20 Aug 2026 16:50:43 +0200 Subject: [PATCH 03/12] 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 8c91a14335..87d3510204 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,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 1f39087c9b..bad0d62c29 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.23.0 golang.org/x/term v0.45.0 mvdan.cc/sh/moreinterp v0.0.0-20260907224004-5864ca90e1a1 @@ -121,7 +122,6 @@ require ( go.opentelemetry.io/otel/trace v1.45.0 // indirect golang.org/x/crypto v0.56.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.37.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 ce0b4d74300c5f9b8043184372f0b1405903d7ea Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Thu, 20 Aug 2026 18:12:26 +0200 Subject: [PATCH 04/12] 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 fe7c11f0c0c14441e56dd00c53b2ffe6920bd9c4 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sun, 23 Aug 2026 12:22:38 +0200 Subject: [PATCH 05/12] 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 be606b876b..b73e0dae6c 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 5c5335be8a..70102a5f4f 100644 --- a/website/src/next/docs/remote-taskfiles.md +++ b/website/src/next/docs/remote-taskfiles.md @@ -173,6 +173,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 954da773dc0e6d462878a0f70c09078bcc9e02ad Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sun, 23 Aug 2026 12:24:58 +0200 Subject: [PATCH 06/12] 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 f0ec3d696f3a44d89daf72fcdbdfb8c0b4697ea6 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sun, 23 Aug 2026 12:25:36 +0200 Subject: [PATCH 07/12] 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 c2258676aa8167ed269b9b1c5118c8057506f562 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sun, 23 Aug 2026 12:35:55 +0200 Subject: [PATCH 08/12] 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 87d3510204..767b10789b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,10 @@ - Added versioned Homebrew casks (`go-task@.`) to install a specific minor version of Task (#3023 by @vmaerten). - 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 b73e0dae6c..88b61f4abc 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" } From e54e4ab59812c96911d54eebebe157867752212b Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sat, 29 Aug 2026 21:47:53 +0200 Subject: [PATCH 09/12] refactor(remote): rename HostHeaders to HeadersByHost The type is a map of host to headers, not a flat header set. Name it after that shape, and rename the fields carrying it to authHeadersByHost so the lookup step is visible at every call site. Claude-Session: https://claude.ai/code/session_01KNPMznEzkRpxFZMLisjdqL --- executor.go | 6 +- internal/flags/flags.go | 6 +- taskfile/http_auth.go | 10 +-- taskfile/http_auth_test.go | 136 ++++++++++++++++++------------------- taskfile/node_base.go | 18 ++--- taskfile/reader.go | 12 ++-- 6 files changed, 94 insertions(+), 94 deletions(-) diff --git a/executor.go b/executor.go index 49a367eeda..2a3e495ca4 100644 --- a/executor.go +++ b/executor.go @@ -37,7 +37,7 @@ type ( Download bool Offline bool TrustedHosts []string - RemoteAuth taskfile.HostHeaders + RemoteAuth taskfile.HeadersByHost Timeout time.Duration CacheExpiryDuration time.Duration RemoteCacheDir string @@ -281,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 taskfile.HostHeaders) ExecutorOption { +func WithRemoteAuth(remoteAuth taskfile.HeadersByHost) ExecutorOption { return &remoteAuthOption{remoteAuth} } type remoteAuthOption struct { - remoteAuth taskfile.HostHeaders + remoteAuth taskfile.HeadersByHost } func (o *remoteAuthOption) ApplyToExecutor(e *Executor) { diff --git a/internal/flags/flags.go b/internal/flags/flags.go index a6b249b5ab..03346aa6c4 100644 --- a/internal/flags/flags.go +++ b/internal/flags/flags.go @@ -80,7 +80,7 @@ var ( Download bool Offline bool TrustedHosts []string - RemoteAuth taskfile.HostHeaders + RemoteAuth taskfile.HeadersByHost ClearCache bool Timeout time.Duration CacheExpiryDuration time.Duration @@ -318,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) taskfile.HostHeaders { +func remoteAuth(config *taskrcast.TaskRC) taskfile.HeadersByHost { if config == nil || len(config.Remote.Auth) == 0 { return nil } - byHost := make(taskfile.HostHeaders, len(config.Remote.Auth)) + byHost := make(taskfile.HeadersByHost, len(config.Remote.Auth)) for _, auth := range config.Remote.Auth { byHost[auth.Host] = auth.Headers } diff --git a/taskfile/http_auth.go b/taskfile/http_auth.go index 7159452bc6..69fe9e6b26 100644 --- a/taskfile/http_auth.go +++ b/taskfile/http_auth.go @@ -12,9 +12,9 @@ import ( "github.com/go-task/task/v3/internal/templater" ) -// HostHeaders maps a host to the HTTP headers to send when fetching a remote +// HeadersByHost maps a host to the HTTP headers to send when fetching a remote // Taskfile from it. Values are templated, but no variables are available. -type HostHeaders map[string]map[string]string +type HeadersByHost map[string]map[string]string type authTransport struct { base http.RoundTripper @@ -38,7 +38,7 @@ func (t *authTransport) RoundTrip(req *http.Request) (*http.Response, error) { // 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) + headers, err := resolveAuthHeaders(node.authHeadersByHost, node.url.Host) if err != nil { return nil, err } @@ -61,9 +61,9 @@ func withAuthHeaders(client *http.Client, host string, headers map[string]string } // resolveAuthHeaders returns the expanded headers for host, or nil if none. -func resolveAuthHeaders(hostHeaders HostHeaders, host string) (map[string]string, error) { +func resolveAuthHeaders(headersByHost HeadersByHost, host string) (map[string]string, error) { var headers map[string]string - for pattern, patternHeaders := range hostHeaders { + for pattern, patternHeaders := range headersByHost { if hostMatches(pattern, host) { headers = patternHeaders break diff --git a/taskfile/http_auth_test.go b/taskfile/http_auth_test.go index 7092cabf1a..9dcadc0fc8 100644 --- a/taskfile/http_auth_test.go +++ b/taskfile/http_auth_test.go @@ -12,98 +12,98 @@ import ( 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 string + headersByHost HeadersByHost + host string + env map[string]string + want map[string]string + wantErr string }{ { - name: "no configuration", - hostHeaders: nil, - host: "gitlab.com", + name: "no configuration", + headersByHost: nil, + host: "gitlab.com", }, { - name: "host does not match", - hostHeaders: HostHeaders{"gitlab.com": {"PRIVATE-TOKEN": "token"}}, - host: "example.com", + name: "host does not match", + headersByHost: HeadersByHost{"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: "port is part of the host", + headersByHost: HeadersByHost{"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: "literal value", + headersByHost: HeadersByHost{"gitlab.com": {"PRIVATE-TOKEN": "token"}}, + host: "gitlab.com", + want: map[string]string{"PRIVATE-TOKEN": "token"}, }, { - 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", + headersByHost: HeadersByHost{"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 {{env "TASK_TEST_TOKEN"}}`}}, - host: "gitlab.com", - env: map[string]string{"TASK_TEST_TOKEN": "s3cret"}, - want: map[string]string{"Authorization": "Bearer s3cret"}, + name: "environment variable inside a longer value", + headersByHost: HeadersByHost{"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": `{{env "TASK_TEST_UNSET"}}`}}, //nolint:gosec // an env var reference, not a credential - host: "gitlab.com", - want: map[string]string{"PRIVATE-TOKEN": ""}, + name: "undefined environment variable expands to nothing", + headersByHost: HeadersByHost{"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"}, + name: "functions compose, so Basic auth needs no manual base64", + headersByHost: HeadersByHost{"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 variable reference resolves to nothing", + headersByHost: HeadersByHost{"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: "a literal value is left untouched", + headersByHost: HeadersByHost{"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: "malformed template", + headersByHost: HeadersByHost{"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"}}, - host: "gitlab.com", - wantErr: `remote auth for host "gitlab.com": invalid header name "PRIVATE TOKEN"`, + name: "header name with a space", + headersByHost: HeadersByHost{"gitlab.com": {"PRIVATE TOKEN": "token"}}, + host: "gitlab.com", + wantErr: `remote auth for host "gitlab.com": invalid header name "PRIVATE 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": invalid header name "X-Foo(bar)"`, + name: "header name outside the HTTP token grammar", + headersByHost: HeadersByHost{"gitlab.com": {"X-Foo(bar)": "token"}}, + host: "gitlab.com", + 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 ""`, + name: "empty header name", + headersByHost: HeadersByHost{"gitlab.com": {"": "token"}}, + host: "gitlab.com", + wantErr: `remote auth for host "gitlab.com": invalid header name ""`, }, } @@ -112,7 +112,7 @@ func TestResolveAuthHeaders(t *testing.T) { //nolint:paralleltest // t.Setenv ca for name, value := range test.env { t.Setenv(name, value) } - headers, err := resolveAuthHeaders(test.hostHeaders, test.host) + headers, err := resolveAuthHeaders(test.headersByHost, test.host) if test.wantErr != "" { require.EqualError(t, err, test.wantErr) return @@ -178,7 +178,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{ + WithAuthHeaders(HeadersByHost{ mustHost(t, srv.URL): {"PRIVATE-TOKEN": `{{env "TASK_TEST_TOKEN"}}`}, //nolint:gosec // an env var reference, not a credential }), ) @@ -208,7 +208,7 @@ func TestHTTPNodeAuthHeadersNotSentOnRedirect(t *testing.T) { defer srv.Close() node, err := NewHTTPNode(srv.URL+"/Taskfile.yml", "", true, - WithAuthHeaders(HostHeaders{ + WithAuthHeaders(HeadersByHost{ mustHost(t, srv.URL): {"PRIVATE-TOKEN": "s3cret"}, }), ) @@ -226,7 +226,7 @@ func TestHTTPNodeAuthHeadersNotSentOnRedirect(t *testing.T) { // 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{ + WithAuthHeaders(HeadersByHost{ "gitlab.com": {"PRIVATE-TOKEN": `{{env "TASK_TEST_LAZY"}}`}, //nolint:gosec // an env var reference, not a credential }), ) @@ -235,7 +235,7 @@ func TestHTTPNodeAuthHeadersResolvedLazily(t *testing.T) { //nolint:paralleltest // Defined only after the node was built: the value must still be picked up. t.Setenv("TASK_TEST_LAZY", "s3cret") - headers, err := resolveAuthHeaders(node.authHeaders, node.url.Host) + headers, err := resolveAuthHeaders(node.authHeadersByHost, node.url.Host) require.NoError(t, err) assert.Equal(t, map[string]string{"PRIVATE-TOKEN": "s3cret"}, headers) } diff --git a/taskfile/node_base.go b/taskfile/node_base.go index 9a8cafa7fd..82b5c1ac14 100644 --- a/taskfile/node_base.go +++ b/taskfile/node_base.go @@ -7,13 +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 - authHeaders HostHeaders + parent Node + dir string + checksum string + caCert string + cert string + certKey string + authHeadersByHost HeadersByHost } ) @@ -78,8 +78,8 @@ func WithCertKey(certKey string) NodeOption { } // WithAuthHeaders sets the HTTP headers to send, keyed by host. -func WithAuthHeaders(authHeaders HostHeaders) NodeOption { +func WithAuthHeaders(authHeadersByHost HeadersByHost) NodeOption { return func(node *baseNode) { - node.authHeaders = authHeaders + node.authHeadersByHost = authHeadersByHost } } diff --git a/taskfile/reader.go b/taskfile/reader.go index 5ceec70ecc..edf0faa91c 100644 --- a/taskfile/reader.go +++ b/taskfile/reader.go @@ -51,7 +51,7 @@ type ( caCert string cert string certKey string - authHeaders HostHeaders + authHeadersByHost HeadersByHost debugFunc DebugFunc promptFunc PromptFunc promptMutex sync.Mutex @@ -244,16 +244,16 @@ func (o *readerCertKeyOption) ApplyToReader(r *Reader) { } // WithReaderAuthHeaders sets the HTTP headers to send to each configured host. -func WithReaderAuthHeaders(authHeaders HostHeaders) ReaderOption { - return &readerAuthHeadersOption{authHeaders: authHeaders} +func WithReaderAuthHeaders(authHeadersByHost HeadersByHost) ReaderOption { + return &readerAuthHeadersOption{authHeadersByHost: authHeadersByHost} } type readerAuthHeadersOption struct { - authHeaders HostHeaders + authHeadersByHost HeadersByHost } func (o *readerAuthHeadersOption) ApplyToReader(r *Reader) { - r.authHeaders = o.authHeaders + r.authHeadersByHost = o.authHeadersByHost } // Read will read the Taskfile defined by the [Reader]'s [Node] and recurse @@ -371,7 +371,7 @@ func (r *Reader) include(ctx context.Context, node Node) error { WithCACert(r.caCert), WithCert(r.cert), WithCertKey(r.certKey), - WithAuthHeaders(r.authHeaders), + WithAuthHeaders(r.authHeadersByHost), ) if err != nil { if include.Optional { From c24867d2d8a075ad3197edd2ce6c6317cf2b2b96 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Tue, 8 Sep 2026 22:31:08 +0200 Subject: [PATCH 10/12] refactor(remote): generalize auth config to headers --- CHANGELOG.md | 2 +- executor.go | 16 ++--- internal/flags/flags.go | 18 +++--- setup.go | 4 +- taskfile/{http_auth.go => http_headers.go} | 30 +++++----- ...http_auth_test.go => http_headers_test.go} | 58 +++++++++++-------- taskfile/node_base.go | 20 +++---- taskfile/node_http.go | 2 +- taskfile/reader.go | 18 +++--- taskrc/ast/taskrc.go | 38 ++++++------ taskrc/taskrc_test.go | 30 ++++++---- website/src/next/docs/reference/config.md | 24 ++++---- website/src/next/docs/remote-taskfiles.md | 2 +- website/src/public/next-schema-taskrc.json | 4 +- 14 files changed, 144 insertions(+), 122 deletions(-) rename taskfile/{http_auth.go => http_headers.go} (67%) rename taskfile/{http_auth_test.go => http_headers_test.go} (78%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 767b10789b..4db64e8c34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ - Added versioned Homebrew casks (`go-task@.`) to install a specific minor version of Task (#3023 by @vmaerten). -- Added a `remote.auth` config option to send HTTP headers when downloading a +- Added a `remote.headers` config option to send HTTP headers when downloading a 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 diff --git a/executor.go b/executor.go index 2a3e495ca4..6931b2512c 100644 --- a/executor.go +++ b/executor.go @@ -37,7 +37,7 @@ type ( Download bool Offline bool TrustedHosts []string - RemoteAuth taskfile.HeadersByHost + RemoteHeaders taskfile.HeadersByHost Timeout time.Duration CacheExpiryDuration time.Duration RemoteCacheDir string @@ -279,18 +279,18 @@ func (o *trustedHostsOption) ApplyToExecutor(e *Executor) { e.TrustedHosts = o.trustedHosts } -// WithRemoteAuth configures the [Executor] with the HTTP headers to send when +// WithRemoteHeaders configures the [Executor] with the HTTP headers to send when // fetching a remote Taskfile, keyed by host. -func WithRemoteAuth(remoteAuth taskfile.HeadersByHost) ExecutorOption { - return &remoteAuthOption{remoteAuth} +func WithRemoteHeaders(remoteHeaders taskfile.HeadersByHost) ExecutorOption { + return &remoteHeadersOption{remoteHeaders} } -type remoteAuthOption struct { - remoteAuth taskfile.HeadersByHost +type remoteHeadersOption struct { + remoteHeaders taskfile.HeadersByHost } -func (o *remoteAuthOption) ApplyToExecutor(e *Executor) { - e.RemoteAuth = o.remoteAuth +func (o *remoteHeadersOption) ApplyToExecutor(e *Executor) { + e.RemoteHeaders = o.remoteHeaders } // WithTimeout sets the [Executor]'s timeout for fetching remote taskfiles. By diff --git a/internal/flags/flags.go b/internal/flags/flags.go index 03346aa6c4..0113256f8f 100644 --- a/internal/flags/flags.go +++ b/internal/flags/flags.go @@ -80,7 +80,7 @@ var ( Download bool Offline bool TrustedHosts []string - RemoteAuth taskfile.HeadersByHost + RemoteHeaders taskfile.HeadersByHost ClearCache bool Timeout time.Duration CacheExpiryDuration time.Duration @@ -168,7 +168,7 @@ func init() { 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.") // No flag: a token on the command line is visible to any process listing it. - RemoteAuth = remoteAuth(config) + RemoteHeaders = remoteHeaders(config) // Gentle force experiment will override the force flag and add a new force-all flag if experiments.GentleForce.Enabled() { @@ -289,7 +289,7 @@ func (o *flagsOption) ApplyToExecutor(e *task.Executor) { task.WithDownload(Download), task.WithOffline(Offline), task.WithTrustedHosts(TrustedHosts), - task.WithRemoteAuth(RemoteAuth), + task.WithRemoteHeaders(RemoteHeaders), task.WithTimeout(Timeout), task.WithCacheExpiryDuration(CacheExpiryDuration), task.WithRemoteCacheDir(RemoteCacheDir), @@ -316,15 +316,15 @@ func (o *flagsOption) ApplyToExecutor(e *task.Executor) { ) } -// remoteAuth flattens the configured entries into a lookup by host, the last +// remoteHeaders 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) taskfile.HeadersByHost { - if config == nil || len(config.Remote.Auth) == 0 { +func remoteHeaders(config *taskrcast.TaskRC) taskfile.HeadersByHost { + if config == nil || len(config.Remote.Headers) == 0 { return nil } - byHost := make(taskfile.HeadersByHost, len(config.Remote.Auth)) - for _, auth := range config.Remote.Auth { - byHost[auth.Host] = auth.Headers + byHost := make(taskfile.HeadersByHost, len(config.Remote.Headers)) + for _, entry := range config.Remote.Headers { + byHost[entry.Host] = entry.Headers } return byHost } diff --git a/setup.go b/setup.go index d3e05aa6f2..5794d90afb 100644 --- a/setup.go +++ b/setup.go @@ -58,7 +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), + taskfile.WithHeaders(e.RemoteHeaders), ) if taskNotFoundError, ok := errors.AsType[errors.TaskfileNotFoundError](err); ok { taskNotFoundError.AskInit = true @@ -91,7 +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.WithReaderHeaders(e.RemoteHeaders), taskfile.WithDebugFunc(debugFunc), taskfile.WithPromptFunc(promptFunc), ) diff --git a/taskfile/http_auth.go b/taskfile/http_headers.go similarity index 67% rename from taskfile/http_auth.go rename to taskfile/http_headers.go index 69fe9e6b26..6e199d36b6 100644 --- a/taskfile/http_auth.go +++ b/taskfile/http_headers.go @@ -16,13 +16,13 @@ import ( // Taskfile from it. Values are templated, but no variables are available. type HeadersByHost map[string]map[string]string -type authTransport struct { +type headersTransport struct { base http.RoundTripper host string headers map[string]string } -func (t *authTransport) RoundTrip(req *http.Request) (*http.Response, error) { +func (t *headersTransport) RoundTrip(req *http.Request) (*http.Response, error) { // 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) { @@ -35,33 +35,33 @@ func (t *authTransport) RoundTrip(req *http.Request) (*http.Response, error) { return t.base.RoundTrip(req) } -// authenticatedClient resolves on each read, not at build time, so a cached +// clientWithHeaders 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.authHeadersByHost, node.url.Host) +func (node *HTTPNode) clientWithHeaders() (*http.Client, error) { + headers, err := resolveHeaders(node.headersByHost, 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 + return withHeaders(node.client, node.url.Host, headers), nil } -// withAuthHeaders copies rather than mutates: buildHTTPClient returns the +// withHeaders 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 - authenticated.Transport = &authTransport{ +func withHeaders(client *http.Client, host string, headers map[string]string) *http.Client { + configured := *client + configured.Transport = &headersTransport{ base: cmp.Or(client.Transport, http.DefaultTransport), host: host, headers: headers, } - return &authenticated + return &configured } -// resolveAuthHeaders returns the expanded headers for host, or nil if none. -func resolveAuthHeaders(headersByHost HeadersByHost, host string) (map[string]string, error) { +// resolveHeaders returns the expanded headers for host, or nil if none. +func resolveHeaders(headersByHost HeadersByHost, host string) (map[string]string, error) { var headers map[string]string for pattern, patternHeaders := range headersByHost { if hostMatches(pattern, host) { @@ -77,12 +77,12 @@ func resolveAuthHeaders(headersByHost HeadersByHost, host string) (map[string]st 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) + return nil, fmt.Errorf(`remote headers for host %q: %w`, host, err) } 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 nil, fmt.Errorf(`remote headers for host %q: %w`, host, err) } return resolved, nil } diff --git a/taskfile/http_auth_test.go b/taskfile/http_headers_test.go similarity index 78% rename from taskfile/http_auth_test.go rename to taskfile/http_headers_test.go index 9dcadc0fc8..e3529bee48 100644 --- a/taskfile/http_auth_test.go +++ b/taskfile/http_headers_test.go @@ -10,7 +10,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestResolveAuthHeaders(t *testing.T) { //nolint:paralleltest // t.Setenv cannot be used in parallel tests +func TestResolveHeaders(t *testing.T) { //nolint:paralleltest // t.Setenv cannot be used in parallel tests tests := []struct { name string headersByHost HeadersByHost @@ -85,25 +85,25 @@ func TestResolveAuthHeaders(t *testing.T) { //nolint:paralleltest // t.Setenv ca name: "malformed template", headersByHost: HeadersByHost{"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`, + wantErr: `remote headers for host "gitlab.com": template: :1: unclosed action`, }, { name: "header name with a space", headersByHost: HeadersByHost{"gitlab.com": {"PRIVATE TOKEN": "token"}}, host: "gitlab.com", - wantErr: `remote auth for host "gitlab.com": invalid header name "PRIVATE TOKEN"`, + wantErr: `remote headers for host "gitlab.com": invalid header name "PRIVATE TOKEN"`, }, { name: "header name outside the HTTP token grammar", headersByHost: HeadersByHost{"gitlab.com": {"X-Foo(bar)": "token"}}, host: "gitlab.com", - wantErr: `remote auth for host "gitlab.com": invalid header name "X-Foo(bar)"`, + wantErr: `remote headers for host "gitlab.com": invalid header name "X-Foo(bar)"`, }, { name: "empty header name", headersByHost: HeadersByHost{"gitlab.com": {"": "token"}}, host: "gitlab.com", - wantErr: `remote auth for host "gitlab.com": invalid header name ""`, + wantErr: `remote headers for host "gitlab.com": invalid header name ""`, }, } @@ -112,7 +112,7 @@ func TestResolveAuthHeaders(t *testing.T) { //nolint:paralleltest // t.Setenv ca for name, value := range test.env { t.Setenv(name, value) } - headers, err := resolveAuthHeaders(test.headersByHost, test.host) + headers, err := resolveHeaders(test.headersByHost, test.host) if test.wantErr != "" { require.EqualError(t, err, test.wantErr) return @@ -123,10 +123,10 @@ func TestResolveAuthHeaders(t *testing.T) { //nolint:paralleltest // t.Setenv ca } } -func TestAuthTransport(t *testing.T) { +func TestHeadersTransport(t *testing.T) { t.Parallel() - transport := &authTransport{ + transport := &headersTransport{ base: roundTripperFunc(func(req *http.Request) (*http.Response, error) { return newResponse(req), nil }), host: "gitlab.com", headers: map[string]string{"PRIVATE-TOKEN": "token"}, @@ -151,25 +151,29 @@ func TestAuthTransport(t *testing.T) { }) } -func TestWithAuthHeadersDoesNotMutateTheDefaultClient(t *testing.T) { +func TestWithHeadersDoesNotMutateTheDefaultClient(t *testing.T) { t.Parallel() - client := withAuthHeaders(http.DefaultClient, "gitlab.com", map[string]string{"PRIVATE-TOKEN": "token"}) + client := withHeaders(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) + assert.IsType(t, &headersTransport{}, client.Transport) } // 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 +func TestHTTPNodeHeaders(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 } + if r.Header.Get("Accept") != "application/yaml" || r.Header.Get("X-Custom-Header") != "custom-value" { + w.WriteHeader(http.StatusBadRequest) + return + } methods = append(methods, r.Method) w.Header().Set("Content-Type", "text/yaml") _, _ = w.Write([]byte("version: '3'\n")) @@ -178,8 +182,12 @@ 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(HeadersByHost{ - mustHost(t, srv.URL): {"PRIVATE-TOKEN": `{{env "TASK_TEST_TOKEN"}}`}, //nolint:gosec // an env var reference, not a credential + WithHeaders(HeadersByHost{ + mustHost(t, srv.URL): { //nolint:gosec // an env var reference, not a credential + "PRIVATE-TOKEN": `{{env "TASK_TEST_TOKEN"}}`, + "Accept": "application/yaml", + "X-Custom-Header": "custom-value", + }, }), ) require.NoError(t, err) @@ -190,13 +198,13 @@ func TestHTTPNodeAuthHeaders(t *testing.T) { //nolint:paralleltest // t.Setenv c assert.Equal(t, []string{"HEAD", "GET"}, methods) } -// A server bouncing the request must not get the credentials forwarded to it. -func TestHTTPNodeAuthHeadersNotSentOnRedirect(t *testing.T) { +// A redirect to another host must not forward any configured headers. +func TestHTTPNodeHeadersNotSentOnRedirect(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")) + received = append(received, r.Header.Get("PRIVATE-TOKEN"), r.Header.Get("Accept"), r.Header.Get("X-Custom-Header")) w.Header().Set("Content-Type", "text/yaml") _, _ = w.Write([]byte("version: '3'\n")) })) @@ -208,8 +216,12 @@ func TestHTTPNodeAuthHeadersNotSentOnRedirect(t *testing.T) { defer srv.Close() node, err := NewHTTPNode(srv.URL+"/Taskfile.yml", "", true, - WithAuthHeaders(HeadersByHost{ - mustHost(t, srv.URL): {"PRIVATE-TOKEN": "s3cret"}, + WithHeaders(HeadersByHost{ + mustHost(t, srv.URL): { + "PRIVATE-TOKEN": "s3cret", + "Accept": "application/yaml", + "X-Custom-Header": "custom-value", + }, }), ) require.NoError(t, err) @@ -218,15 +230,15 @@ func TestHTTPNodeAuthHeadersNotSentOnRedirect(t *testing.T) { 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") + assert.Empty(t, header, "configured headers must not follow a redirect to another host") } } // 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 +func TestHTTPNodeHeadersResolvedLazily(t *testing.T) { //nolint:paralleltest // t.Setenv cannot be used in parallel tests node, err := NewHTTPNode("https://gitlab.com/Taskfile.yml", "", false, - WithAuthHeaders(HeadersByHost{ + WithHeaders(HeadersByHost{ "gitlab.com": {"PRIVATE-TOKEN": `{{env "TASK_TEST_LAZY"}}`}, //nolint:gosec // an env var reference, not a credential }), ) @@ -235,7 +247,7 @@ func TestHTTPNodeAuthHeadersResolvedLazily(t *testing.T) { //nolint:paralleltest // Defined only after the node was built: the value must still be picked up. t.Setenv("TASK_TEST_LAZY", "s3cret") - headers, err := resolveAuthHeaders(node.authHeadersByHost, node.url.Host) + headers, err := resolveHeaders(node.headersByHost, node.url.Host) require.NoError(t, err) assert.Equal(t, map[string]string{"PRIVATE-TOKEN": "s3cret"}, headers) } diff --git a/taskfile/node_base.go b/taskfile/node_base.go index 82b5c1ac14..6bd1e66222 100644 --- a/taskfile/node_base.go +++ b/taskfile/node_base.go @@ -7,13 +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 - authHeadersByHost HeadersByHost + parent Node + dir string + checksum string + caCert string + cert string + certKey string + headersByHost HeadersByHost } ) @@ -77,9 +77,9 @@ func WithCertKey(certKey string) NodeOption { } } -// WithAuthHeaders sets the HTTP headers to send, keyed by host. -func WithAuthHeaders(authHeadersByHost HeadersByHost) NodeOption { +// WithHeaders sets the HTTP headers to send, keyed by host. +func WithHeaders(headersByHost HeadersByHost) NodeOption { return func(node *baseNode) { - node.authHeadersByHost = authHeadersByHost + node.headersByHost = headersByHost } } diff --git a/taskfile/node_http.go b/taskfile/node_http.go index 3041f07d18..f5ba91d83f 100644 --- a/taskfile/node_http.go +++ b/taskfile/node_http.go @@ -106,7 +106,7 @@ func (node *HTTPNode) Read() ([]byte, error) { } func (node *HTTPNode) ReadContext(ctx context.Context) ([]byte, error) { - client, err := node.authenticatedClient() + client, err := node.clientWithHeaders() if err != nil { return nil, err } diff --git a/taskfile/reader.go b/taskfile/reader.go index edf0faa91c..a4ede421e7 100644 --- a/taskfile/reader.go +++ b/taskfile/reader.go @@ -51,7 +51,7 @@ type ( caCert string cert string certKey string - authHeadersByHost HeadersByHost + headersByHost HeadersByHost debugFunc DebugFunc promptFunc PromptFunc promptMutex sync.Mutex @@ -243,17 +243,17 @@ func (o *readerCertKeyOption) ApplyToReader(r *Reader) { r.certKey = o.certKey } -// WithReaderAuthHeaders sets the HTTP headers to send to each configured host. -func WithReaderAuthHeaders(authHeadersByHost HeadersByHost) ReaderOption { - return &readerAuthHeadersOption{authHeadersByHost: authHeadersByHost} +// WithReaderHeaders sets the HTTP headers to send to each configured host. +func WithReaderHeaders(headersByHost HeadersByHost) ReaderOption { + return &readerHeadersOption{headersByHost: headersByHost} } -type readerAuthHeadersOption struct { - authHeadersByHost HeadersByHost +type readerHeadersOption struct { + headersByHost HeadersByHost } -func (o *readerAuthHeadersOption) ApplyToReader(r *Reader) { - r.authHeadersByHost = o.authHeadersByHost +func (o *readerHeadersOption) ApplyToReader(r *Reader) { + r.headersByHost = o.headersByHost } // Read will read the Taskfile defined by the [Reader]'s [Node] and recurse @@ -371,7 +371,7 @@ func (r *Reader) include(ctx context.Context, node Node) error { WithCACert(r.caCert), WithCert(r.cert), WithCertKey(r.certKey), - WithAuthHeaders(r.authHeadersByHost), + WithHeaders(r.headersByHost), ) if err != nil { if include.Optional { diff --git a/taskrc/ast/taskrc.go b/taskrc/ast/taskrc.go index 7975446d3a..53489326b6 100644 --- a/taskrc/ast/taskrc.go +++ b/taskrc/ast/taskrc.go @@ -24,21 +24,21 @@ type TaskRC struct { } type Remote struct { - Insecure *bool `yaml:"insecure"` - Offline *bool `yaml:"offline"` - Timeout *time.Duration `yaml:"timeout"` - 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"` + Insecure *bool `yaml:"insecure"` + Offline *bool `yaml:"offline"` + Timeout *time.Duration `yaml:"timeout"` + CacheExpiry *time.Duration `yaml:"cache-expiry"` + CacheDir *string `yaml:"cache-dir"` + TrustedHosts []string `yaml:"trusted-hosts"` + Headers []RemoteHeaders `yaml:"headers"` + 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 +// RemoteHeaders holds the HTTP headers to send when fetching a remote Taskfile // from a given host. -type RemoteAuth struct { +type RemoteHeaders struct { Host string `yaml:"host"` Headers map[string]string `yaml:"headers"` } @@ -68,7 +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.Headers = mergeHeaders(t.Remote.Headers, other.Remote.Headers) 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) @@ -83,19 +83,19 @@ func (t *TaskRC) Merge(other *TaskRC) { t.TempDir = cmp.Or(other.TempDir, t.TempDir) } -// mergeAuth unions both lists by host. An entry from other replaces the one +// mergeHeaders 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 { +func mergeHeaders(base, other []RemoteHeaders) []RemoteHeaders { 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 + byHost := make(map[string]RemoteHeaders, len(base)+len(other)) + for _, entry := range slices.Concat(base, other) { + byHost[entry.Host] = entry } merged := slices.Collect(maps.Values(byHost)) - slices.SortFunc(merged, func(a, b RemoteAuth) int { + slices.SortFunc(merged, func(a, b RemoteHeaders) int { return cmp.Compare(a.Host, b.Host) }) return merged diff --git a/taskrc/taskrc_test.go b/taskrc/taskrc_test.go index 7f61d41564..68ae7cd484 100644 --- a/taskrc/taskrc_test.go +++ b/taskrc/taskrc_test.go @@ -342,36 +342,42 @@ remote: }) } -func TestGetConfig_RemoteAuth(t *testing.T) { //nolint:paralleltest // cannot run in parallel +func TestGetConfig_RemoteHeaders(t *testing.T) { //nolint:paralleltest // cannot run in parallel _, _, localDir := setupDirs(t) configYAML := ` remote: - auth: + headers: - host: gitlab.com headers: - PRIVATE-TOKEN: ${GITLAB_TOKEN} + PRIVATE-TOKEN: '{{env "GITLAB_TOKEN"}}' - host: example.com:8080 headers: Authorization: Bearer token + Accept: application/yaml + X-Custom-Header: custom-value ` 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) + assert.Equal(t, []ast.RemoteHeaders{ + {Host: "gitlab.com", Headers: map[string]string{"PRIVATE-TOKEN": `{{env "GITLAB_TOKEN"}}`}}, //nolint:gosec // an env var reference, not a credential + {Host: "example.com:8080", Headers: map[string]string{ + "Authorization": "Bearer token", + "Accept": "application/yaml", + "X-Custom-Header": "custom-value", + }}, + }, cfg.Remote.Headers) } -func TestGetConfig_RemoteAuthMerge(t *testing.T) { //nolint:paralleltest // cannot run in parallel +func TestGetConfig_RemoteHeadersMerge(t *testing.T) { //nolint:paralleltest // cannot run in parallel xdgConfigDir, homeDir, localDir := setupDirs(t) writeFile(t, xdgConfigDir, "taskrc.yml", ` remote: - auth: + headers: - host: gitlab.com headers: PRIVATE-TOKEN: from-xdg @@ -385,7 +391,7 @@ remote: // untouched. writeFile(t, homeDir, ".taskrc.yml", ` remote: - auth: + headers: - host: gitlab.com headers: JOB-TOKEN: from-home @@ -394,8 +400,8 @@ remote: cfg, err := GetConfig(localDir) require.NoError(t, err) require.NotNil(t, cfg) - assert.Equal(t, []ast.RemoteAuth{ + assert.Equal(t, []ast.RemoteHeaders{ {Host: "example.com", Headers: map[string]string{"Authorization": "from-xdg"}}, {Host: "gitlab.com", Headers: map[string]string{"JOB-TOKEN": "from-home"}}, - }, cfg.Remote.Auth) + }, cfg.Remote.Headers) } diff --git a/website/src/next/docs/reference/config.md b/website/src/next/docs/reference/config.md index 88b61f4abc..cb0d186b27 100644 --- a/website/src/next/docs/reference/config.md +++ b/website/src/next/docs/reference/config.md @@ -300,28 +300,31 @@ 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` +#### `remote.headers` - **Type**: `array of objects` - **Default**: `[]` (empty list) - **Description**: HTTP headers to send when downloading a remote Taskfile from - a given host + a given host, including authentication and custom headers ```yaml remote: - auth: + headers: - host: gitlab.com headers: PRIVATE-TOKEN: '{{env "GITLAB_TOKEN"}}' - host: artifacts.example.com:8443 headers: Authorization: 'Bearer {{env "ARTIFACTS_TOKEN"}}' + Accept: application/yaml + X-Custom-Header: custom-value ``` -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. +Use this option for authentication, content negotiation or other headers your +server expects. For authentication, this is the recommended way to access 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 @@ -329,13 +332,14 @@ the URL has one — the same rule as [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`. +sent empty. For an authentication header, this may cause the server to reject +the request with a `401`. Functions compose, so an `Authorization` header needs no manual encoding: ```yaml remote: - auth: + headers: - host: artifacts.example.com headers: Authorization: 'Basic {{ printf "%s:%s" (env "USER") (env "PASS") | b64enc }}' @@ -424,7 +428,7 @@ remote: trusted-hosts: - github.com - gitlab.com - auth: + headers: - host: gitlab.com headers: PRIVATE-TOKEN: '{{env "GITLAB_TOKEN"}}' diff --git a/website/src/next/docs/remote-taskfiles.md b/website/src/next/docs/remote-taskfiles.md index 70102a5f4f..0f5bbd7165 100644 --- a/website/src/next/docs/remote-taskfiles.md +++ b/website/src/next/docs/remote-taskfiles.md @@ -173,7 +173,7 @@ 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 +Prefer the [`remote.headers`](./reference/config.md#remote-headers) 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. diff --git a/website/src/public/next-schema-taskrc.json b/website/src/public/next-schema-taskrc.json index 55a02c60df..a49043a2b2 100644 --- a/website/src/public/next-schema-taskrc.json +++ b/website/src/public/next-schema-taskrc.json @@ -50,9 +50,9 @@ "type": "string" } }, - "auth": { + "headers": { "type": "array", - "description": "HTTP headers to send when downloading remote Taskfiles, per host.", + "description": "HTTP headers to send when downloading remote Taskfiles, per host, including authentication and custom headers.", "items": { "type": "object", "properties": { From 7d775192ad8ff49d6413de11bc8bee853b732cd9 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Tue, 8 Sep 2026 22:37:09 +0200 Subject: [PATCH 11/12] chore(remote): simplify header comments --- internal/flags/flags.go | 3 +-- taskfile/http_headers.go | 19 +++++++------------ taskfile/http_headers_test.go | 10 ++-------- taskrc/ast/taskrc.go | 7 ++----- 4 files changed, 12 insertions(+), 27 deletions(-) diff --git a/internal/flags/flags.go b/internal/flags/flags.go index 0113256f8f..abecdd8b21 100644 --- a/internal/flags/flags.go +++ b/internal/flags/flags.go @@ -316,8 +316,7 @@ func (o *flagsOption) ApplyToExecutor(e *task.Executor) { ) } -// remoteHeaders flattens the configured entries into a lookup by host, the last -// entry winning as it does when configuration files are merged. +// The last entry for each host wins, matching config file merging. func remoteHeaders(config *taskrcast.TaskRC) taskfile.HeadersByHost { if config == nil || len(config.Remote.Headers) == 0 { return nil diff --git a/taskfile/http_headers.go b/taskfile/http_headers.go index 6e199d36b6..4cdbfaab5f 100644 --- a/taskfile/http_headers.go +++ b/taskfile/http_headers.go @@ -12,8 +12,8 @@ import ( "github.com/go-task/task/v3/internal/templater" ) -// HeadersByHost maps a host to the HTTP headers to send when fetching a remote -// Taskfile from it. Values are templated, but no variables are available. +// HeadersByHost configures HTTP headers per host for remote Taskfiles. +// Values support template functions, but not Taskfile variables. type HeadersByHost map[string]map[string]string type headersTransport struct { @@ -23,8 +23,7 @@ type headersTransport struct { } func (t *headersTransport) RoundTrip(req *http.Request) (*http.Response, error) { - // Re-checked per request: a redirect goes through this same transport, and - // Go only strips Authorization, WWW-Authenticate and Cookie on its own. + // Scope all configured headers to this host, including after redirects. if !hostMatches(t.host, req.URL.Host) { return t.base.RoundTrip(req) } @@ -35,8 +34,7 @@ func (t *headersTransport) RoundTrip(req *http.Request) (*http.Response, error) return t.base.RoundTrip(req) } -// clientWithHeaders resolves on each read, not at build time, so a cached -// run needs no credentials. +// Resolve headers only when downloading, so cached runs need no credentials. func (node *HTTPNode) clientWithHeaders() (*http.Client, error) { headers, err := resolveHeaders(node.headersByHost, node.url.Host) if err != nil { @@ -48,9 +46,8 @@ func (node *HTTPNode) clientWithHeaders() (*http.Client, error) { return withHeaders(node.client, node.url.Host, headers), nil } -// withHeaders copies rather than mutates: buildHTTPClient returns the -// shared http.DefaultClient when no TLS option is set. func withHeaders(client *http.Client, host string, headers map[string]string) *http.Client { + // The client may be http.DefaultClient; leave it unchanged. configured := *client configured.Transport = &headersTransport{ base: cmp.Or(client.Transport, http.DefaultTransport), @@ -60,7 +57,6 @@ func withHeaders(client *http.Client, host string, headers map[string]string) *h return &configured } -// resolveHeaders returns the expanded headers for host, or nil if none. func resolveHeaders(headersByHost HeadersByHost, host string) (map[string]string, error) { var headers map[string]string for pattern, patternHeaders := range headersByHost { @@ -87,8 +83,7 @@ func resolveHeaders(headersByHost HeadersByHost, host string) (map[string]string return resolved, nil } -// validateHeaderName names the offending header; ReadContext discards the -// transport's own error. +// Validate names here because ReadContext hides transport errors. func validateHeaderName(name string) error { if !httpguts.ValidHeaderFieldName(name) { return fmt.Errorf("invalid header name %q", name) @@ -96,7 +91,7 @@ func validateHeaderName(name string) error { return nil } -// hostMatches compares exactly, port included, as trusted hosts do. +// Match the host and port exactly for both headers and trusted hosts. func hostMatches(pattern, host string) bool { return pattern == host } diff --git a/taskfile/http_headers_test.go b/taskfile/http_headers_test.go index e3529bee48..b6dde36443 100644 --- a/taskfile/http_headers_test.go +++ b/taskfile/http_headers_test.go @@ -68,7 +68,6 @@ func TestResolveHeaders(t *testing.T) { //nolint:paralleltest // t.Setenv cannot 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", headersByHost: HeadersByHost{"gitlab.com": {"PRIVATE-TOKEN": "{{.TASK_TEST_TOKEN}}"}}, //nolint:gosec // a template, not a credential host: "gitlab.com", @@ -138,7 +137,6 @@ func TestHeadersTransport(t *testing.T) { 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")) }) @@ -161,8 +159,7 @@ func TestWithHeadersDoesNotMutateTheDefaultClient(t *testing.T) { assert.IsType(t, &headersTransport{}, client.Transport) } -// Both requests must carry the headers: RemoteExists probes with HEAD before -// ReadContext issues the GET. +// Downloads probe with HEAD before GET; both requests need the headers. func TestHTTPNodeHeaders(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) { @@ -198,7 +195,6 @@ func TestHTTPNodeHeaders(t *testing.T) { //nolint:paralleltest // t.Setenv canno assert.Equal(t, []string{"HEAD", "GET"}, methods) } -// A redirect to another host must not forward any configured headers. func TestHTTPNodeHeadersNotSentOnRedirect(t *testing.T) { t.Parallel() @@ -234,8 +230,6 @@ func TestHTTPNodeHeadersNotSentOnRedirect(t *testing.T) { } } -// A node must build without the credentials it would need to download, so that -// cached and offline runs do not require them. func TestHTTPNodeHeadersResolvedLazily(t *testing.T) { //nolint:paralleltest // t.Setenv cannot be used in parallel tests node, err := NewHTTPNode("https://gitlab.com/Taskfile.yml", "", false, WithHeaders(HeadersByHost{ @@ -244,7 +238,7 @@ func TestHTTPNodeHeadersResolvedLazily(t *testing.T) { //nolint:paralleltest // ) require.NoError(t, err) - // Defined only after the node was built: the value must still be picked up. + // Set the value after construction to verify lazy resolution. t.Setenv("TASK_TEST_LAZY", "s3cret") headers, err := resolveHeaders(node.headersByHost, node.url.Host) diff --git a/taskrc/ast/taskrc.go b/taskrc/ast/taskrc.go index 53489326b6..a70209db5d 100644 --- a/taskrc/ast/taskrc.go +++ b/taskrc/ast/taskrc.go @@ -36,8 +36,7 @@ type Remote struct { CertKey *string `yaml:"cert-key"` } -// RemoteHeaders holds the HTTP headers to send when fetching a remote Taskfile -// from a given host. +// RemoteHeaders configures HTTP headers for a single host. type RemoteHeaders struct { Host string `yaml:"host"` Headers map[string]string `yaml:"headers"` @@ -83,9 +82,7 @@ func (t *TaskRC) Merge(other *TaskRC) { t.TempDir = cmp.Or(other.TempDir, t.TempDir) } -// mergeHeaders 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. +// Replace each host's headers as a whole so closer config files can drop headers. func mergeHeaders(base, other []RemoteHeaders) []RemoteHeaders { if len(other) == 0 { return base From a89698342a126d8cd0c82ccd51e87510bb55d9f4 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Tue, 8 Sep 2026 22:42:58 +0200 Subject: [PATCH 12/12] fix(docs): escape inline header templates in VitePress --- website/src/next/docs/reference/config.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/website/src/next/docs/reference/config.md b/website/src/next/docs/reference/config.md index cb0d186b27..7b9e6c7cad 100644 --- a/website/src/next/docs/reference/config.md +++ b/website/src/next/docs/reference/config.md @@ -330,10 +330,10 @@ 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 [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. For an authentication header, this may cause the server to reject -the request with a `401`. +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. For an authentication header, this may +cause the server to reject the request with a `401`. Functions compose, so an `Authorization` header needs no manual encoding: @@ -347,9 +347,10 @@ remote: ::: 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. +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. :::