diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fa1dce48a6..f107fb6b7c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,7 @@ * [ENHANCEMENT] Ruler: Added `-ruler.enabled-tenants` and `-ruler.disabled-tenants` to explicitly enable or disable rules processing for specific tenants. #4074 * [ENHANCEMENT] Block Storage Ingester: `/flush` now accepts two new parameters: `tenant` to specify tenant to flush and `wait=true` to make call synchronous. Multiple tenants can be specified by repeating `tenant` parameter. If no `tenant` is specified, all tenants are flushed, as before. #4073 * [ENHANCEMENT] Alertmanager: validate configured `-alertmanager.web.external-url` and fail if ends with `/`. #4081 +* [ENHANCEMENT] Alertmanager: added `-alertmanager.receivers-firewall.block.cidr-networks` and `-alertmanager.receivers-firewall.block.private-addresses` to block specific network addresses in HTTP-based Alertmanager receiver integrations. #4085 * [ENHANCEMENT] Allow configuration of Cassandra's host selection policy. #4069 * [ENHANCEMENT] Store-gateway: retry synching blocks if a per-tenant sync fails. #3975 #4088 * [ENHANCEMENT] Add metric `cortex_tcp_connections` exposing the current number of accepted TCP connections. #4099 diff --git a/docs/blocks-storage/production-tips.md b/docs/blocks-storage/production-tips.md index fbfc4a6d293..1c01cf3e242 100644 --- a/docs/blocks-storage/production-tips.md +++ b/docs/blocks-storage/production-tips.md @@ -105,3 +105,14 @@ You can see that the initial migration is done by looking for the following mess The rule of thumb to ensure memcached is properly scaled is to make sure evictions happen infrequently. When that's not the case and they affect query performances, the suggestion is to scale out the memcached cluster adding more nodes or increasing the memory limit of existing ones. We also recommend to run a different memcached cluster for each cache type (metadata, index, chunks). It's not required, but suggested to not worry about the effect of memory pressure on a cache type against others. + +## Alertmanager + +### Ensure Alertmanager networking is hardened + +If the Alertmanager API is enabled, users with access to Cortex can autonomously configure the Alertmanager, including receiver integrations that allow to issue network requests to the configured URL (eg. webhook). If the Alertmanager network is not hardened, Cortex users may have the ability to issue network requests to any network endpoint including services running in the local network accessible by the Alertmanager itself. + +Despite hardening the system is out of the scope of Cortex, Cortex provides a basic built-in firewall to block connections created by Alertmanager receiver integrations: + +- `-alertmanager.receivers-firewall.block.cidr-networks` +- `-alertmanager.receivers-firewall.block.private-addresses` diff --git a/docs/configuration/config-file-reference.md b/docs/configuration/config-file-reference.md index 564fb891499..1d792b8f476 100644 --- a/docs/configuration/config-file-reference.md +++ b/docs/configuration/config-file-reference.md @@ -1849,6 +1849,20 @@ The `alertmanager_config` configures the Cortex alertmanager. # CLI flag: -alertmanager.max-recv-msg-size [max_recv_msg_size: | default = 16777216] +receivers_firewall: + block: + # Comma-separated list of network CIDRs to block in Alertmanager receiver + # integrations. + # CLI flag: -alertmanager.receivers-firewall.block.cidr-networks + [cidr_networks: | default = ""] + + # True to block private and local addresses in Alertmanager receiver + # integrations. It blocks private addresses defined by RFC 1918 (IPv4 + # addresses) and RFC 4193 (IPv6 addresses), as well as loopback, local + # unicast and local multicast addresses. + # CLI flag: -alertmanager.receivers-firewall.block.private-addresses + [private_addresses: | default = false] + # Shard tenants across multiple alertmanager instances. # CLI flag: -alertmanager.sharding-enabled [sharding_enabled: | default = false] diff --git a/docs/configuration/v1-guarantees.md b/docs/configuration/v1-guarantees.md index 4f88611168f..2a3cddf8a48 100644 --- a/docs/configuration/v1-guarantees.md +++ b/docs/configuration/v1-guarantees.md @@ -41,7 +41,10 @@ Currently experimental features are: - Azure blob storage. - Zone awareness based replication. - Ruler API (to PUT rules). -- Alertmanager API +- Alertmanager: + - API (enabled via `-experimental.alertmanager.enable-api`) + - Sharding of tenants across multiple instances (enabled via `-alertmanager.sharding-enabled`) + - Receiver integrations firewall (configured via `-alertmanager.receivers-firewall.*`) - Memcached client DNS-based service discovery. - Delete series APIs. - In-memory (FIFO) and Redis cache. @@ -61,7 +64,6 @@ Currently experimental features are: - The bucket index support in the querier and store-gateway (enabled via `-blocks-storage.bucket-store.bucket-index.enabled=true`) is experimental - The block deletion marks migration support in the compactor (`-compactor.block-deletion-marks-migration-enabled`) is temporarily and will be removed in future versions - Querier: tenant federation -- Alertmanager: Sharding of tenants across multiple instances - The thanosconvert tool for converting Thanos block metadata to Cortex - HA Tracker: cleanup of old replicas from KV Store. - Flags for configuring whether blocks-ingester streams samples or chunks are temporary, and will be removed when feature is tested: diff --git a/pkg/alertmanager/alertmanager.go b/pkg/alertmanager/alertmanager.go index b90b20d38c9..e2951e0a35e 100644 --- a/pkg/alertmanager/alertmanager.go +++ b/pkg/alertmanager/alertmanager.go @@ -40,10 +40,12 @@ import ( "github.com/prometheus/alertmanager/ui" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" + commoncfg "github.com/prometheus/common/config" "github.com/prometheus/common/model" "github.com/prometheus/common/route" "github.com/cortexproject/cortex/pkg/alertmanager/alertstore" + util_net "github.com/cortexproject/cortex/pkg/util/net" "github.com/cortexproject/cortex/pkg/util/services" ) @@ -59,12 +61,13 @@ const ( // Config configures an Alertmanager. type Config struct { - UserID string - Logger log.Logger - Peer *cluster.Peer - PeerTimeout time.Duration - Retention time.Duration - ExternalURL *url.URL + UserID string + Logger log.Logger + Peer *cluster.Peer + PeerTimeout time.Duration + Retention time.Duration + ExternalURL *url.URL + ReceiversFirewall FirewallConfig // Tenant-specific local directory where AM can store its state (notifications, silences, templates). When AM is stopped, entire dir is removed. TenantDataDir string @@ -94,6 +97,7 @@ type Alertmanager struct { wg sync.WaitGroup mux *http.ServeMux registry *prometheus.Registry + firewallDialer *util_net.FirewallDialer // The Dispatcher is the only component we need to recreate when we call ApplyConfig. // Given its metrics don't have any variable labels we need to re-use the same metrics. @@ -147,6 +151,10 @@ func New(cfg *Config, reg *prometheus.Registry) (*Alertmanager, error) { cfg: cfg, logger: log.With(cfg.Logger, "user", cfg.UserID), stop: make(chan struct{}), + firewallDialer: util_net.NewFirewallDialer(util_net.FirewallDialerConfig{ + BlockCIDRNetworks: cfg.ReceiversFirewall.Block.CIDRNetworks, + BlockPrivateAddresses: cfg.ReceiversFirewall.Block.PrivateAddresses, + }), configHashMetric: promauto.With(reg).NewGauge(prometheus.GaugeOpts{ Name: "alertmanager_config_hash", Help: "Hash of the currently loaded alertmanager configuration.", @@ -315,7 +323,7 @@ func (am *Alertmanager) ApplyConfig(userID string, conf *config.Config, rawCfg s return d + waitFunc() } - integrationsMap, err := buildIntegrationsMap(conf.Receivers, tmpl, am.logger) + integrationsMap, err := buildIntegrationsMap(conf.Receivers, tmpl, am.firewallDialer, am.logger) if err != nil { return nil } @@ -407,10 +415,10 @@ func (am *Alertmanager) getFullState() (*clusterpb.FullState, error) { // buildIntegrationsMap builds a map of name to the list of integration notifiers off of a // list of receiver config. -func buildIntegrationsMap(nc []*config.Receiver, tmpl *template.Template, logger log.Logger) (map[string][]notify.Integration, error) { +func buildIntegrationsMap(nc []*config.Receiver, tmpl *template.Template, firewallDialer *util_net.FirewallDialer, logger log.Logger) (map[string][]notify.Integration, error) { integrationsMap := make(map[string][]notify.Integration, len(nc)) for _, rcv := range nc { - integrations, err := buildReceiverIntegrations(rcv, tmpl, logger) + integrations, err := buildReceiverIntegrations(rcv, tmpl, firewallDialer, logger) if err != nil { return nil, err } @@ -422,7 +430,7 @@ func buildIntegrationsMap(nc []*config.Receiver, tmpl *template.Template, logger // buildReceiverIntegrations builds a list of integration notifiers off of a // receiver config. // Taken from https://github.com/prometheus/alertmanager/blob/94d875f1227b29abece661db1a68c001122d1da5/cmd/alertmanager/main.go#L112-L159. -func buildReceiverIntegrations(nc *config.Receiver, tmpl *template.Template, logger log.Logger) ([]notify.Integration, error) { +func buildReceiverIntegrations(nc *config.Receiver, tmpl *template.Template, firewallDialer *util_net.FirewallDialer, logger log.Logger) ([]notify.Integration, error) { var ( errs types.MultiError integrations []notify.Integration @@ -436,29 +444,34 @@ func buildReceiverIntegrations(nc *config.Receiver, tmpl *template.Template, log } ) + // Inject the firewall to any receiver integration supporting it. + httpOps := []commoncfg.HTTPClientOption{ + commoncfg.WithDialContextFunc(firewallDialer.DialContext), + } + for i, c := range nc.WebhookConfigs { - add("webhook", i, c, func(l log.Logger) (notify.Notifier, error) { return webhook.New(c, tmpl, l) }) + add("webhook", i, c, func(l log.Logger) (notify.Notifier, error) { return webhook.New(c, tmpl, l, httpOps...) }) } for i, c := range nc.EmailConfigs { add("email", i, c, func(l log.Logger) (notify.Notifier, error) { return email.New(c, tmpl, l), nil }) } for i, c := range nc.PagerdutyConfigs { - add("pagerduty", i, c, func(l log.Logger) (notify.Notifier, error) { return pagerduty.New(c, tmpl, l) }) + add("pagerduty", i, c, func(l log.Logger) (notify.Notifier, error) { return pagerduty.New(c, tmpl, l, httpOps...) }) } for i, c := range nc.OpsGenieConfigs { - add("opsgenie", i, c, func(l log.Logger) (notify.Notifier, error) { return opsgenie.New(c, tmpl, l) }) + add("opsgenie", i, c, func(l log.Logger) (notify.Notifier, error) { return opsgenie.New(c, tmpl, l, httpOps...) }) } for i, c := range nc.WechatConfigs { - add("wechat", i, c, func(l log.Logger) (notify.Notifier, error) { return wechat.New(c, tmpl, l) }) + add("wechat", i, c, func(l log.Logger) (notify.Notifier, error) { return wechat.New(c, tmpl, l, httpOps...) }) } for i, c := range nc.SlackConfigs { - add("slack", i, c, func(l log.Logger) (notify.Notifier, error) { return slack.New(c, tmpl, l) }) + add("slack", i, c, func(l log.Logger) (notify.Notifier, error) { return slack.New(c, tmpl, l, httpOps...) }) } for i, c := range nc.VictorOpsConfigs { - add("victorops", i, c, func(l log.Logger) (notify.Notifier, error) { return victorops.New(c, tmpl, l) }) + add("victorops", i, c, func(l log.Logger) (notify.Notifier, error) { return victorops.New(c, tmpl, l, httpOps...) }) } for i, c := range nc.PushoverConfigs { - add("pushover", i, c, func(l log.Logger) (notify.Notifier, error) { return pushover.New(c, tmpl, l) }) + add("pushover", i, c, func(l log.Logger) (notify.Notifier, error) { return pushover.New(c, tmpl, l, httpOps...) }) } if errs.Len() > 0 { return nil, &errs diff --git a/pkg/alertmanager/firewall.go b/pkg/alertmanager/firewall.go new file mode 100644 index 00000000000..8f057180305 --- /dev/null +++ b/pkg/alertmanager/firewall.go @@ -0,0 +1,26 @@ +package alertmanager + +import ( + "flag" + "fmt" + + "github.com/cortexproject/cortex/pkg/util/flagext" +) + +type FirewallConfig struct { + Block FirewallHostsSpec `yaml:"block"` +} + +func (cfg *FirewallConfig) RegisterFlagsWithPrefix(prefix string, f *flag.FlagSet) { + cfg.Block.RegisterFlagsWithPrefix(prefix+".block", "block", f) +} + +type FirewallHostsSpec struct { + CIDRNetworks flagext.CIDRSliceCSV `yaml:"cidr_networks"` + PrivateAddresses bool `yaml:"private_addresses"` +} + +func (cfg *FirewallHostsSpec) RegisterFlagsWithPrefix(prefix, action string, f *flag.FlagSet) { + f.Var(&cfg.CIDRNetworks, prefix+".cidr-networks", fmt.Sprintf("Comma-separated list of network CIDRs to %s in Alertmanager receiver integrations.", action)) + f.BoolVar(&cfg.PrivateAddresses, prefix+".private-addresses", false, fmt.Sprintf("True to %s private and local addresses in Alertmanager receiver integrations. It blocks private addresses defined by RFC 1918 (IPv4 addresses) and RFC 4193 (IPv6 addresses), as well as loopback, local unicast and local multicast addresses.", action)) +} diff --git a/pkg/alertmanager/multitenant.go b/pkg/alertmanager/multitenant.go index 661cbe23cc1..9e773eef40e 100644 --- a/pkg/alertmanager/multitenant.go +++ b/pkg/alertmanager/multitenant.go @@ -102,11 +102,12 @@ func init() { // MultitenantAlertmanagerConfig is the configuration for a multitenant Alertmanager. type MultitenantAlertmanagerConfig struct { - DataDir string `yaml:"data_dir"` - Retention time.Duration `yaml:"retention"` - ExternalURL flagext.URLValue `yaml:"external_url"` - PollInterval time.Duration `yaml:"poll_interval"` - MaxRecvMsgSize int64 `yaml:"max_recv_msg_size"` + DataDir string `yaml:"data_dir"` + Retention time.Duration `yaml:"retention"` + ExternalURL flagext.URLValue `yaml:"external_url"` + PollInterval time.Duration `yaml:"poll_interval"` + MaxRecvMsgSize int64 `yaml:"max_recv_msg_size"` + ReceiversFirewall FirewallConfig `yaml:"receivers_firewall"` // Enable sharding for the Alertmanager ShardingEnabled bool `yaml:"sharding_enabled"` @@ -158,9 +159,8 @@ func (cfg *MultitenantAlertmanagerConfig) RegisterFlags(f *flag.FlagSet) { f.BoolVar(&cfg.ShardingEnabled, "alertmanager.sharding-enabled", false, "Shard tenants across multiple alertmanager instances.") cfg.AlertmanagerClient.RegisterFlagsWithPrefix("alertmanager.alertmanager-client", f) - cfg.Persister.RegisterFlagsWithPrefix("alertmanager", f) - + cfg.ReceiversFirewall.RegisterFlagsWithPrefix("alertmanager.receivers-firewall", f) cfg.ShardingRing.RegisterFlags(f) cfg.Store.RegisterFlags(f) cfg.Cluster.RegisterFlags(f) @@ -873,6 +873,7 @@ func (am *MultitenantAlertmanager) newAlertmanager(userID string, amConfig *amco ReplicationFactor: am.cfg.ShardingRing.ReplicationFactor, Store: am.store, PersisterConfig: am.cfg.Persister, + ReceiversFirewall: am.cfg.ReceiversFirewall, }, reg) if err != nil { return nil, fmt.Errorf("unable to start Alertmanager for user %v: %v", userID, err) diff --git a/pkg/alertmanager/multitenant_test.go b/pkg/alertmanager/multitenant_test.go index 017ba6ee6a1..41226be5a78 100644 --- a/pkg/alertmanager/multitenant_test.go +++ b/pkg/alertmanager/multitenant_test.go @@ -24,11 +24,13 @@ import ( "github.com/prometheus/alertmanager/types" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/testutil" + "github.com/prometheus/common/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/thanos-io/thanos/pkg/objstore" "github.com/weaveworks/common/httpgrpc" "github.com/weaveworks/common/user" + "go.uber.org/atomic" "google.golang.org/grpc" "github.com/cortexproject/cortex/pkg/alertmanager/alertmanagerpb" @@ -277,6 +279,175 @@ templates: `), "cortex_alertmanager_config_last_reload_successful")) } +func TestMultitenantAlertmanager_FirewallShouldBlockHTTPBasedReceiversWhenEnabled(t *testing.T) { + tests := map[string]struct { + getAlertmanagerConfig func(backendURL string) string + }{ + "webhook": { + getAlertmanagerConfig: func(backendURL string) string { + return fmt.Sprintf(` +route: + receiver: webhook + group_wait: 0s + +receivers: + - name: webhook + webhook_configs: + - url: %s +`, backendURL) + }, + }, + "pagerduty": { + getAlertmanagerConfig: func(backendURL string) string { + return fmt.Sprintf(` +route: + receiver: pagerduty + group_wait: 0s + +receivers: + - name: pagerduty + pagerduty_configs: + - url: %s + routing_key: secret +`, backendURL) + }, + }, + "slack": { + getAlertmanagerConfig: func(backendURL string) string { + return fmt.Sprintf(` +route: + receiver: slack + group_wait: 0s + +receivers: + - name: slack + slack_configs: + - api_url: %s + channel: test +`, backendURL) + }, + }, + "opsgenie": { + getAlertmanagerConfig: func(backendURL string) string { + return fmt.Sprintf(` +route: + receiver: opsgenie + group_wait: 0s + +receivers: + - name: opsgenie + opsgenie_configs: + - api_url: %s + api_key: secret +`, backendURL) + }, + }, + "wechat": { + getAlertmanagerConfig: func(backendURL string) string { + return fmt.Sprintf(` +route: + receiver: wechat + group_wait: 0s + +receivers: + - name: wechat + wechat_configs: + - api_url: %s + api_secret: secret + corp_id: babycorp +`, backendURL) + }, + }, + } + + for receiverName, testData := range tests { + for _, firewallEnabled := range []bool{true, false} { + t.Run(fmt.Sprintf("%s firewall: %v", receiverName, firewallEnabled), func(t *testing.T) { + ctx := context.Background() + userID := "user-1" + serverInvoked := atomic.NewBool(false) + + // Create a local HTTP server to test whether the request is received. + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + serverInvoked.Store(true) + writer.WriteHeader(http.StatusOK) + })) + defer server.Close() + + // Create the alertmanager config. + alertmanagerCfg := testData.getAlertmanagerConfig(fmt.Sprintf("http://%s", server.Listener.Addr().String())) + + // Store the alertmanager config in the bucket. + store := prepareInMemoryAlertStore() + require.NoError(t, store.SetAlertConfig(ctx, alertspb.AlertConfigDesc{ + User: userID, + RawConfig: alertmanagerCfg, + })) + + // Prepare the alertmanager config. + cfg := mockAlertmanagerConfig(t) + cfg.ReceiversFirewall.Block.PrivateAddresses = firewallEnabled + + // Start the alertmanager. + reg := prometheus.NewPedanticRegistry() + am, err := createMultitenantAlertmanager(cfg, nil, nil, store, nil, log.NewNopLogger(), reg) + require.NoError(t, err) + require.NoError(t, services.StartAndAwaitRunning(ctx, am)) + t.Cleanup(func() { + require.NoError(t, services.StopAndAwaitTerminated(ctx, am)) + }) + + // Ensure the configs are synced correctly. + assert.NoError(t, testutil.GatherAndCompare(reg, bytes.NewBufferString(` + # HELP cortex_alertmanager_config_last_reload_successful Boolean set to 1 whenever the last configuration reload attempt was successful. + # TYPE cortex_alertmanager_config_last_reload_successful gauge + cortex_alertmanager_config_last_reload_successful{user="user-1"} 1 + `), "cortex_alertmanager_config_last_reload_successful")) + + // Create an alert to push. + alerts := types.Alerts(&types.Alert{ + Alert: model.Alert{ + Labels: map[model.LabelName]model.LabelValue{model.AlertNameLabel: "test"}, + StartsAt: time.Now().Add(-time.Minute), + EndsAt: time.Now().Add(time.Minute), + }, + UpdatedAt: time.Now(), + Timeout: false, + }) + + alertsPayload, err := json.Marshal(alerts) + require.NoError(t, err) + + // Push an alert. + req := httptest.NewRequest(http.MethodPost, cfg.ExternalURL.String()+"/api/v1/alerts", bytes.NewReader(alertsPayload)) + req.Header.Set("content-type", "application/json") + reqCtx := user.InjectOrgID(req.Context(), userID) + { + w := httptest.NewRecorder() + am.ServeHTTP(w, req.WithContext(reqCtx)) + + resp := w.Result() + _, err := ioutil.ReadAll(resp.Body) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, w.Code) + } + + // Ensure the server endpoint has not been called if firewall is enabled. Since the alert is delivered + // asynchronously, we should pool it for a short period. + deadline := time.Now().Add(time.Second) + for { + if time.Now().After(deadline) || serverInvoked.Load() { + break + } + time.Sleep(100 * time.Millisecond) + } + + assert.Equal(t, !firewallEnabled, serverInvoked.Load()) + }) + } + } +} + func TestMultitenantAlertmanager_migrateStateFilesToPerTenantDirectories(t *testing.T) { ctx := context.Background() diff --git a/pkg/util/flagext/cidr.go b/pkg/util/flagext/cidr.go new file mode 100644 index 00000000000..72b93b680cf --- /dev/null +++ b/pkg/util/flagext/cidr.go @@ -0,0 +1,82 @@ +package flagext + +import ( + "net" + "strings" + + "github.com/pkg/errors" +) + +// CIDR is a network CIDR. +type CIDR struct { + Value *net.IPNet +} + +// String implements flag.Value. +func (c CIDR) String() string { + if c.Value == nil { + return "" + } + return c.Value.String() +} + +// Set implements flag.Value. +func (c *CIDR) Set(s string) error { + _, value, err := net.ParseCIDR(s) + if err != nil { + return err + } + c.Value = value + return nil +} + +// CIDRSliceCSV is a slice of CIDRs that is parsed from a comma-separated string. +// It implements flag.Value and yaml Marshalers. +type CIDRSliceCSV []CIDR + +// String implements flag.Value +func (c CIDRSliceCSV) String() string { + values := make([]string, 0, len(c)) + for _, cidr := range c { + values = append(values, cidr.String()) + } + + return strings.Join(values, ",") +} + +// Set implements flag.Value +func (c *CIDRSliceCSV) Set(s string) error { + parts := strings.Split(s, ",") + + for _, part := range parts { + cidr := &CIDR{} + if err := cidr.Set(part); err != nil { + return errors.Wrapf(err, "cidr: %s", part) + } + + *c = append(*c, *cidr) + } + + return nil +} + +// UnmarshalYAML implements yaml.Unmarshaler. +func (c *CIDRSliceCSV) UnmarshalYAML(unmarshal func(interface{}) error) error { + var s string + if err := unmarshal(&s); err != nil { + return err + } + + // An empty string means no CIDRs has been configured. + if s == "" { + *c = nil + return nil + } + + return c.Set(s) +} + +// MarshalYAML implements yaml.Marshaler. +func (c CIDRSliceCSV) MarshalYAML() (interface{}, error) { + return c.String(), nil +} diff --git a/pkg/util/flagext/cidr_test.go b/pkg/util/flagext/cidr_test.go new file mode 100644 index 00000000000..1e1209adfc4 --- /dev/null +++ b/pkg/util/flagext/cidr_test.go @@ -0,0 +1,51 @@ +package flagext + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "gopkg.in/yaml.v2" +) + +func Test_CIDRSliceCSV_YamlMarshalling(t *testing.T) { + type TestStruct struct { + CIDRs CIDRSliceCSV `yaml:"cidrs"` + } + + tests := map[string]struct { + input string + expected []string + }{ + "should marshal empty config": { + input: "cidrs: \"\"\n", + expected: nil, + }, + "should marshal single value": { + input: "cidrs: 127.0.0.1/32\n", + expected: []string{"127.0.0.1/32"}, + }, + "should marshal multiple comma-separated values": { + input: "cidrs: 127.0.0.1/32,10.0.10.0/28,fdf8:f53b:82e4::/100,192.168.0.0/20\n", + expected: []string{"127.0.0.1/32", "10.0.10.0/28", "fdf8:f53b:82e4::/100", "192.168.0.0/20"}, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // Unmarshal. + actual := TestStruct{} + err := yaml.Unmarshal([]byte(tc.input), &actual) + assert.NoError(t, err) + + assert.Len(t, actual.CIDRs, len(tc.expected)) + for idx, cidr := range actual.CIDRs { + assert.Equal(t, tc.expected[idx], cidr.String()) + } + + // Marshal. + out, err := yaml.Marshal(actual) + assert.NoError(t, err) + assert.Equal(t, tc.input, string(out)) + }) + } +} diff --git a/pkg/util/net/firewall_dialer.go b/pkg/util/net/firewall_dialer.go new file mode 100644 index 00000000000..5bb7af530c7 --- /dev/null +++ b/pkg/util/net/firewall_dialer.go @@ -0,0 +1,93 @@ +package net + +import ( + "context" + "net" + "syscall" + + "github.com/pkg/errors" + + "github.com/cortexproject/cortex/pkg/util/flagext" +) + +var errBlockedAddress = errors.New("blocked address") +var errInvalidAddress = errors.New("invalid address") + +type FirewallDialerConfig struct { + BlockCIDRNetworks []flagext.CIDR + BlockPrivateAddresses bool +} + +// FirewallDialer is a net dialer which integrates a firewall to block specific addresses. +type FirewallDialer struct { + parent *net.Dialer + cfg FirewallDialerConfig +} + +func NewFirewallDialer(cfg FirewallDialerConfig) *FirewallDialer { + d := &FirewallDialer{cfg: cfg} + d.parent = &net.Dialer{Control: d.control} + return d +} + +func (d *FirewallDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { + return d.parent.DialContext(ctx, network, address) +} + +func (d *FirewallDialer) control(_, address string, _ syscall.RawConn) error { + // Skip any control if no firewall has been configured. + if !d.cfg.BlockPrivateAddresses && len(d.cfg.BlockCIDRNetworks) == 0 { + return nil + } + + host, _, err := net.SplitHostPort(address) + if err != nil { + return errInvalidAddress + } + + // We expect an IP as address because the DNS resolution already occurred. + ip := net.ParseIP(host) + if ip == nil { + return errBlockedAddress + } + + if d.cfg.BlockPrivateAddresses && (isPrivate(ip) || isLocal(ip)) { + return errBlockedAddress + } + + for _, cidr := range d.cfg.BlockCIDRNetworks { + if cidr.Value.Contains(ip) { + return errBlockedAddress + } + } + + return nil +} + +func isLocal(ip net.IP) bool { + return ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() +} + +// isPrivate reports whether ip is a private address, according to +// RFC 1918 (IPv4 addresses) and RFC 4193 (IPv6 addresses). +// +// This function has been copied from golang and should be removed once +// we'll upgrade to go 1.17. See: https://github.com/golang/go/pull/42793 +func isPrivate(ip net.IP) bool { + if ip4 := ip.To4(); ip4 != nil { + // Following RFC 4193, Section 3. Local IPv6 Unicast Addresses which says: + // The Internet Assigned Numbers Authority (IANA) has reserved the + // following three blocks of the IPv4 address space for private internets: + // 10.0.0.0 - 10.255.255.255 (10/8 prefix) + // 172.16.0.0 - 172.31.255.255 (172.16/12 prefix) + // 192.168.0.0 - 192.168.255.255 (192.168/16 prefix) + return ip4[0] == 10 || + (ip4[0] == 172 && ip4[1]&0xf0 == 16) || + (ip4[0] == 192 && ip4[1] == 168) + } + // Following RFC 4193, Section 3. Private Address Space which says: + // The Internet Assigned Numbers Authority (IANA) has reserved the + // following block of the IPv6 address space for local internets: + // FC00:: - FDFF:FFFF:FFFF:FFFF:FFFF:FFFF:FFFF:FFFF (FC00::/7 prefix) + return len(ip) == net.IPv6len && ip[0]&0xfe == 0xfc +} diff --git a/pkg/util/net/firewall_dialer_test.go b/pkg/util/net/firewall_dialer_test.go new file mode 100644 index 00000000000..bfdce190ab2 --- /dev/null +++ b/pkg/util/net/firewall_dialer_test.go @@ -0,0 +1,135 @@ +package net + +import ( + "context" + "fmt" + "net" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/cortexproject/cortex/pkg/util/flagext" +) + +func TestFirewallDialer(t *testing.T) { + blockedCIDR := flagext.CIDR{} + require.NoError(t, blockedCIDR.Set("172.217.168.64/28")) + + type testCase struct { + address string + expectBlocked bool + } + + tests := map[string]struct { + cfg FirewallDialerConfig + cases []testCase + }{ + "should not block traffic with default config": { + cfg: FirewallDialerConfig{}, + cases: []testCase{ + {"localhost", false}, + {"127.0.0.1", false}, + {"google.com", false}, + {"172.217.168.78", false}, + }, + }, + "should support blocking private addresses": { + cfg: FirewallDialerConfig{ + BlockPrivateAddresses: true, + }, + cases: []testCase{ + {"localhost", true}, + {"127.0.0.1", true}, + {"192.168.0.1", true}, + {"10.0.0.1", true}, + {"google.com", false}, + {"172.217.168.78", false}, + {"fdf8:f53b:82e4::53", true}, // Local + {"fe80::200:5aee:feaa:20a2", true}, // Link-local + {"2001:4860:4860::8844", false}, // Google DNS + {"::ffff:172.217.168.78", false}, // IPv6 mapped v4 non-private + {"::ffff:192.168.0.1", true}, // IPv6 mapped v4 private + }, + }, + "should support blocking custom CIDRs": { + cfg: FirewallDialerConfig{ + BlockCIDRNetworks: []flagext.CIDR{blockedCIDR}, + }, + cases: []testCase{ + {"localhost", false}, + {"127.0.0.1", false}, + {"192.168.0.1", false}, + {"10.0.0.1", false}, + {"172.217.168.78", true}, + {"fdf8:f53b:82e4::53", false}, // Local + {"fe80::200:5aee:feaa:20a2", false}, // Link-local + {"2001:4860:4860::8844", false}, // Google DNS + {"::ffff:10.0.0.1", false}, // IPv6 mapped v4 non-blocked + {"::ffff:172.217.168.78", true}, // IPv6 mapped v4 blocked + }, + }, + } + + for testName, testData := range tests { + t.Run(testName, func(t *testing.T) { + d := NewFirewallDialer(testData.cfg) + + for _, tc := range testData.cases { + t.Run(fmt.Sprintf("address: %s", tc.address), func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + conn, err := d.DialContext(ctx, "tcp", fmt.Sprintf("[%s]:80", tc.address)) + if conn != nil { + require.NoError(t, conn.Close()) + } + + if tc.expectBlocked { + assert.Error(t, err, errBlockedAddress.Error()) + assert.Contains(t, err.Error(), errBlockedAddress.Error()) + } else { + // We're fine either if succeeded or triggered a different error (eg. connection refused). + assert.True(t, err == nil || !strings.Contains(err.Error(), errBlockedAddress.Error())) + } + }) + } + }) + } +} + +func TestIsPrivate(t *testing.T) { + tests := []struct { + ip net.IP + expected bool + }{ + {nil, false}, + {net.IPv4(1, 1, 1, 1), false}, + {net.IPv4(9, 255, 255, 255), false}, + {net.IPv4(10, 0, 0, 0), true}, + {net.IPv4(10, 255, 255, 255), true}, + {net.IPv4(11, 0, 0, 0), false}, + {net.IPv4(172, 15, 255, 255), false}, + {net.IPv4(172, 16, 0, 0), true}, + {net.IPv4(172, 16, 255, 255), true}, + {net.IPv4(172, 23, 18, 255), true}, + {net.IPv4(172, 31, 255, 255), true}, + {net.IPv4(172, 31, 0, 0), true}, + {net.IPv4(172, 32, 0, 0), false}, + {net.IPv4(192, 167, 255, 255), false}, + {net.IPv4(192, 168, 0, 0), true}, + {net.IPv4(192, 168, 255, 255), true}, + {net.IPv4(192, 169, 0, 0), false}, + {net.IP{0xfc, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, true}, + {net.IP{0xfc, 0xff, 0x12, 0, 0, 0, 0, 0x44, 0, 0, 0, 0, 0, 0, 0, 0}, true}, + {net.IP{0xfb, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}, false}, + {net.IP{0xfd, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}, true}, + {net.IP{0xfe, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, false}, + } + + for _, test := range tests { + assert.Equalf(t, test.expected, isPrivate(test.ip), "ip: %s", test.ip.String()) + } +} diff --git a/tools/doc-generator/parser.go b/tools/doc-generator/parser.go index 0d1ef2d442a..07e1bdcfff9 100644 --- a/tools/doc-generator/parser.go +++ b/tools/doc-generator/parser.go @@ -253,6 +253,8 @@ func getFieldType(t reflect.Type) (string, error) { return "string", nil case "flagext.StringSliceCSV": return "string", nil + case "flagext.CIDRSliceCSV": + return "string", nil case "[]*relabel.Config": return "relabel_config...", nil }