Skip to content
Draft
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,21 @@

## Unreleased

### 🚀 Features

- Added a `remote.auth` 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

- Bumped the minimum Go version to 1.26. Task follows Go's two-latest support
Expand Down
16 changes: 16 additions & 0 deletions executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -36,6 +37,7 @@ type (
Download bool
Offline bool
TrustedHosts []string
RemoteAuth taskfile.HeadersByHost
Timeout time.Duration
CacheExpiryDuration time.Duration
RemoteCacheDir string
Expand Down Expand Up @@ -277,6 +279,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 taskfile.HeadersByHost) ExecutorOption {
return &remoteAuthOption{remoteAuth}
}

type remoteAuthOption struct {
remoteAuth taskfile.HeadersByHost
}

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 {
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ require (
github.com/stretchr/testify v1.11.1
github.com/zeebo/xxh3 v1.1.0
go.yaml.in/yaml/v3 v3.0.4
golang.org/x/net v0.58.0
golang.org/x/sync v0.22.0
golang.org/x/term v0.45.0
mvdan.cc/sh/moreinterp v0.0.0-20260817215856-d6550df7ed8d
Expand Down Expand Up @@ -121,7 +122,6 @@ require (
go.opentelemetry.io/otel/trace v1.45.0 // indirect
golang.org/x/crypto v0.55.0 // indirect
golang.org/x/exp v0.0.0-20260718201538-764159d718ef // indirect
golang.org/x/net v0.58.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect
Expand Down
18 changes: 18 additions & 0 deletions internal/flags/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -79,6 +80,7 @@ var (
Download bool
Offline bool
TrustedHosts []string
RemoteAuth taskfile.HeadersByHost
ClearCache bool
Timeout time.Duration
CacheExpiryDuration time.Duration
Expand Down Expand Up @@ -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.
RemoteAuth = remoteAuth(config)

// Gentle force experiment will override the force flag and add a new force-all flag
if experiments.GentleForce.Enabled() {
Expand Down Expand Up @@ -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),
Expand All @@ -311,6 +316,19 @@ 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.HeadersByHost {
if config == nil || len(config.Remote.Auth) == 0 {
return nil
}
byHost := make(taskfile.HeadersByHost, 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 != "" {
Expand Down
2 changes: 2 additions & 0 deletions setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
)
Expand Down
102 changes: 102 additions & 0 deletions taskfile/http_auth.go

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@vmaerten Hi, just looking at this and wondering if a more generic "headers" approach would be viable. Similar to curl with its -H option. Its the same code, just without "auth" (also drop from the schema).

Rational is that headers can be set for a number of reasons, from which authorisation is only a subset.

Just for example:

for _, headers := range config.Remote.headers {
	byHost[auth.Host] = headers
}

Also, it might be useful, or necessary, to have different headers for requests against the same host. If I understand correctly, you are consolidating (last wins).

But OK, I see that you put this in the taskrc file, and not the includes, so there is no solution for that.

Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package taskfile

import (
"cmp"
"fmt"
"maps"
"net/http"
"slices"

"golang.org/x/net/http/httpguts"

"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.
type HeadersByHost map[string]map[string]string

type authTransport struct {
base http.RoundTripper
host string
headers map[string]string
}

func (t *authTransport) 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) {
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)
}

// 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.authHeadersByHost, 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 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{
base: cmp.Or(client.Transport, http.DefaultTransport),
host: host,
headers: headers,
}
return &authenticated
}

// resolveAuthHeaders returns the expanded headers for host, or nil if none.
func resolveAuthHeaders(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 auth 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 resolved, nil
}

// validateHeaderName names the offending header; ReadContext discards the
// transport's own error.
func validateHeaderName(name string) error {
if !httpguts.ValidHeaderFieldName(name) {
return fmt.Errorf("invalid header name %q", name)
}
return nil
}

// hostMatches compares exactly, port included, as trusted hosts do.
func hostMatches(pattern, host string) bool {
return pattern == host
}
Loading