Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 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).

### 🐛 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.HostHeaders
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.HostHeaders) ExecutorOption {
return &remoteAuthOption{remoteAuth}
}

type remoteAuthOption struct {
remoteAuth taskfile.HostHeaders
}

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.HostHeaders
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.HostHeaders {
if config == nil || len(config.Remote.Auth) == 0 {
return nil
}
byHost := make(taskfile.HostHeaders, 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
97 changes: 97 additions & 0 deletions taskfile/http_auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package taskfile

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

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

// HostHeaders maps a host to the HTTP headers to send when fetching a remote
// Taskfile from it. Values may reference environment variables.
type HostHeaders 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.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 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(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)
}
resolved[name] = os.ExpandEnv(headers[name])
}
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