Consul Peering Imported Nodes/Services Leak
Description
HashiCorp Consul and Consul Enterprise 1.13.0 up to 1.13.3 do not filter cluster filtering's imported nodes and services for HTTP or RPC endpoints used by the UI. Fixed in 1.14.0.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
HashiCorp Consul 1.13.0-1.13.3 leaks imported cluster peers' nodes and services via UI endpoints, fixed in 1.14.0.
Vulnerability
Analysis
HashiCorp Consul and Consul Enterprise versions 1.13.0 through 1.13.3 fail to properly filter ACLs for imported nodes and services when handling HTTP and RPC endpoints used by the UI. Specifically, the /v1/internal/ui/nodes and /v1/internal/ui/services endpoints do not apply cluster peering ACL filters, exposing information from peer clusters [1][2].
Attack
Vector
An attacker does not require an ACL token to exploit this vulnerability. The cluster peering feature, introduced as a beta in Consul 1.13.0, allows nodes and services from peered clusters to be viewed without proper authorization. By accessing the UI or directly calling the affected endpoints, an attacker can enumerate imported resources [3].
Impact
Successful exploitation leads to unauthorized disclosure of node and service information from peered clusters. This can include service names, node identities, and health check details, potentially aiding further attacks or exposing sensitive infrastructure [2][3].
Mitigation
The vulnerability is fixed in Consul 1.14.0. Users are advised to upgrade immediately, especially those using the cluster peering feature. No workarounds are available [1][2][3].
AI Insight generated on May 21, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
github.com/hashicorp/consulGo | >= 1.13.0, < 1.14.0 | 1.14.0 |
Affected products
10- osv-coords8 versionspkg:apk/chainguard/consul-1.15pkg:apk/chainguard/consul-1.15-oci-entrypointpkg:apk/chainguard/consul-1.15-oci-entrypoint-compatpkg:apk/wolfi/consul-1.15pkg:apk/wolfi/consul-1.15-oci-entrypointpkg:apk/wolfi/consul-1.15-oci-entrypoint-compatpkg:bitnami/consulpkg:golang/github.com/hashicorp/consul
< 1.15.5-r0+ 7 more
- (no CPE)range: < 1.15.5-r0
- (no CPE)range: < 1.15.5-r0
- (no CPE)range: < 1.15.5-r0
- (no CPE)range: < 1.15.5-r0
- (no CPE)range: < 1.15.5-r0
- (no CPE)range: < 1.15.5-r0
- (no CPE)range: >= 1.13.0, < 1.13.4
- (no CPE)range: >= 1.13.0, < 1.14.0
- HashiCorp/Consulv5Range: 1.13.0
- HashiCorp/Consul Enterprisev5Range: 1.13.0
Patches
1706866fa0016Ensure that NodeDump imported nodes are filtered (#15356)
4 files changed · +241 −45
agent/structs/aclfilter/filter.go+6 −1 modified@@ -61,7 +61,12 @@ func (f *Filter) Filter(subject any) { v.QueryMeta.ResultsFilteredByACLs = f.filterIntentions(&v.Intentions) case *structs.IndexedNodeDump: - v.QueryMeta.ResultsFilteredByACLs = f.filterNodeDump(&v.Dump) + if f.filterNodeDump(&v.Dump) { + v.QueryMeta.ResultsFilteredByACLs = true + } + if f.filterNodeDump(&v.ImportedDump) { + v.QueryMeta.ResultsFilteredByACLs = true + } case *structs.IndexedServiceDump: v.QueryMeta.ResultsFilteredByACLs = f.filterServiceDump(&v.Dump)
agent/structs/aclfilter/filter_test.go+229 −43 modified@@ -1444,78 +1444,264 @@ func TestACL_filterNodeDump(t *testing.T) { }, }, }, + ImportedDump: structs.NodeDump{ + { + // The node and service names are intentionally the same to ensure that + // local permissions for the same names do not allow reading imports. + Node: "node1", + PeerName: "cluster-02", + Services: []*structs.NodeService{ + { + ID: "foo", + Service: "foo", + PeerName: "cluster-02", + }, + }, + Checks: []*structs.HealthCheck{ + { + Node: "node1", + CheckID: "check1", + ServiceName: "foo", + PeerName: "cluster-02", + }, + }, + }, + }, } } + type testCase struct { + authzFn func() acl.Authorizer + expect *structs.IndexedNodeDump + } - t.Run("allowed", func(t *testing.T) { + run := func(t *testing.T, tc testCase) { + authz := tc.authzFn() - policy, err := acl.NewPolicyFromSource(` + list := makeList() + New(authz, logger).Filter(list) + + require.Equal(t, tc.expect, list) + } + + tt := map[string]testCase{ + "denied": { + authzFn: func() acl.Authorizer { + return acl.DenyAll() + }, + expect: &structs.IndexedNodeDump{ + Dump: structs.NodeDump{}, + ImportedDump: structs.NodeDump{}, + QueryMeta: structs.QueryMeta{ResultsFilteredByACLs: true}, + }, + }, + "can read local service but not the node": { + authzFn: func() acl.Authorizer { + policy, err := acl.NewPolicyFromSource(` service "foo" { policy = "read" } + `, acl.SyntaxLegacy, nil, nil) + require.NoError(t, err) + + authz, err := acl.NewPolicyAuthorizerWithDefaults(acl.DenyAll(), []*acl.Policy{policy}, nil) + require.NoError(t, err) + + return authz + }, + expect: &structs.IndexedNodeDump{ + Dump: structs.NodeDump{}, + ImportedDump: structs.NodeDump{}, + QueryMeta: structs.QueryMeta{ResultsFilteredByACLs: true}, + }, + }, + "can read the local node but not the service": { + authzFn: func() acl.Authorizer { + policy, err := acl.NewPolicyFromSource(` node "node1" { policy = "read" } `, acl.SyntaxLegacy, nil, nil) - require.NoError(t, err) - - authz, err := acl.NewPolicyAuthorizerWithDefaults(acl.DenyAll(), []*acl.Policy{policy}, nil) - require.NoError(t, err) - - list := makeList() - New(authz, logger).Filter(list) - - require.Len(t, list.Dump, 1) - require.False(t, list.QueryMeta.ResultsFilteredByACLs, "ResultsFilteredByACLs should be false") - }) + require.NoError(t, err) - t.Run("allowed to read the service, but not the node", func(t *testing.T) { + authz, err := acl.NewPolicyAuthorizerWithDefaults(acl.DenyAll(), []*acl.Policy{policy}, nil) + require.NoError(t, err) - policy, err := acl.NewPolicyFromSource(` + return authz + }, + expect: &structs.IndexedNodeDump{ + Dump: structs.NodeDump{ + { + Node: "node1", + Services: []*structs.NodeService{}, + Checks: structs.HealthChecks{}, + }, + }, + ImportedDump: structs.NodeDump{}, + QueryMeta: structs.QueryMeta{ResultsFilteredByACLs: true}, + }, + }, + "can read local data": { + authzFn: func() acl.Authorizer { + policy, err := acl.NewPolicyFromSource(` service "foo" { policy = "read" } + node "node1" { + policy = "read" + } `, acl.SyntaxLegacy, nil, nil) - require.NoError(t, err) - - authz, err := acl.NewPolicyAuthorizerWithDefaults(acl.DenyAll(), []*acl.Policy{policy}, nil) - require.NoError(t, err) + require.NoError(t, err) - list := makeList() - New(authz, logger).Filter(list) + authz, err := acl.NewPolicyAuthorizerWithDefaults(acl.DenyAll(), []*acl.Policy{policy}, nil) + require.NoError(t, err) - require.Empty(t, list.Dump) - require.True(t, list.QueryMeta.ResultsFilteredByACLs, "ResultsFilteredByACLs should be true") - }) + return authz + }, + expect: &structs.IndexedNodeDump{ + Dump: structs.NodeDump{ + { + Node: "node1", + Services: []*structs.NodeService{ + { + ID: "foo", + Service: "foo", + }, + }, + Checks: []*structs.HealthCheck{ + { + Node: "node1", + CheckID: "check1", + ServiceName: "foo", + }, + }, + }, + }, + ImportedDump: structs.NodeDump{}, + QueryMeta: structs.QueryMeta{ResultsFilteredByACLs: true}, + }, + }, + "can read imported service but not the node": { + authzFn: func() acl.Authorizer { + // Wildcard service read also grants read to imported services. + policy, err := acl.NewPolicyFromSource(` + service "" { + policy = "read" + } + `, acl.SyntaxLegacy, nil, nil) + require.NoError(t, err) - t.Run("allowed to read the node, but not the service", func(t *testing.T) { + authz, err := acl.NewPolicyAuthorizerWithDefaults(acl.DenyAll(), []*acl.Policy{policy}, nil) + require.NoError(t, err) - policy, err := acl.NewPolicyFromSource(` - node "node1" { + return authz + }, + expect: &structs.IndexedNodeDump{ + Dump: structs.NodeDump{}, + ImportedDump: structs.NodeDump{}, + QueryMeta: structs.QueryMeta{ResultsFilteredByACLs: true}, + }, + }, + "can read the imported node but not the service": { + authzFn: func() acl.Authorizer { + // Wildcard node read also grants read to imported nodes. + policy, err := acl.NewPolicyFromSource(` + node "" { policy = "read" } `, acl.SyntaxLegacy, nil, nil) - require.NoError(t, err) - - authz, err := acl.NewPolicyAuthorizerWithDefaults(acl.DenyAll(), []*acl.Policy{policy}, nil) - require.NoError(t, err) + require.NoError(t, err) - list := makeList() - New(authz, logger).Filter(list) + authz, err := acl.NewPolicyAuthorizerWithDefaults(acl.DenyAll(), []*acl.Policy{policy}, nil) + require.NoError(t, err) - require.Len(t, list.Dump, 1) - require.Empty(t, list.Dump[0].Services) - require.True(t, list.QueryMeta.ResultsFilteredByACLs, "ResultsFilteredByACLs should be true") - }) + return authz + }, + expect: &structs.IndexedNodeDump{ + Dump: structs.NodeDump{ + { + Node: "node1", + Services: []*structs.NodeService{}, + Checks: structs.HealthChecks{}, + }, + }, + ImportedDump: structs.NodeDump{ + { + Node: "node1", + PeerName: "cluster-02", + Services: []*structs.NodeService{}, + Checks: structs.HealthChecks{}, + }, + }, + QueryMeta: structs.QueryMeta{ResultsFilteredByACLs: true}, + }, + }, + "can read all data": { + authzFn: func() acl.Authorizer { + policy, err := acl.NewPolicyFromSource(` + service "" { + policy = "read" + } + node "" { + policy = "read" + } + `, acl.SyntaxLegacy, nil, nil) + require.NoError(t, err) - t.Run("denied", func(t *testing.T) { + authz, err := acl.NewPolicyAuthorizerWithDefaults(acl.DenyAll(), []*acl.Policy{policy}, nil) + require.NoError(t, err) - list := makeList() - New(acl.DenyAll(), logger).Filter(list) + return authz + }, + expect: &structs.IndexedNodeDump{ + Dump: structs.NodeDump{ + { + Node: "node1", + Services: []*structs.NodeService{ + { + ID: "foo", + Service: "foo", + }, + }, + Checks: []*structs.HealthCheck{ + { + Node: "node1", + CheckID: "check1", + ServiceName: "foo", + }, + }, + }, + }, + ImportedDump: structs.NodeDump{ + { + Node: "node1", + PeerName: "cluster-02", + Services: []*structs.NodeService{ + { + ID: "foo", + Service: "foo", + PeerName: "cluster-02", + }, + }, + Checks: []*structs.HealthCheck{ + { + Node: "node1", + CheckID: "check1", + ServiceName: "foo", + PeerName: "cluster-02", + }, + }, + }, + }, + QueryMeta: structs.QueryMeta{ResultsFilteredByACLs: false}, + }, + }, + } - require.Empty(t, list.Dump) - require.True(t, list.QueryMeta.ResultsFilteredByACLs, "ResultsFilteredByACLs should be true") - }) + for name, tc := range tt { + t.Run(name, func(t *testing.T) { + run(t, tc) + }) + } } func TestACL_filterNodes(t *testing.T) {
agent/structs/structs_oss.go+3 −1 modified@@ -67,7 +67,9 @@ func (n *Node) OverridePartition(_ string) { func (_ *Coordinate) FillAuthzContext(_ *acl.AuthorizerContext) {} -func (_ *NodeInfo) FillAuthzContext(_ *acl.AuthorizerContext) {} +func (n *NodeInfo) FillAuthzContext(ctx *acl.AuthorizerContext) { + ctx.Peer = n.PeerName +} // FillAuthzContext stub func (_ *DirEntry) FillAuthzContext(_ *acl.AuthorizerContext) {}
.changelog/15356.txt+3 −0 added@@ -0,0 +1,3 @@ +```release-note:security +Ensure that data imported from peers is filtered by ACLs at the UI Nodes/Services endpoints [CVE-2022-3920](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2022-3920) +``` \ No newline at end of file
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4- github.com/advisories/GHSA-gw2g-hhc9-wgjhghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2022-3920ghsaADVISORY
- discuss.hashicorp.com/t/hcsec-2022-28-consul-cluster-peering-leaks-imported-nodes-services-information/46946ghsaWEB
- github.com/hashicorp/consul/commit/706866fa0016b0aa302679f9c648859050d19b2eghsaWEB
News mentions
0No linked articles in our index yet.