refactor(remote): template header values instead of expanding ${VAR}

Aligns the syntax with the rest of Task, and lets functions compose: a
Basic credential no longer needs its base64 computed by hand. The strict
expansion this replaces was already gone, so nothing is lost by the
switch.

Only functions resolve — the configuration file is read before any
Taskfile, so {{.VAR}} has nothing to read and produces an empty header.
That is documented next to the option.
This commit is contained in:
Valentin Maerten
2026-08-23 12:35:55 +02:00
parent 8fc344f6cc
commit bff4f97d7e
5 changed files with 71 additions and 21 deletions

View File

@@ -5,10 +5,10 @@
### 🚀 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).
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

View File

@@ -5,14 +5,15 @@ import (
"fmt"
"maps"
"net/http"
"os"
"slices"
"golang.org/x/net/http/httpguts"
"github.com/go-task/task/v3/internal/templater"
)
// HostHeaders maps a host to the HTTP headers to send when fetching a remote
// Taskfile from it. Values may reference environment variables.
// Taskfile from it. Values are templated, but no variables are available.
type HostHeaders map[string]map[string]string
type authTransport struct {
@@ -72,12 +73,16 @@ func resolveAuthHeaders(hostHeaders HostHeaders, host string) (map[string]string
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] = os.ExpandEnv(headers[name])
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
}

View File

@@ -41,25 +41,52 @@ func TestResolveAuthHeaders(t *testing.T) { //nolint:paralleltest // t.Setenv ca
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
name: "environment variable",
hostHeaders: HostHeaders{"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",
hostHeaders: HostHeaders{"gitlab.com": {"Authorization": "Bearer $TASK_TEST_TOKEN"}},
hostHeaders: HostHeaders{"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",
hostHeaders: HostHeaders{"gitlab.com": {"PRIVATE-TOKEN": "${TASK_TEST_UNSET}"}}, //nolint:gosec // an env var reference, not a credential
hostHeaders: HostHeaders{"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",
hostHeaders: HostHeaders{"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"},
},
{
// The .taskrc is read before any Taskfile, so no variable exists.
name: "a variable reference resolves to nothing",
hostHeaders: HostHeaders{"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",
hostHeaders: HostHeaders{"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",
hostHeaders: HostHeaders{"gitlab.com": {"PRIVATE-TOKEN": `{{env "TASK_TEST_TOKEN"`}}, //nolint:gosec // a template, not a credential
host: "gitlab.com",
wantErr: `remote auth for host "gitlab.com": template: :1: unclosed action`,
},
{
name: "header name with a space",
hostHeaders: HostHeaders{"gitlab.com": {"PRIVATE TOKEN": "token"}},
@@ -152,7 +179,7 @@ func TestHTTPNodeAuthHeaders(t *testing.T) { //nolint:paralleltest // t.Setenv c
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
mustHost(t, srv.URL): {"PRIVATE-TOKEN": `{{env "TASK_TEST_TOKEN"}}`}, //nolint:gosec // an env var reference, not a credential
}),
)
require.NoError(t, err)
@@ -200,7 +227,7 @@ func TestHTTPNodeAuthHeadersNotSentOnRedirect(t *testing.T) {
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_LAZY}"}, //nolint:gosec // an env var reference, not a credential
"gitlab.com": {"PRIVATE-TOKEN": `{{env "TASK_TEST_LAZY"}}`}, //nolint:gosec // an env var reference, not a credential
}),
)
require.NoError(t, err)

View File

@@ -312,10 +312,10 @@ remote:
auth:
- host: gitlab.com
headers:
PRIVATE-TOKEN: ${GITLAB_TOKEN}
PRIVATE-TOKEN: '{{env "GITLAB_TOKEN"}}'
- host: artifacts.example.com:8443
headers:
Authorization: Bearer ${ARTIFACTS_TOKEN}
Authorization: 'Bearer {{env "ARTIFACTS_TOKEN"}}'
```
This is the recommended way to authenticate a remote Taskfile. Unlike a
@@ -326,10 +326,28 @@ 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`, read when Task contacts the host.
An undefined variable expands to nothing, so the header is sent empty and the
server rejects it — prefer an environment variable over a literal value, which
cannot contain a `$` followed by a name.
[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 and the server rejects it with a `401`.
Functions compose, so an `Authorization` header needs no manual encoding:
```yaml
remote:
auth:
- 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:
@@ -409,7 +427,7 @@ remote:
auth:
- host: gitlab.com
headers:
PRIVATE-TOKEN: ${GITLAB_TOKEN}
PRIVATE-TOKEN: '{{env "GITLAB_TOKEN"}}'
cacert: ''
cert: ''
cert-key: ''

View File

@@ -62,7 +62,7 @@
},
"headers": {
"type": "object",
"description": "Headers to send. Values may reference environment variables with ${VAR} or $VAR.",
"description": "Headers to send. Values support templating functions, e.g. {{env \"GITLAB_TOKEN\"}}.",
"additionalProperties": {
"type": "string"
}