mirror of
https://github.com/go-task/task.git
synced 2026-09-02 04:02:08 +02:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: ''
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user