Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions config/resolvers/http-resolver-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,7 @@ metadata:
data:
# The maximum amount of time the http resolver will wait for a response from the server.
fetch-timeout: "1m"
# Controls whether the HTTP resolver blocks requests to private, loopback,
# link-local, and unspecified IP addresses (SSRF protection). Set to "false"
# to allow requests to internal/private network addresses (e.g. internal registries).
# block-private-ips: "true"
4 changes: 3 additions & 1 deletion docs/additional-configs.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ installation.

## Configuring built-in remote Task and Pipeline resolution

Four remote resolvers are currently provided as part of the Tekton Pipelines installation.
Five remote resolvers are currently provided as part of the Tekton Pipelines installation.
By default, these remote resolvers are enabled. Each resolver can be disabled by setting
the appropriate feature flag in the `resolvers-feature-flags` ConfigMap in the `tekton-pipelines-resolvers`
namespace:
Expand All @@ -53,6 +53,8 @@ namespace:
feature flag to `false`.
1. [The `cluster` resolver](./cluster-resolver.md), disabled by setting the `enable-cluster-resolver`
feature flag to `false`.
1. [The `http` resolver](./http-resolver.md), disabled by setting the `enable-http-resolver`
feature flag to `false`.

## Configuring CloudEvents notifications

Expand Down
1 change: 1 addition & 0 deletions docs/http-resolver.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ for the name, namespace and defaults that the resolver ships with.
| Option Name | Description | Example Values |
|-----------------------------|------------------------------------------------------|------------------------|
| `fetch-timeout` | The maximum time any fetching of URL resolution may take. **Note**: a global maximum timeout of 1 minute is currently enforced on _all_ resolution requests. | `1m`, `2s`, `700ms` |
| `block-private-ips` | Controls whether the HTTP resolver blocks requests to private, loopback, link-local, and unspecified IP addresses (SSRF protection). Defaults to `"true"`. Set to `"false"` to allow requests to internal/private network addresses (e.g. cluster-internal registries or services). | `"true"`, `"false"` |

## Usage

Expand Down
7 changes: 5 additions & 2 deletions pkg/remoteresolution/resolver/http/resolver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -358,7 +358,9 @@ func TestResolverReconcileBasicAuth(t *testing.T) {
p.url = svr.URL
}
request := createRequest(p)
cfg := make(map[string]string)
cfg := map[string]string{
httpresolution.BlockPrivateIPsKey: "false",
}
d := test.Data{
ConfigMaps: []*corev1.ConfigMap{{
ObjectMeta: metav1.ObjectMeta{
Expand Down Expand Up @@ -468,7 +470,8 @@ func toParams(m map[string]string) []pipelinev1.Param {

func contextWithConfig(timeout string) context.Context {
config := map[string]string{
httpresolution.TimeoutKey: timeout,
httpresolution.TimeoutKey: timeout,
httpresolution.BlockPrivateIPsKey: "false",
}
return resolutionframework.InjectResolverConfigToContext(context.Background(), config)
}
Expand Down
5 changes: 5 additions & 0 deletions pkg/resolution/resolver/http/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ const (
// the maximum duration of a resolution request for a file from http.
TimeoutKey = "fetch-timeout"

// BlockPrivateIPsKey is the configuration field name for controlling
// whether the HTTP resolver blocks requests to private, loopback,
// link-local, and unspecified IP addresses. Defaults to "true".
BlockPrivateIPsKey = "block-private-ips"

// maxResponseBodySize is the maximum response body size the HTTP resolver
// will read. This is hardcoded to 1 MiB which is below the etcd maximum
// object size (1.5 MiB), leaving room for the ResolutionRequest CRD
Expand Down
78 changes: 76 additions & 2 deletions pkg/resolution/resolver/http/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,11 @@ import (
"errors"
"fmt"
"io"
"net"
"net/http"
"net/netip"
"net/url"
"slices"
"strings"
"time"

Expand Down Expand Up @@ -202,6 +205,48 @@ func PopulateDefaultParams(ctx context.Context, params []pipelinev1.Param) (map[
return paramsMap, nil
}

// cgnatPrefix covers Carrier-grade NAT (RFC 6598), commonly used for internal
// VPC routing in cloud environments. IsGlobalUnicast() considers it global,
// so we block it explicitly.
var cgnatPrefix = netip.MustParsePrefix("100.64.0.0/10")

func isBlockedIP(addr netip.Addr) bool {
addr = addr.Unmap()
return !addr.IsGlobalUnicast() || addr.IsPrivate() || cgnatPrefix.Contains(addr)
}

func safeDialContext(ctx context.Context, network, addr string) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, fmt.Errorf("invalid address %s: %w", addr, err)
}

ips, err := (&net.Resolver{}).LookupNetIP(ctx, "ip", host)
if err != nil {
return nil, fmt.Errorf("DNS resolution failed for %s: %w", host, err)
}
if len(ips) == 0 {
return nil, fmt.Errorf("DNS resolution for %s returned no addresses", host)
}

if slices.ContainsFunc(ips, isBlockedIP) {
return nil, fmt.Errorf("requests to private/internal IP address for host %s are blocked; set %s to \"false\" in the %s ConfigMap to allow internal requests",
host, BlockPrivateIPsKey, configMapName)
}

var lastErr error
dialer := net.Dialer{}
for _, ip := range ips {
conn, dialErr := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
if dialErr != nil {
lastErr = dialErr
continue
}
return conn, nil
}
return nil, fmt.Errorf("failed to connect to %s: %w", addr, lastErr)
}

func makeHttpClient(ctx context.Context) (*http.Client, error) {
conf := framework.GetResolverConfigFromContext(ctx)
timeout, _ := time.ParseDuration(defaultHttpTimeoutValue)
Expand All @@ -212,9 +257,38 @@ func makeHttpClient(ctx context.Context) (*http.Client, error) {
return nil, fmt.Errorf("error parsing timeout value %s: %w", v, err)
}
}
return &http.Client{

client := &http.Client{
Timeout: timeout,
}, nil
}

blockPrivateIPs := true
if v, ok := conf[BlockPrivateIPsKey]; ok && v == "false" {
blockPrivateIPs = false
}

if blockPrivateIPs {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.DialContext = safeDialContext
client.Transport = transport
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return errors.New("stopped after 10 redirects")
}
host := req.URL.Hostname()
ips, err := (&net.Resolver{}).LookupNetIP(req.Context(), "ip", host)
if err != nil {
return fmt.Errorf("DNS resolution failed for redirect target %s: %w", host, err)
}
if slices.ContainsFunc(ips, isBlockedIP) {
return fmt.Errorf("redirect to private/internal IP address for host %s is blocked; set %s to \"false\" in the %s ConfigMap to allow internal requests",
host, BlockPrivateIPsKey, configMapName)
}
return nil
}
}

return client, nil
}

// compareSHA compares two hexadecimal SHA strings in constant time.
Expand Down
Loading
Loading