From deb3b7864a8f20ed7dbfe4c8880a876b8a73e655 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Thu, 13 Aug 2026 15:14:31 +0200 Subject: [PATCH 1/2] 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/docs/reference/config.md | 55 ++++++ website/src/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 d67a714e36..c21557a0ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,11 @@ reports exit code `124`. Callers that join a `run: once` or `when_changed` task already running now honor their own `timeout`, and inherit that task's failure instead of being told it succeeded (#1569, #2898 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). ## v3.52.0 - 2026-07-02 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 72c82e2d9b..703bbe7b66 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), ) var taskNotFoundError errors.TaskfileNotFoundError if errors.As(err, &taskNotFoundError) { @@ -91,6 +92,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 1b8556c1dd..2c95e5fb5e 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/docs/reference/config.md b/website/src/docs/reference/config.md index ff6941178c..26b8033339 100644 --- a/website/src/docs/reference/config.md +++ b/website/src/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/docs/remote-taskfiles.md b/website/src/docs/remote-taskfiles.md index 4d54918e61..613376c8ce 100644 --- a/website/src/docs/remote-taskfiles.md +++ b/website/src/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 416321d255827d3fabb8d7de1c742520e1d87034 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Thu, 13 Aug 2026 18:06:12 +0200 Subject: [PATCH 2/2] 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