🐛 fix(jvm): 收紧 JMX domain allowlist 校验

在 helper runtime 中对直接 ObjectName、资源浏览、变更预览和监控路径统一执行 domain allowlist,阻断默认域别名和空白后缀绕过。
This commit is contained in:
Syngnat
2026-04-28 09:42:29 +08:00
parent ffc4f2c2d9
commit 58ee269855
6 changed files with 401 additions and 10 deletions

View File

@@ -287,6 +287,10 @@ func TestJMXProviderRealJMXRoundTrip(t *testing.T) {
}
provider := NewJMXProvider()
monitoringProvider, ok := provider.(MonitoringCapableProvider)
if !ok {
t.Fatal("expected JMX provider to implement monitoring snapshots")
}
fixture := startJMXFixture(t)
readOnly := false
cfg := connection.ConnectionConfig{
@@ -335,14 +339,310 @@ func TestJMXProviderRealJMXRoundTrip(t *testing.T) {
}
mbean := mbeans[0]
blockedDomainPath := buildJMXResourcePath(jmxResourceTarget{
Kind: jmxResourceKindDomain,
Domain: "java.lang",
})
_, err = provider.ListResources(context.Background(), cfg, blockedDomainPath)
if err == nil {
t.Fatal("expected list on blocked domain to fail")
}
if got := err.Error(); !containsAll(got, "domain", "java.lang") {
t.Fatalf("expected blocked domain list context, got %v", err)
}
_, err = provider.GetValue(context.Background(), cfg, blockedDomainPath)
if err == nil {
t.Fatal("expected get on blocked domain to fail")
}
if got := err.Error(); !containsAll(got, "domain", "java.lang") {
t.Fatalf("expected blocked domain get context, got %v", err)
}
blockedMBeanPath := buildJMXResourcePath(jmxResourceTarget{
Kind: jmxResourceKindMBean,
ObjectName: "java.lang:type=Memory",
})
_, err = provider.ListResources(context.Background(), cfg, blockedMBeanPath)
if err == nil {
t.Fatal("expected list on blocked domain mbean to fail")
}
if got := err.Error(); !containsAll(got, "domain", "java.lang") {
t.Fatalf("expected blocked domain mbean list context, got %v", err)
}
_, err = provider.GetValue(context.Background(), cfg, blockedMBeanPath)
if err == nil {
t.Fatal("expected direct mbean get on blocked domain to fail")
}
if got := err.Error(); !containsAll(got, "domain", "java.lang") {
t.Fatalf("expected blocked domain mbean get context, got %v", err)
}
blockedAttributePath := buildJMXResourcePath(jmxResourceTarget{
Kind: jmxResourceKindAttribute,
ObjectName: "java.lang:type=Memory",
Attribute: "HeapMemoryUsage",
})
_, err = provider.GetValue(context.Background(), cfg, blockedAttributePath)
if err == nil {
t.Fatal("expected direct attribute get on blocked domain to fail")
}
if got := err.Error(); !containsAll(got, "domain", "java.lang") {
t.Fatalf("expected blocked domain attribute get context, got %v", err)
}
blockedOperationPath := buildJMXResourcePath(jmxResourceTarget{
Kind: jmxResourceKindOperation,
ObjectName: "java.lang:type=Memory",
Operation: "gc",
})
_, err = provider.GetValue(context.Background(), cfg, blockedOperationPath)
if err == nil {
t.Fatal("expected direct operation get on blocked domain to fail")
}
if got := err.Error(); !containsAll(got, "domain", "java.lang") {
t.Fatalf("expected blocked domain operation get context, got %v", err)
}
_, err = provider.PreviewChange(context.Background(), cfg, ChangeRequest{
ProviderMode: ModeJMX,
ResourceID: blockedOperationPath,
Action: "invoke",
Reason: "尝试跨域操作预览",
})
if err == nil {
t.Fatal("expected preview on blocked domain operation to fail")
}
if got := err.Error(); !containsAll(got, "domain", "java.lang") {
t.Fatalf("expected blocked domain operation preview context, got %v", err)
}
_, err = provider.ApplyChange(context.Background(), cfg, ChangeRequest{
ProviderMode: ModeJMX,
ResourceID: blockedOperationPath,
Action: "invoke",
Reason: "尝试跨域操作调用",
})
if err == nil {
t.Fatal("expected apply on blocked domain operation to fail")
}
if got := err.Error(); !containsAll(got, "domain", "java.lang") {
t.Fatalf("expected blocked domain operation apply context, got %v", err)
}
_, err = provider.PreviewChange(context.Background(), cfg, ChangeRequest{
ProviderMode: ModeJMX,
ResourceID: blockedAttributePath,
Action: "update",
Reason: "尝试跨域属性预览",
Payload: map[string]any{
"value": "blocked",
},
})
if err == nil {
t.Fatal("expected preview on blocked domain attribute to fail")
}
if got := err.Error(); !containsAll(got, "domain", "java.lang") {
t.Fatalf("expected blocked domain attribute preview context, got %v", err)
}
_, err = provider.ApplyChange(context.Background(), cfg, ChangeRequest{
ProviderMode: ModeJMX,
ResourceID: blockedAttributePath,
Action: "update",
Reason: "尝试跨域属性修改",
Payload: map[string]any{
"value": "blocked",
},
})
if err == nil {
t.Fatal("expected apply on blocked domain attribute to fail")
}
if got := err.Error(); !containsAll(got, "domain", "java.lang") {
t.Fatalf("expected blocked domain attribute apply context, got %v", err)
}
defaultDomainMBeanPath := buildJMXResourcePath(jmxResourceTarget{
Kind: jmxResourceKindMBean,
ObjectName: ":type=CacheSettings,name=DefaultDomainCache",
})
_, err = provider.ListResources(context.Background(), cfg, defaultDomainMBeanPath)
if err == nil {
t.Fatal("expected list on default domain alias mbean to fail")
}
if got := err.Error(); !containsAll(got, "domain") {
t.Fatalf("expected default domain alias mbean list context, got %v", err)
}
defaultDomainAttributePath := buildJMXResourcePath(jmxResourceTarget{
Kind: jmxResourceKindAttribute,
ObjectName: ":type=CacheSettings,name=DefaultDomainCache",
Attribute: "Mode",
})
_, err = provider.GetValue(context.Background(), cfg, defaultDomainAttributePath)
if err == nil {
t.Fatal("expected get on default domain alias attribute to fail")
}
if got := err.Error(); !containsAll(got, "domain") {
t.Fatalf("expected default domain alias attribute get context, got %v", err)
}
defaultDomainOperationPath := buildJMXResourcePath(jmxResourceTarget{
Kind: jmxResourceKindOperation,
ObjectName: ":type=CacheSettings,name=DefaultDomainCache",
Operation: "resize",
Signature: []string{"int", "boolean"},
})
_, err = provider.PreviewChange(context.Background(), cfg, ChangeRequest{
ProviderMode: ModeJMX,
ResourceID: defaultDomainOperationPath,
Action: "invoke",
Reason: "尝试默认域别名操作预览",
Payload: map[string]any{
"args": []any{3, true},
},
})
if err == nil {
t.Fatal("expected preview on default domain alias operation to fail")
}
if got := err.Error(); !containsAll(got, "domain") {
t.Fatalf("expected default domain alias operation preview context, got %v", err)
}
_, err = provider.ApplyChange(context.Background(), cfg, ChangeRequest{
ProviderMode: ModeJMX,
ResourceID: defaultDomainOperationPath,
Action: "invoke",
Reason: "尝试默认域别名操作调用",
Payload: map[string]any{
"args": []any{3, true},
},
})
if err == nil {
t.Fatal("expected apply on default domain alias operation to fail")
}
if got := err.Error(); !containsAll(got, "domain") {
t.Fatalf("expected default domain alias operation apply context, got %v", err)
}
whitespaceDomainMBeanPath := buildJMXResourcePath(jmxResourceTarget{
Kind: jmxResourceKindMBean,
ObjectName: "com.gonavi.fixture :type=CacheSettings,name=WhitespaceDomainCache",
})
_, err = provider.ListResources(context.Background(), cfg, whitespaceDomainMBeanPath)
if err == nil {
t.Fatal("expected list on whitespace-suffixed domain mbean to fail")
}
if got := err.Error(); !containsAll(got, "domain", "com.gonavi.fixture") {
t.Fatalf("expected whitespace-suffixed domain mbean list context, got %v", err)
}
whitespaceDomainAttributePath := buildJMXResourcePath(jmxResourceTarget{
Kind: jmxResourceKindAttribute,
ObjectName: "com.gonavi.fixture :type=CacheSettings,name=WhitespaceDomainCache",
Attribute: "Mode",
})
_, err = provider.GetValue(context.Background(), cfg, whitespaceDomainAttributePath)
if err == nil {
t.Fatal("expected get on whitespace-suffixed domain attribute to fail")
}
if got := err.Error(); !containsAll(got, "domain", "com.gonavi.fixture") {
t.Fatalf("expected whitespace-suffixed domain attribute get context, got %v", err)
}
whitespaceDomainOperationPath := buildJMXResourcePath(jmxResourceTarget{
Kind: jmxResourceKindOperation,
ObjectName: "com.gonavi.fixture :type=CacheSettings,name=WhitespaceDomainCache",
Operation: "resize",
Signature: []string{"int", "boolean"},
})
_, err = provider.PreviewChange(context.Background(), cfg, ChangeRequest{
ProviderMode: ModeJMX,
ResourceID: whitespaceDomainOperationPath,
Action: "invoke",
Reason: "尝试空白后缀域操作预览",
Payload: map[string]any{
"args": []any{4, true},
},
})
if err == nil {
t.Fatal("expected preview on whitespace-suffixed domain operation to fail")
}
if got := err.Error(); !containsAll(got, "domain", "com.gonavi.fixture") {
t.Fatalf("expected whitespace-suffixed domain operation preview context, got %v", err)
}
_, err = provider.ApplyChange(context.Background(), cfg, ChangeRequest{
ProviderMode: ModeJMX,
ResourceID: whitespaceDomainOperationPath,
Action: "invoke",
Reason: "尝试空白后缀域操作调用",
Payload: map[string]any{
"args": []any{4, true},
},
})
if err == nil {
t.Fatal("expected apply on whitespace-suffixed domain operation to fail")
}
if got := err.Error(); !containsAll(got, "domain", "com.gonavi.fixture") {
t.Fatalf("expected whitespace-suffixed domain operation apply context, got %v", err)
}
_, err = monitoringProvider.GetMonitoringSnapshot(context.Background(), cfg, nil)
if err == nil {
t.Fatal("expected monitor on blocked domain allowlist to fail")
}
if got := err.Error(); !containsAll(got, "domain", "java.lang") {
t.Fatalf("expected blocked domain monitor context, got %v", err)
}
monitoringCfg := cfg
monitoringCfg.JVM.JMX.DomainAllowlist = []string{"com.gonavi.fixture", "java.lang"}
monitoringSnapshot, err := monitoringProvider.GetMonitoringSnapshot(context.Background(), monitoringCfg, nil)
if err != nil {
t.Fatalf("expected monitor with java.lang allowlist to succeed: %v", err)
}
if monitoringSnapshot.Point.Timestamp <= 0 {
t.Fatalf("unexpected monitor snapshot point: %#v", monitoringSnapshot.Point)
}
children, err := provider.ListResources(context.Background(), cfg, mbean.Path)
if err != nil {
t.Fatalf("ListResources(mbean) returned error: %v", err)
}
modeAttr := findResourceByName(t, children, "Mode")
passwordAttr := findResourceByName(t, children, "Password")
apiKeyAttr := findResourceByName(t, children, "ApiKey")
lastInvocationAttr := findResourceByName(t, children, "LastInvocation")
resizeOp := findResourceByName(t, children, "resize(int,boolean)")
passwordSnapshot, err := provider.GetValue(context.Background(), cfg, passwordAttr.Path)
if err != nil {
t.Fatalf("GetValue(password) returned error: %v", err)
}
if !passwordSnapshot.Sensitive {
t.Fatalf("expected password snapshot to be sensitive: %#v", passwordSnapshot)
}
for _, action := range passwordSnapshot.SupportedActions {
if payloadValue, ok := action.PayloadExample["value"]; ok && payloadValue == "secret-token" {
t.Fatalf("sensitive payload example leaked raw password: %#v", action.PayloadExample)
}
}
apiKeySnapshot, err := provider.GetValue(context.Background(), cfg, apiKeyAttr.Path)
if err != nil {
t.Fatalf("GetValue(api key) returned error: %v", err)
}
if !apiKeySnapshot.Sensitive {
t.Fatalf("expected api key snapshot to be sensitive: %#v", apiKeySnapshot)
}
for _, action := range apiKeySnapshot.SupportedActions {
if payloadValue, ok := action.PayloadExample["value"]; ok && payloadValue == "api-key-secret" {
t.Fatalf("sensitive payload example leaked raw api key: %#v", action.PayloadExample)
}
}
modeBefore, err := provider.GetValue(context.Background(), cfg, modeAttr.Path)
if err != nil {
t.Fatalf("GetValue(mode before) returned error: %v", err)

View File

@@ -2,6 +2,8 @@ package com.gonavi.fixture;
public final class CacheSettings implements CacheSettingsMBean {
private volatile String mode = "warm";
private volatile String password = "secret-token";
private volatile String apiKey = "api-key-secret";
private final int hitCount = 7;
private volatile String lastInvocation = "none";
@@ -15,6 +17,26 @@ public final class CacheSettings implements CacheSettingsMBean {
this.mode = mode;
}
@Override
public String getPassword() {
return password;
}
@Override
public void setPassword(String password) {
this.password = password;
}
@Override
public String getApiKey() {
return apiKey;
}
@Override
public void setApiKey(String apiKey) {
this.apiKey = apiKey;
}
@Override
public int getHitCount() {
return hitCount;

View File

@@ -4,6 +4,12 @@ public interface CacheSettingsMBean {
String getMode();
void setMode(String mode);
String getPassword();
void setPassword(String password);
String getApiKey();
void setApiKey(String apiKey);
int getHitCount();
String getLastInvocation();

View File

@@ -15,6 +15,14 @@ public final class JMXTestServer {
if (!server.isRegistered(objectName)) {
server.registerMBean(new CacheSettings(), objectName);
}
ObjectName defaultDomainObjectName = new ObjectName(":type=CacheSettings,name=DefaultDomainCache");
if (!server.isRegistered(defaultDomainObjectName)) {
server.registerMBean(new CacheSettings(), defaultDomainObjectName);
}
ObjectName whitespaceDomainObjectName = new ObjectName("com.gonavi.fixture :type=CacheSettings,name=WhitespaceDomainCache");
if (!server.isRegistered(whitespaceDomainObjectName)) {
server.registerMBean(new CacheSettings(), whitespaceDomainObjectName);
}
System.out.println("READY");
System.out.flush();

View File

@@ -52,13 +52,13 @@ final class JmxRuntime {
case "list":
return listResources(server, connection, target);
case "get":
return singleton("snapshot", getValue(server, target));
return singleton("snapshot", getValue(server, connection, target));
case "monitor":
return singleton("monitoringSnapshot", getMonitoringSnapshot(server));
return singleton("monitoringSnapshot", getMonitoringSnapshot(server, connection));
case "preview":
return singleton("preview", previewChange(server, target, change));
return singleton("preview", previewChange(server, connection, target, change));
case "apply":
return singleton("applyResult", applyChange(server, target, change));
return singleton("applyResult", applyChange(server, connection, target, change));
default:
throw new IllegalArgumentException("unsupported helper command: " + command);
}
@@ -100,6 +100,7 @@ final class JmxRuntime {
}
if (target.isDomain()) {
requireDomainAllowed(connection, target.domain);
Set<ObjectName> names = server.queryNames(new ObjectName(target.domain + ":*"), null);
List<ObjectName> sortedNames = new ArrayList<>(names);
Collections.sort(sortedNames, Comparator.comparing(ObjectName::getCanonicalName));
@@ -123,6 +124,7 @@ final class JmxRuntime {
if (target.isMBean()) {
ObjectName objectName = new ObjectName(target.objectName);
requireDomainAllowed(connection, objectName);
MBeanInfo info = server.getMBeanInfo(objectName);
MBeanAttributeInfo[] attributes = info.getAttributes();
@@ -170,10 +172,15 @@ final class JmxRuntime {
throw new IllegalArgumentException("target kind " + target.kind + " does not support list");
}
private static Map<String, Object> getValue(MBeanServerConnection server, TargetSpec target) throws Exception {
private static Map<String, Object> getValue(
MBeanServerConnection server,
ConnectionSpec connection,
TargetSpec target
) throws Exception {
requireTarget(target);
if (target.isDomain()) {
requireDomainAllowed(connection, target.domain);
Set<ObjectName> names = server.queryNames(new ObjectName(target.domain + ":*"), null);
Map<String, Object> value = new LinkedHashMap<>();
value.put("domain", target.domain);
@@ -182,6 +189,7 @@ final class JmxRuntime {
}
ObjectName objectName = new ObjectName(target.objectName);
requireDomainAllowed(connection, objectName);
if (target.isMBean()) {
MBeanInfo info = server.getMBeanInfo(objectName);
List<Map<String, Object>> attributes = new ArrayList<>();
@@ -219,7 +227,9 @@ final class JmxRuntime {
throw new IllegalArgumentException("unsupported target kind: " + target.kind);
}
private static Map<String, Object> getMonitoringSnapshot(MBeanServerConnection server) throws Exception {
private static Map<String, Object> getMonitoringSnapshot(MBeanServerConnection server, ConnectionSpec connection) throws Exception {
requireDomainAllowed(connection, "java.lang");
LinkedHashMap<String, Object> result = new LinkedHashMap<>();
LinkedHashMap<String, Object> point = new LinkedHashMap<>();
List<String> availableMetrics = new ArrayList<>();
@@ -423,6 +433,7 @@ final class JmxRuntime {
private static Map<String, Object> previewChange(
MBeanServerConnection server,
ConnectionSpec connection,
TargetSpec target,
Map<String, Object> change
) throws Exception {
@@ -431,6 +442,7 @@ final class JmxRuntime {
if (target.isAttribute()) {
ObjectName objectName = new ObjectName(target.objectName);
requireDomainAllowed(connection, objectName);
MBeanAttributeInfo attributeInfo = requireAttributeInfo(server, objectName, target.attribute);
Map<String, Object> before = attributeSnapshot(objectName, attributeInfo, server.getAttribute(objectName, target.attribute));
if (!attributeInfo.isWritable()) {
@@ -457,6 +469,7 @@ final class JmxRuntime {
if (target.isOperation()) {
ObjectName objectName = new ObjectName(target.objectName);
requireDomainAllowed(connection, objectName);
MBeanOperationInfo operationInfo = requireOperationInfo(server, objectName, target.operation, target.signature);
List<Object> args = argumentList(payload);
String[] signature = effectiveSignature(target, payload, operationInfo);
@@ -486,6 +499,7 @@ final class JmxRuntime {
private static Map<String, Object> applyChange(
MBeanServerConnection server,
ConnectionSpec connection,
TargetSpec target,
Map<String, Object> change
) throws Exception {
@@ -494,6 +508,7 @@ final class JmxRuntime {
if (target.isAttribute()) {
ObjectName objectName = new ObjectName(target.objectName);
requireDomainAllowed(connection, objectName);
MBeanAttributeInfo attributeInfo = requireAttributeInfo(server, objectName, target.attribute);
if (!attributeInfo.isWritable()) {
throw new IllegalArgumentException("attribute " + target.attribute + " is not writable");
@@ -512,6 +527,7 @@ final class JmxRuntime {
if (target.isOperation()) {
ObjectName objectName = new ObjectName(target.objectName);
requireDomainAllowed(connection, objectName);
MBeanOperationInfo operationInfo = requireOperationInfo(server, objectName, target.operation, target.signature);
List<Object> args = argumentList(payload);
String[] signature = effectiveSignature(target, payload, operationInfo);
@@ -590,17 +606,18 @@ final class JmxRuntime {
Object value
) {
Object jsonValue = toJsonCompatible(value);
boolean sensitive = isSensitiveName(attributeInfo.getName());
List<Map<String, Object>> supportedActions = attributeInfo.isWritable()
? Collections.singletonList(actionDefinition(
"set",
"设置属性",
"更新 JMX 属性 " + attributeInfo.getName(),
isSensitiveName(attributeInfo.getName()),
sensitive,
Collections.singletonList(payloadField("value", attributeInfo.getType(), true, "目标属性值")),
metadata("value", jsonValue)
sensitive ? Collections.<String, Object>emptyMap() : metadata("value", jsonValue)
))
: Collections.emptyList();
return snapshot("attribute", inferFormat(jsonValue), jsonValue, attributeInfo.getDescription(), isSensitiveName(attributeInfo.getName()), supportedActions, metadata(
return snapshot("attribute", inferFormat(jsonValue), jsonValue, attributeInfo.getDescription(), sensitive, supportedActions, metadata(
"objectName", objectName.toString(),
"attribute", attributeInfo.getName(),
"type", attributeInfo.getType(),
@@ -898,13 +915,47 @@ final class JmxRuntime {
return lowered.contains("password")
|| lowered.contains("secret")
|| lowered.contains("token")
|| lowered.contains("credential");
|| lowered.contains("credential")
|| lowered.contains("apikey")
|| lowered.contains("api_key")
|| lowered.contains("accesskey")
|| lowered.contains("access_key")
|| lowered.contains("privatekey")
|| lowered.contains("private_key")
|| lowered.contains("secretkey")
|| lowered.contains("secret_key")
|| lowered.contains("authkey")
|| lowered.contains("auth_key");
}
private static String domainOf(ObjectName objectName) {
return objectName.getDomain();
}
private static void requireDomainAllowed(ConnectionSpec connection, String domain) {
if (connection == null) {
return;
}
String rawDomain = domain == null ? "" : domain;
String normalizedDomain = rawDomain.trim();
if (normalizedDomain.isEmpty()) {
if (connection.hasDomainAllowlist()) {
throw new IllegalArgumentException("domain is not allowed: <default>");
}
return;
}
if (!rawDomain.equals(normalizedDomain) || !connection.isDomainAllowed(rawDomain)) {
throw new IllegalArgumentException("domain is not allowed: " + normalizedDomain);
}
}
private static void requireDomainAllowed(ConnectionSpec connection, ObjectName objectName) {
if (objectName == null) {
return;
}
requireDomainAllowed(connection, objectName.getDomain());
}
private static void requireTarget(TargetSpec target) {
if (target == null || target.isRoot()) {
throw new IllegalArgumentException("change target is required");
@@ -1263,6 +1314,10 @@ final class JmxRuntime {
return new ConnectionSpec(host, port, username, password, allowlist);
}
private boolean hasDomainAllowlist() {
return !domainAllowlist.isEmpty();
}
private boolean isDomainAllowed(String domain) {
return domainAllowlist.isEmpty() || domainAllowlist.contains(domain);
}