diff --git a/CHANGELOG.md b/CHANGELOG.md index 204ff5bcfb..4db64e8c34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ - Added versioned Homebrew casks (`go-task@.`) to install a specific minor version of Task (#3023 by @vmaerten). +- 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 + 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 diff --git a/executor.go b/executor.go index 2ed4463beb..6931b2512c 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,6 +37,7 @@ type ( Download bool Offline bool TrustedHosts []string + RemoteHeaders taskfile.HeadersByHost Timeout time.Duration CacheExpiryDuration time.Duration RemoteCacheDir string @@ -277,6 +279,20 @@ func (o *trustedHostsOption) ApplyToExecutor(e *Executor) { e.TrustedHosts = o.trustedHosts } +// WithRemoteHeaders configures the [Executor] with the HTTP headers to send when +// fetching a remote Taskfile, keyed by host. +func WithRemoteHeaders(remoteHeaders taskfile.HeadersByHost) ExecutorOption { + return &remoteHeadersOption{remoteHeaders} +} + +type remoteHeadersOption struct { + remoteHeaders taskfile.HeadersByHost +} + +func (o *remoteHeadersOption) ApplyToExecutor(e *Executor) { + e.RemoteHeaders = o.remoteHeaders +} + // 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/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/internal/flags/flags.go b/internal/flags/flags.go index 9e43d4a943..abecdd8b21 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,6 +80,7 @@ var ( Download bool Offline bool TrustedHosts []string + RemoteHeaders taskfile.HeadersByHost ClearCache bool Timeout time.Duration CacheExpiryDuration time.Duration @@ -165,6 +167,8 @@ 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.") + // No flag: a token on the command line is visible to any process listing it. + RemoteHeaders = remoteHeaders(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.WithRemoteHeaders(RemoteHeaders), task.WithTimeout(Timeout), task.WithCacheExpiryDuration(CacheExpiryDuration), task.WithRemoteCacheDir(RemoteCacheDir), @@ -311,6 +316,18 @@ func (o *flagsOption) ApplyToExecutor(e *task.Executor) { ) } +// 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 + } + byHost := make(taskfile.HeadersByHost, len(config.Remote.Headers)) + for _, entry := range config.Remote.Headers { + byHost[entry.Host] = entry.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..5794d90afb 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.WithHeaders(e.RemoteHeaders), ) 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.WithReaderHeaders(e.RemoteHeaders), taskfile.WithDebugFunc(debugFunc), taskfile.WithPromptFunc(promptFunc), ) diff --git a/taskfile/http_headers.go b/taskfile/http_headers.go new file mode 100644 index 0000000000..4cdbfaab5f --- /dev/null +++ b/taskfile/http_headers.go @@ -0,0 +1,97 @@ +package taskfile + +import ( + "cmp" + "fmt" + "maps" + "net/http" + "slices" + + "golang.org/x/net/http/httpguts" + + "github.com/go-task/task/v3/internal/templater" +) + +// 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 { + base http.RoundTripper + host string + headers map[string]string +} + +func (t *headersTransport) RoundTrip(req *http.Request) (*http.Response, error) { + // Scope all configured headers to this host, including after redirects. + if !hostMatches(t.host, req.URL.Host) { + return t.base.RoundTrip(req) + } + req = req.Clone(req.Context()) + for name, value := range t.headers { + req.Header.Set(name, value) + } + return t.base.RoundTrip(req) +} + +// 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 { + return nil, err + } + if len(headers) == 0 { + return node.client, nil + } + return withHeaders(node.client, node.url.Host, headers), nil +} + +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), + host: host, + headers: headers, + } + return &configured +} + +func resolveHeaders(headersByHost HeadersByHost, host string) (map[string]string, error) { + var headers map[string]string + for pattern, patternHeaders := range headersByHost { + if hostMatches(pattern, host) { + headers = patternHeaders + break + } + } + if len(headers) == 0 { + 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 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 headers for host %q: %w`, host, err) + } + return resolved, nil +} + +// 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) + } + return nil +} + +// 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 new file mode 100644 index 0000000000..b6dde36443 --- /dev/null +++ b/taskfile/http_headers_test.go @@ -0,0 +1,271 @@ +package taskfile + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestResolveHeaders(t *testing.T) { //nolint:paralleltest // t.Setenv cannot be used in parallel tests + tests := []struct { + name string + headersByHost HeadersByHost + host string + env map[string]string + want map[string]string + wantErr string + }{ + { + name: "no configuration", + headersByHost: nil, + host: "gitlab.com", + }, + { + name: "host does not match", + headersByHost: HeadersByHost{"gitlab.com": {"PRIVATE-TOKEN": "token"}}, + host: "example.com", + }, + { + name: "port is part of the host", + headersByHost: HeadersByHost{"example.com": {"PRIVATE-TOKEN": "token"}}, + host: "example.com:8080", + }, + { + name: "literal value", + headersByHost: HeadersByHost{"gitlab.com": {"PRIVATE-TOKEN": "token"}}, + host: "gitlab.com", + want: map[string]string{"PRIVATE-TOKEN": "token"}, + }, + { + 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", + 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", + 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", + 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"}, + }, + { + 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", + 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", + headersByHost: HeadersByHost{"gitlab.com": {"PRIVATE-TOKEN": `{{env "TASK_TEST_TOKEN"`}}, //nolint:gosec // a template, not a credential + host: "gitlab.com", + 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 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 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 headers for host "gitlab.com": invalid header name ""`, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + for name, value := range test.env { + t.Setenv(name, value) + } + headers, err := resolveHeaders(test.headersByHost, test.host) + if test.wantErr != "" { + require.EqualError(t, err, test.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, test.want, headers) + }) + } +} + +func TestHeadersTransport(t *testing.T) { + t.Parallel() + + 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"}, + } + + 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")) + 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 TestWithHeadersDoesNotMutateTheDefaultClient(t *testing.T) { + t.Parallel() + + 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, &headersTransport{}, client.Transport) +} + +// 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) { + 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")) + })) + defer srv.Close() + + t.Setenv("TASK_TEST_TOKEN", "s3cret") + node, err := NewHTTPNode(srv.URL+"/Taskfile.yml", "", true, + 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) + + b, err := node.Read() + require.NoError(t, err) + assert.Equal(t, "version: '3'\n", string(b)) + assert.Equal(t, []string{"HEAD", "GET"}, methods) +} + +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"), r.Header.Get("Accept"), r.Header.Get("X-Custom-Header")) + 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, + WithHeaders(HeadersByHost{ + mustHost(t, srv.URL): { + "PRIVATE-TOKEN": "s3cret", + "Accept": "application/yaml", + "X-Custom-Header": "custom-value", + }, + }), + ) + require.NoError(t, err) + + _, err = node.Read() + require.NoError(t, err) + require.NotEmpty(t, received) + for _, header := range received { + assert.Empty(t, header, "configured headers must not follow a redirect to another host") + } +} + +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{ + "gitlab.com": {"PRIVATE-TOKEN": `{{env "TASK_TEST_LAZY"}}`}, //nolint:gosec // an env var reference, not a credential + }), + ) + require.NoError(t, err) + + // Set the value after construction to verify lazy resolution. + t.Setenv("TASK_TEST_LAZY", "s3cret") + + headers, err := resolveHeaders(node.headersByHost, node.url.Host) + require.NoError(t, err) + assert.Equal(t, map[string]string{"PRIVATE-TOKEN": "s3cret"}, headers) +} + +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/node_base.go b/taskfile/node_base.go index 2d81dded51..6bd1e66222 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 + headersByHost HeadersByHost } ) @@ -75,3 +76,10 @@ func WithCertKey(certKey string) NodeOption { node.certKey = certKey } } + +// WithHeaders sets the HTTP headers to send, keyed by host. +func WithHeaders(headersByHost HeadersByHost) NodeOption { + return func(node *baseNode) { + node.headersByHost = headersByHost + } +} diff --git a/taskfile/node_http.go b/taskfile/node_http.go index e8cbecba2d..f5ba91d83f 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.clientWithHeaders() + 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/reader.go b/taskfile/reader.go index fc5d6d30af..a4ede421e7 100644 --- a/taskfile/reader.go +++ b/taskfile/reader.go @@ -51,6 +51,7 @@ type ( caCert string cert string certKey string + headersByHost HeadersByHost debugFunc DebugFunc promptFunc PromptFunc promptMutex sync.Mutex @@ -242,6 +243,19 @@ func (o *readerCertKeyOption) ApplyToReader(r *Reader) { r.certKey = o.certKey } +// WithReaderHeaders sets the HTTP headers to send to each configured host. +func WithReaderHeaders(headersByHost HeadersByHost) ReaderOption { + return &readerHeadersOption{headersByHost: headersByHost} +} + +type readerHeadersOption struct { + headersByHost HeadersByHost +} + +func (o *readerHeadersOption) ApplyToReader(r *Reader) { + r.headersByHost = o.headersByHost +} + // 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), + WithHeaders(r.headersByHost), ) if err != nil { if include.Optional { 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/taskrc/ast/taskrc.go b/taskrc/ast/taskrc.go index 895b8f7ee8..a70209db5d 100644 --- a/taskrc/ast/taskrc.go +++ b/taskrc/ast/taskrc.go @@ -24,15 +24,22 @@ 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"` - 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"` +} + +// RemoteHeaders configures HTTP headers for a single host. +type RemoteHeaders 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. @@ -60,6 +67,7 @@ func (t *TaskRC) Merge(other *TaskRC) { slices.Sort(merged) t.Remote.TrustedHosts = slices.Compact(merged) } + 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) @@ -73,3 +81,19 @@ func (t *TaskRC) Merge(other *TaskRC) { t.Failfast = cmp.Or(other.Failfast, t.Failfast) t.TempDir = cmp.Or(other.TempDir, t.TempDir) } + +// 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 + } + 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 RemoteHeaders) int { + return cmp.Compare(a.Host, b.Host) + }) + return merged +} diff --git a/taskrc/taskrc_test.go b/taskrc/taskrc_test.go index dde9f9c58c..68ae7cd484 100644 --- a/taskrc/taskrc_test.go +++ b/taskrc/taskrc_test.go @@ -341,3 +341,67 @@ remote: assert.Equal(t, []string{"github.com", "gitlab.com"}, base.Remote.TrustedHosts) }) } + +func TestGetConfig_RemoteHeaders(t *testing.T) { //nolint:paralleltest // cannot run in parallel + _, _, localDir := setupDirs(t) + + configYAML := ` +remote: + headers: + - host: gitlab.com + headers: + 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.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_RemoteHeadersMerge(t *testing.T) { //nolint:paralleltest // cannot run in parallel + xdgConfigDir, homeDir, localDir := setupDirs(t) + + writeFile(t, xdgConfigDir, "taskrc.yml", ` +remote: + headers: + - 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: + headers: + - host: gitlab.com + headers: + JOB-TOKEN: from-home +`) + + cfg, err := GetConfig(localDir) + require.NoError(t, err) + require.NotNil(t, cfg) + 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.Headers) +} diff --git a/website/src/next/docs/reference/config.md b/website/src/next/docs/reference/config.md index be606b876b..7b9e6c7cad 100644 --- a/website/src/next/docs/reference/config.md +++ b/website/src/next/docs/reference/config.md @@ -300,6 +300,81 @@ 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.headers` + +- **Type**: `array of objects` +- **Default**: `[]` (empty list) +- **Description**: HTTP headers to send when downloading a remote Taskfile from + a given host, including authentication and custom headers + +```yaml +remote: + 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 +``` + +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 +[`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`. + +Functions compose, so an `Authorization` header needs no manual encoding: + +```yaml +remote: + headers: + - 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: + +| 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 +429,10 @@ remote: trusted-hosts: - github.com - gitlab.com + headers: + - host: gitlab.com + headers: + PRIVATE-TOKEN: '{{env "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..0f5bbd7165 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.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. + ## Special Variables The file-path [special variables](../docs/reference/templating.md#file-paths) diff --git a/website/src/public/next-schema-taskrc.json b/website/src/public/next-schema-taskrc.json index d12f4460bc..a49043a2b2 100644 --- a/website/src/public/next-schema-taskrc.json +++ b/website/src/public/next-schema-taskrc.json @@ -49,6 +49,28 @@ "items": { "type": "string" } + }, + "headers": { + "type": "array", + "description": "HTTP headers to send when downloading remote Taskfiles, per host, including authentication and custom headers.", + "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 support templating functions, e.g. {{env \"GITLAB_TOKEN\"}}.", + "additionalProperties": { + "type": "string" + } + } + }, + "required": ["host", "headers"], + "additionalProperties": false + } } }, "additionalProperties": false