2026-08-13 15:14:31 +02:00
|
|
|
package taskfile
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"cmp"
|
|
|
|
|
"fmt"
|
|
|
|
|
"maps"
|
|
|
|
|
"net/http"
|
|
|
|
|
"slices"
|
fix(remote): report a 401 instead of a missing Taskfile
RemoteExists treated every non-200 as an absent file, so a server
refusing the credentials ended up as "No Taskfile found", sending the
user to check the URL rather than the token. A 401 now stops the search
and reports the status code; the default names need the same credentials,
so trying them would only add rejected requests. A 403 is left alone: it
is also what a server without directory listing answers for a readable
directory.
That message being correct, the expansion no longer needs to refuse an
undefined variable: os.ExpandEnv is inlined and expandEnv is gone. The
`$$` escape goes with it, so a literal value can no longer hold a `$`
followed by a name; a secret carried in an environment variable is
unaffected, as os.Expand never rescans what it substituted.
Header names are validated with httpguts.ValidHeaderFieldName, the table
net/http itself uses, rather than a denylist that let X-Foo(bar) through.
golang.org/x/net was already in the module graph, so tidy only moves it
to the direct block.
Finally, node_http_auth.go becomes http_auth.go: the node_ prefix is for
files defining a Node type, and this one holds the auth concern of
HTTPNode plus hostMatches, which reader.go uses for trusted hosts.
2026-08-20 16:50:43 +02:00
|
|
|
|
|
|
|
|
"golang.org/x/net/http/httpguts"
|
2026-08-23 12:35:55 +02:00
|
|
|
|
|
|
|
|
"github.com/go-task/task/v3/internal/templater"
|
2026-08-13 15:14:31 +02:00
|
|
|
)
|
|
|
|
|
|
2026-08-29 21:47:53 +02:00
|
|
|
// HeadersByHost maps a host to the HTTP headers to send when fetching a remote
|
2026-08-23 12:35:55 +02:00
|
|
|
// Taskfile from it. Values are templated, but no variables are available.
|
2026-08-29 21:47:53 +02:00
|
|
|
type HeadersByHost map[string]map[string]string
|
2026-08-13 15:14:31 +02:00
|
|
|
|
|
|
|
|
type authTransport struct {
|
|
|
|
|
base http.RoundTripper
|
|
|
|
|
host string
|
|
|
|
|
headers map[string]string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (t *authTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
2026-08-13 18:06:12 +02:00
|
|
|
// Re-checked per request: a redirect goes through this same transport, and
|
|
|
|
|
// Go only strips Authorization, WWW-Authenticate and Cookie on its own.
|
2026-08-13 15:14:31 +02:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
fix(remote): report a 401 instead of a missing Taskfile
RemoteExists treated every non-200 as an absent file, so a server
refusing the credentials ended up as "No Taskfile found", sending the
user to check the URL rather than the token. A 401 now stops the search
and reports the status code; the default names need the same credentials,
so trying them would only add rejected requests. A 403 is left alone: it
is also what a server without directory listing answers for a readable
directory.
That message being correct, the expansion no longer needs to refuse an
undefined variable: os.ExpandEnv is inlined and expandEnv is gone. The
`$$` escape goes with it, so a literal value can no longer hold a `$`
followed by a name; a secret carried in an environment variable is
unaffected, as os.Expand never rescans what it substituted.
Header names are validated with httpguts.ValidHeaderFieldName, the table
net/http itself uses, rather than a denylist that let X-Foo(bar) through.
golang.org/x/net was already in the module graph, so tidy only moves it
to the direct block.
Finally, node_http_auth.go becomes http_auth.go: the node_ prefix is for
files defining a Node type, and this one holds the auth concern of
HTTPNode plus hostMatches, which reader.go uses for trusted hosts.
2026-08-20 16:50:43 +02:00
|
|
|
// authenticatedClient resolves on each read, not at build time, so a cached
|
|
|
|
|
// run needs no credentials.
|
2026-08-13 15:14:31 +02:00
|
|
|
func (node *HTTPNode) authenticatedClient() (*http.Client, error) {
|
2026-08-29 21:47:53 +02:00
|
|
|
headers, err := resolveAuthHeaders(node.authHeadersByHost, node.url.Host)
|
2026-08-13 15:14:31 +02:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if len(headers) == 0 {
|
|
|
|
|
return node.client, nil
|
|
|
|
|
}
|
|
|
|
|
return withAuthHeaders(node.client, node.url.Host, headers), nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-13 18:06:12 +02:00
|
|
|
// withAuthHeaders copies rather than mutates: buildHTTPClient returns the
|
2026-08-13 15:14:31 +02:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
fix(remote): report a 401 instead of a missing Taskfile
RemoteExists treated every non-200 as an absent file, so a server
refusing the credentials ended up as "No Taskfile found", sending the
user to check the URL rather than the token. A 401 now stops the search
and reports the status code; the default names need the same credentials,
so trying them would only add rejected requests. A 403 is left alone: it
is also what a server without directory listing answers for a readable
directory.
That message being correct, the expansion no longer needs to refuse an
undefined variable: os.ExpandEnv is inlined and expandEnv is gone. The
`$$` escape goes with it, so a literal value can no longer hold a `$`
followed by a name; a secret carried in an environment variable is
unaffected, as os.Expand never rescans what it substituted.
Header names are validated with httpguts.ValidHeaderFieldName, the table
net/http itself uses, rather than a denylist that let X-Foo(bar) through.
golang.org/x/net was already in the module graph, so tidy only moves it
to the direct block.
Finally, node_http_auth.go becomes http_auth.go: the node_ prefix is for
files defining a Node type, and this one holds the auth concern of
HTTPNode plus hostMatches, which reader.go uses for trusted hosts.
2026-08-20 16:50:43 +02:00
|
|
|
// resolveAuthHeaders returns the expanded headers for host, or nil if none.
|
2026-08-29 21:47:53 +02:00
|
|
|
func resolveAuthHeaders(headersByHost HeadersByHost, host string) (map[string]string, error) {
|
2026-08-13 15:14:31 +02:00
|
|
|
var headers map[string]string
|
2026-08-29 21:47:53 +02:00
|
|
|
for pattern, patternHeaders := range headersByHost {
|
2026-08-13 15:14:31 +02:00
|
|
|
if hostMatches(pattern, host) {
|
|
|
|
|
headers = patternHeaders
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if len(headers) == 0 {
|
|
|
|
|
return nil, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-23 12:35:55 +02:00
|
|
|
cache := &templater.Cache{}
|
2026-08-13 15:14:31 +02:00
|
|
|
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)
|
|
|
|
|
}
|
2026-08-23 12:35:55 +02:00
|
|
|
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)
|
2026-08-13 15:14:31 +02:00
|
|
|
}
|
|
|
|
|
return resolved, nil
|
|
|
|
|
}
|
|
|
|
|
|
fix(remote): report a 401 instead of a missing Taskfile
RemoteExists treated every non-200 as an absent file, so a server
refusing the credentials ended up as "No Taskfile found", sending the
user to check the URL rather than the token. A 401 now stops the search
and reports the status code; the default names need the same credentials,
so trying them would only add rejected requests. A 403 is left alone: it
is also what a server without directory listing answers for a readable
directory.
That message being correct, the expansion no longer needs to refuse an
undefined variable: os.ExpandEnv is inlined and expandEnv is gone. The
`$$` escape goes with it, so a literal value can no longer hold a `$`
followed by a name; a secret carried in an environment variable is
unaffected, as os.Expand never rescans what it substituted.
Header names are validated with httpguts.ValidHeaderFieldName, the table
net/http itself uses, rather than a denylist that let X-Foo(bar) through.
golang.org/x/net was already in the module graph, so tidy only moves it
to the direct block.
Finally, node_http_auth.go becomes http_auth.go: the node_ prefix is for
files defining a Node type, and this one holds the auth concern of
HTTPNode plus hostMatches, which reader.go uses for trusted hosts.
2026-08-20 16:50:43 +02:00
|
|
|
// validateHeaderName names the offending header; ReadContext discards the
|
|
|
|
|
// transport's own error.
|
2026-08-13 15:14:31 +02:00
|
|
|
func validateHeaderName(name string) error {
|
fix(remote): report a 401 instead of a missing Taskfile
RemoteExists treated every non-200 as an absent file, so a server
refusing the credentials ended up as "No Taskfile found", sending the
user to check the URL rather than the token. A 401 now stops the search
and reports the status code; the default names need the same credentials,
so trying them would only add rejected requests. A 403 is left alone: it
is also what a server without directory listing answers for a readable
directory.
That message being correct, the expansion no longer needs to refuse an
undefined variable: os.ExpandEnv is inlined and expandEnv is gone. The
`$$` escape goes with it, so a literal value can no longer hold a `$`
followed by a name; a secret carried in an environment variable is
unaffected, as os.Expand never rescans what it substituted.
Header names are validated with httpguts.ValidHeaderFieldName, the table
net/http itself uses, rather than a denylist that let X-Foo(bar) through.
golang.org/x/net was already in the module graph, so tidy only moves it
to the direct block.
Finally, node_http_auth.go becomes http_auth.go: the node_ prefix is for
files defining a Node type, and this one holds the auth concern of
HTTPNode plus hostMatches, which reader.go uses for trusted hosts.
2026-08-20 16:50:43 +02:00
|
|
|
if !httpguts.ValidHeaderFieldName(name) {
|
|
|
|
|
return fmt.Errorf("invalid header name %q", name)
|
2026-08-13 15:14:31 +02:00
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-13 18:06:12 +02:00
|
|
|
// hostMatches compares exactly, port included, as trusted hosts do.
|
2026-08-13 15:14:31 +02:00
|
|
|
func hostMatches(pattern, host string) bool {
|
|
|
|
|
return pattern == host
|
|
|
|
|
}
|