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.
This commit is contained in:
Valentin Maerten
2026-08-13 15:14:31 +02:00
parent b250872d9a
commit 8a93190060
14 changed files with 628 additions and 9 deletions

View File

@@ -2,6 +2,14 @@
## 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).
### 📦 Package API
- Bumped the minimum Go version to 1.26. Task follows Go's two-latest support

View File

@@ -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 {

View File

@@ -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 != "" {

View File

@@ -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
@@ -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),
)

View File

@@ -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
}
}

View File

@@ -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

141
taskfile/node_http_auth.go Normal file
View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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 <token>` |
| 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: ''

View File

@@ -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)

View File

@@ -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