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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
15 changes: 15 additions & 0 deletions executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
17 changes: 17 additions & 0 deletions internal/flags/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -165,6 +166,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 +288,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 +315,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) 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 != "" {
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),
)
var taskNotFoundError errors.TaskfileNotFoundError
if errors.As(err, &taskNotFoundError) {
Expand Down Expand Up @@ -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),
)
Expand Down
20 changes: 14 additions & 6 deletions taskfile/node_base.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
)

Expand Down Expand Up @@ -75,3 +76,10 @@ func WithCertKey(certKey string) NodeOption {
node.certKey = certKey
}
}

// WithAuthHeaders sets the HTTP headers to send, keyed by host.
func WithAuthHeaders(authHeaders HostHeaders) NodeOption {
return func(node *baseNode) {
node.authHeaders = authHeaders
}
}
8 changes: 6 additions & 2 deletions taskfile/node_http.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
Expand Down
128 changes: 128 additions & 0 deletions taskfile/node_http_auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
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.
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 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 {
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 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 {
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; `$$` 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.
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 compares exactly, port included, as trusted hosts do.
func hostMatches(pattern, host string) bool {
return pattern == host
}
Loading