diff --git a/agent/blacklist.go b/agent/blacklist.go
new file mode 100644
index 0000000000..5158ce52c9
--- /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 0000000000..e3691fe0a0
--- /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 4b494067c6..34506c6712 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 b0dc90faf7..e4f0b16aab 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 5a041dafcd..7eb93ba9c7 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 01970a348a..60d7cc1cc1 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 8e5eb7624a..4baa8ecdef 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