diff --git a/agent/blacklist.go b/agent/blacklist.go new file mode 100644 index 000000000000..5158ce52c981 --- /dev/null +++ b/agent/blacklist.go @@ -0,0 +1,27 @@ +package agent + +import ( + "github.com/armon/go-radix" +) + +// Blacklist implements an HTTP endpoint blacklist based on a list of endpoint +// prefixes which should be blocked. +type Blacklist struct { + tree *radix.Tree +} + +// NewBlacklist returns a blacklist for the given list of prefixes. +func NewBlacklist(prefixes []string) *Blacklist { + tree := radix.New() + for _, prefix := range prefixes { + tree.Insert(prefix, nil) + } + return &Blacklist{tree} +} + +// Block will return true if the given path is included among any of the +// blocked prefixes. +func (b *Blacklist) Block(path string) bool { + _, _, blocked := b.tree.LongestPrefix(path) + return blocked +} diff --git a/agent/blacklist_test.go b/agent/blacklist_test.go new file mode 100644 index 000000000000..e3691fe0a05b --- /dev/null +++ b/agent/blacklist_test.go @@ -0,0 +1,39 @@ +package agent + +import ( + "testing" +) + +func TestBlacklist(t *testing.T) { + t.Parallel() + + complex := []string{ + "/a", + "/b/c", + } + + tests := []struct { + desc string + prefixes []string + path string + block bool + }{ + {"nothing blocked root", nil, "/", false}, + {"nothing blocked path", nil, "/a", false}, + {"exact match 1", complex, "/a", true}, + {"exact match 2", complex, "/b/c", true}, + {"subpath", complex, "/a/b", true}, + {"longer prefix", complex, "/apple", true}, + {"longer subpath", complex, "/b/c/d", true}, + {"partial prefix", complex, "/b/d", false}, + {"no match", complex, "/c", false}, + } + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + blacklist := NewBlacklist(tt.prefixes) + if got, want := blacklist.Block(tt.path), tt.block; got != want { + t.Fatalf("got %v want %v", got, want) + } + }) + } +} diff --git a/agent/config.go b/agent/config.go index 4b494067c631..34506c6712a0 100644 --- a/agent/config.go +++ b/agent/config.go @@ -131,6 +131,10 @@ type DNSConfig struct { // HTTPConfig is used to fine tune the Http sub-system. type HTTPConfig struct { + // BlockEndpoints is a list of endpoint prefixes to block in the + // HTTP API. Any requests to these will get a 403 response. + BlockEndpoints []string `mapstructure:"block_endpoints"` + // ResponseHeaders are used to add HTTP header response fields to the HTTP API responses. ResponseHeaders map[string]string `mapstructure:"response_headers"` } @@ -1996,6 +2000,9 @@ func MergeConfig(a, b *Config) *Config { result.SessionTTLMin = b.SessionTTLMin result.SessionTTLMinRaw = b.SessionTTLMinRaw } + + result.HTTPConfig.BlockEndpoints = append(a.HTTPConfig.BlockEndpoints, + b.HTTPConfig.BlockEndpoints...) if len(b.HTTPConfig.ResponseHeaders) > 0 { if result.HTTPConfig.ResponseHeaders == nil { result.HTTPConfig.ResponseHeaders = make(map[string]string) @@ -2004,6 +2011,7 @@ func MergeConfig(a, b *Config) *Config { result.HTTPConfig.ResponseHeaders[field] = value } } + if len(b.Meta) != 0 { if result.Meta == nil { result.Meta = make(map[string]string) diff --git a/agent/config_test.go b/agent/config_test.go index b0dc90faf7b3..e4f0b16aab72 100644 --- a/agent/config_test.go +++ b/agent/config_test.go @@ -330,6 +330,10 @@ func TestDecodeConfig(t *testing.T) { in: `{"encrypt_verify_outgoing":true}`, c: &Config{EncryptVerifyOutgoing: Bool(true)}, }, + { + in: `{"http_config":{"block_endpoints":["a","b","c","d"]}}`, + c: &Config{HTTPConfig: HTTPConfig{BlockEndpoints: []string{"a", "b", "c", "d"}}}, + }, { in: `{"http_api_response_headers":{"a":"b","c":"d"}}`, c: &Config{HTTPConfig: HTTPConfig{ResponseHeaders: map[string]string{"a": "b", "c": "d"}}}, @@ -1394,6 +1398,10 @@ func TestMergeConfig(t *testing.T) { DisableUpdateCheck: true, DisableAnonymousSignature: true, HTTPConfig: HTTPConfig{ + BlockEndpoints: []string{ + "/v1/agent/self", + "/v1/acl", + }, ResponseHeaders: map[string]string{ "Access-Control-Allow-Origin": "*", }, diff --git a/agent/http.go b/agent/http.go index 5a041dafcd4e..7eb93ba9c77c 100644 --- a/agent/http.go +++ b/agent/http.go @@ -18,13 +18,20 @@ import ( // HTTPServer provides an HTTP api for an agent. type HTTPServer struct { *http.Server - agent *Agent + agent *Agent + blacklist *Blacklist + + // proto is filled by the agent to "http" or "https". proto string } func NewHTTPServer(addr string, a *Agent) *HTTPServer { - s := &HTTPServer{Server: &http.Server{Addr: addr}, agent: a} - s.Server.Handler = s.handler(s.agent.config.EnableDebug) + s := &HTTPServer{ + Server: &http.Server{Addr: addr}, + agent: a, + blacklist: NewBlacklist(a.config.HTTPConfig.BlockEndpoints), + } + s.Server.Handler = s.handler(a.config.EnableDebug) return s } @@ -183,6 +190,14 @@ func (s *HTTPServer) wrap(handler func(resp http.ResponseWriter, req *http.Reque } } + if s.blacklist.Block(req.URL.Path) { + errMsg := "Endpoint is blocked by agent configuration" + s.agent.logger.Printf("[ERR] http: Request %s %v, error: %v from=%s", req.Method, logURL, err, req.RemoteAddr) + resp.WriteHeader(http.StatusForbidden) + fmt.Fprint(resp, errMsg) + return + } + handleErr := func(err error) { s.agent.logger.Printf("[ERR] http: Request %s %v, error: %v from=%s", req.Method, logURL, err, req.RemoteAddr) code := http.StatusInternalServerError // 500 diff --git a/agent/http_test.go b/agent/http_test.go index 01970a348a51..60d7cc1cc13f 100644 --- a/agent/http_test.go +++ b/agent/http_test.go @@ -195,6 +195,42 @@ func TestSetMeta(t *testing.T) { } } +func TestHTTPAPI_BlockEndpoints(t *testing.T) { + t.Parallel() + + cfg := TestConfig() + cfg.HTTPConfig.BlockEndpoints = []string{ + "/v1/agent/self", + } + + a := NewTestAgent(t.Name(), cfg) + defer a.Shutdown() + + handler := func(resp http.ResponseWriter, req *http.Request) (interface{}, error) { + return nil, nil + } + + // Try a blocked endpoint, which should get a 403. + { + req, _ := http.NewRequest("GET", "/v1/agent/self", nil) + resp := httptest.NewRecorder() + a.srv.wrap(handler)(resp, req) + if got, want := resp.Code, http.StatusForbidden; got != want { + t.Fatalf("bad response code got %d want %d", got, want) + } + } + + // Make sure some other endpoint still works. + { + req, _ := http.NewRequest("GET", "/v1/agent/checks", nil) + resp := httptest.NewRecorder() + a.srv.wrap(handler)(resp, req) + if got, want := resp.Code, http.StatusOK; got != want { + t.Fatalf("bad response code got %d want %d", got, want) + } + } +} + func TestHTTPAPI_TranslateAddrHeader(t *testing.T) { t.Parallel() // Header should not be present if address translation is off. diff --git a/website/source/docs/agent/options.html.md b/website/source/docs/agent/options.html.md index 8e5eb7624af7..4baa8ecdeffd 100644 --- a/website/source/docs/agent/options.html.md +++ b/website/source/docs/agent/options.html.md @@ -757,6 +757,17 @@ Consul will not enable TLS for the HTTP API unless the `https` port has been ass

The following sub-keys are available: + * `block_endpoints` + This object is a list of HTTP endpoint prefixes to block on the agent, and defaults to + an empty list, meaning all endpoints are enabled. Any endpoint that has a common prefix + with one of the entries on this list will be blocked and will return a 403 response code + when accessed. For example, to block all of the V1 ACL endpoints, set this to + `["/v1/acl"]`, which will block `/v1/acl/create`, `/v1/acl/update`, and the other ACL + endpoints that begin with `/v1/acl`. Any CLI commands that use disabled endpoints will + no longer function as well. For more general access control, Consul's + [ACL system](/docs/guides/acl.html) should be used, but this option is useful for removing + access to HTTP endpoints completely, or on specific agents. + * `response_headers` This object allows adding headers to the HTTP API responses. For example, the following config can be used to enable