From bff4f97d7e5b98690e121e6d28ef457a586989f3 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sun, 23 Aug 2026 12:35:55 +0200 Subject: [PATCH] refactor(remote): template header values instead of expanding ${VAR} MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 8 ++--- taskfile/http_auth.go | 11 ++++-- taskfile/http_auth_test.go | 39 ++++++++++++++++++---- website/src/next/docs/reference/config.md | 32 ++++++++++++++---- website/src/public/next-schema-taskrc.json | 2 +- 5 files changed, 71 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf2940f7..2969fa63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/taskfile/http_auth.go b/taskfile/http_auth.go index b1871060..7159452b 100644 --- a/taskfile/http_auth.go +++ b/taskfile/http_auth.go @@ -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 } diff --git a/taskfile/http_auth_test.go b/taskfile/http_auth_test.go index 30891cbc..7092cabf 100644 --- a/taskfile/http_auth_test.go +++ b/taskfile/http_auth_test.go @@ -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) diff --git a/website/src/next/docs/reference/config.md b/website/src/next/docs/reference/config.md index 83c66f33..5552779c 100644 --- a/website/src/next/docs/reference/config.md +++ b/website/src/next/docs/reference/config.md @@ -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: '' diff --git a/website/src/public/next-schema-taskrc.json b/website/src/public/next-schema-taskrc.json index 9f4069d8..55a02c60 100644 --- a/website/src/public/next-schema-taskrc.json +++ b/website/src/public/next-schema-taskrc.json @@ -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" }