diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/config/Config.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/config/Config.java index 4dfdbc3a01..af51b4995a 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/config/Config.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/config/Config.java @@ -24,6 +24,11 @@ import lombok.Getter; public class Config extends YMLFile { + + /** Explicit permission for executable deployment over private-network plaintext HTTP. */ + public boolean getControlAllowInsecureHttpPluginDeployment() { + return getData().getBoolean("Control.AllowInsecureHttpPluginDeployment", false); + } @ConfigDataBoolean(path = "AddCustomCommands") @Getter diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java index cc53800077..554a29cc07 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java @@ -72,6 +72,7 @@ public final class BackendControlConnector implements AutoCloseable { private final ControlInspectionService inspections; private final PluginDeploymentService deployments; private final boolean directLocalDeploymentEndpoint; + private final boolean allowInsecureHttpPluginDeployment; private final UUID sessionId = UUID.randomUUID(); private final Map completed = new LinkedHashMap<>(); private final boolean recovering; @@ -142,8 +143,13 @@ private BackendControlConnector(VotingPluginMain plugin, Path dataDirectory, Set directLocalDeploymentEndpoint = HostedControlManager.isDirectLocalEndpoint( settings.endpoint().toString(), hostedConfiguration); PluginDeploymentService prepared = null; - boolean deploymentEndpointAllowed = PluginDeploymentService.deploymentEndpointAllowed( - settings.endpoint(), directLocalDeploymentEndpoint); + allowInsecureHttpPluginDeployment = plugin.getConfigFile().getControlAllowInsecureHttpPluginDeployment(); + PluginDeploymentService.DeploymentEndpointPolicy deploymentPolicy = PluginDeploymentService.deploymentEndpointPolicy( + settings.endpoint(), directLocalDeploymentEndpoint, allowInsecureHttpPluginDeployment); + boolean deploymentEndpointAllowed = deploymentPolicy.allowed(); + if (!recovering && deploymentPolicy.initializationMessage() != null) { + plugin.getLogger().warning("[Control] " + deploymentPolicy.initializationMessage()); + } if (!recovering && deploymentEndpointAllowed) { try { prepared = PluginDeploymentService.backend(plugin.getServer().getUpdateFolderFile().toPath(), @@ -151,12 +157,6 @@ private BackendControlConnector(VotingPluginMain plugin, Path dataDirectory, Set } catch (Exception failure) { plugin.getLogger().warning("[Control] Plugin deployment staging is unavailable; capability not advertised"); } - } else if (!recovering && !deploymentEndpointAllowed) { - plugin.getLogger().warning("[Control] Plugin deployment staging requires HTTPS or a literal private-network HTTP endpoint"); - } - if (prepared != null && PluginDeploymentService.usesUnencryptedHttp(settings.endpoint())) { - plugin.getLogger().warning("[Control] Verified plugin staging is enabled over unencrypted HTTP. " - + "HTTPS is strongly recommended because node credentials and plugin artifacts cross this connection"); } deployments = prepared; } @@ -488,7 +488,7 @@ private void claimAndDeploy() throws Exception { if (response.status() == 204 || closed) return; JsonObject claimed = requireObject(response, 200); PluginDeploymentService.Task task = deploymentTask(claimed); - PluginDeploymentService.Result result = deployments.deploy(task, settings.endpoint(), directLocalDeploymentEndpoint, + PluginDeploymentService.Result result = deployments.deploy(task, settings.endpoint(), directLocalDeploymentEndpoint, allowInsecureHttpPluginDeployment, settings.nodeId(), sessionId, credential, http, Duration.ofMillis(settings.requestTimeoutMillis()), () -> !closed); if (closed) return; diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/PluginDeploymentService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/PluginDeploymentService.java index 267a92d1e4..2c3cc1171a 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/PluginDeploymentService.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/PluginDeploymentService.java @@ -120,12 +120,17 @@ public void cancel() { public Result deploy(Task task, URI endpoint, boolean directLocalHosted, String nodeId, UUID sessionId, String credential, HttpClient http, Duration timeout, BooleanSupplier active) { + return deploy(task, endpoint, directLocalHosted, false, nodeId, sessionId, credential, http, timeout, active); + } + + public Result deploy(Task task, URI endpoint, boolean directLocalHosted, boolean allowInsecurePrivateHttp, + String nodeId, UUID sessionId, String credential, HttpClient http, Duration timeout, BooleanSupplier active) { if (!staging.compareAndSet(false, true)) return Result.failure("DEPLOYMENT_FAILED", "Another deployment is still staging"); try { validate(task); - if (!deploymentEndpointAllowed(endpoint, directLocalHosted)) { + if (!deploymentEndpointAllowed(endpoint, directLocalHosted, allowInsecurePrivateHttp)) { return Result.failure("INSECURE_ENDPOINT", - "Verified update staging requires HTTPS, a literal private-network HTTP endpoint, or proven same-node localhost hosting"); + "Plugin staging requires HTTPS, local HTTP, or explicit opt-in for literal private-network HTTP"); } if (!active.getAsBoolean()) return Result.failure("CANCELLED", "Deployment was cancelled before download"); if (alreadyStaged(task)) return Result.restartRequired(); @@ -448,22 +453,61 @@ private static void forceDirectory(Path directory) throws IOException { /** * True when the configured Control transport can carry a deployment request. * - *

HTTP remains supported for literal loopback, link-local, and private network - * addresses. The overload also permits {@code localhost} when direct local hosting + *

HTTP remains supported for literal loopback. Non-loopback link-local and private network + * addresses require an explicit insecure-deployment opt-in unless direct same-node hosting is proven. + * The overload also permits {@code localhost} when direct local hosting * is confirmed. Public addresses and other hostnames require HTTPS. Callers warn * operators because HTTPS is strongly recommended whenever traffic leaves the * local process.

*/ public static boolean deploymentEndpointAllowed(URI endpoint) { - return deploymentEndpointAllowed(endpoint, false); + return deploymentEndpointAllowed(endpoint, false, false); } public static boolean deploymentEndpointAllowed(URI endpoint, boolean directLocalHosted) { - if (endpoint == null) return false; - return "https".equalsIgnoreCase(endpoint.getScheme()) - || "http".equalsIgnoreCase(endpoint.getScheme()) - && (isLocalNetworkAddress(endpoint.getHost()) - || directLocalHosted && "localhost".equalsIgnoreCase(endpoint.getHost())); + return deploymentEndpointAllowed(endpoint, directLocalHosted, false); + } + + public static boolean deploymentEndpointAllowed(URI endpoint, boolean directLocalHosted, + boolean allowInsecurePrivateHttp) { + return deploymentEndpointPolicy(endpoint, directLocalHosted, allowInsecurePrivateHttp).allowed(); + } + + /** No DNS-based private-host relaxation; local-host proof never authorizes arbitrary hostnames. */ + public static DeploymentEndpointPolicy deploymentEndpointPolicy(URI endpoint, boolean directLocalHosted, + boolean allowInsecurePrivateHttp) { + if (endpoint == null) return DeploymentEndpointPolicy.UNSUPPORTED; + if ("https".equalsIgnoreCase(endpoint.getScheme())) return DeploymentEndpointPolicy.HTTPS; + if (!usesUnencryptedHttp(endpoint)) return DeploymentEndpointPolicy.UNSUPPORTED; + String host = endpoint.getHost(); + InetAddress localAddress = localNetworkAddress(host); + if (localAddress != null) { + if (localAddress.isLoopbackAddress() || directLocalHosted) return DeploymentEndpointPolicy.LOCAL_HTTP; + return allowInsecurePrivateHttp ? DeploymentEndpointPolicy.PRIVATE_HTTP_OPT_IN + : DeploymentEndpointPolicy.PRIVATE_HTTP_DISABLED; + } + if (directLocalHosted && "localhost".equalsIgnoreCase(host)) return DeploymentEndpointPolicy.LOCAL_HTTP; + return DeploymentEndpointPolicy.UNSUPPORTED; + } + + public enum DeploymentEndpointPolicy { + HTTPS(true), LOCAL_HTTP(true), PRIVATE_HTTP_OPT_IN(true), PRIVATE_HTTP_DISABLED(false), UNSUPPORTED(false); + + private final boolean allowed; + DeploymentEndpointPolicy(boolean allowed) { this.allowed = allowed; } + public boolean allowed() { return allowed; } + public String initializationMessage() { + return switch (this) { + case PRIVATE_HTTP_DISABLED -> "Plugin deployment over private-network HTTP is disabled. " + + "Use HTTPS or explicitly enable Control.AllowInsecureHttpPluginDeployment."; + case PRIVATE_HTTP_OPT_IN -> "WARNING: Executable plugin deployment and node credentials cross an " + + "unauthenticated plaintext HTTP connection. Deployment metadata and artifacts can both be " + + "substituted by a network attacker. HTTPS is strongly recommended."; + case UNSUPPORTED -> "Plugin deployment requires HTTPS, local HTTP, or opted-in literal private-network HTTP; " + + "public HTTP and arbitrary HTTP hostnames are prohibited."; + default -> null; + }; + } } /** @deprecated Retained for credential transport callers; use {@link #deploymentEndpointAllowed(URI, boolean)} only for deployment staging. */ @@ -479,23 +523,23 @@ public static boolean usesUnencryptedHttp(URI endpoint) { return endpoint != null && "http".equalsIgnoreCase(endpoint.getScheme()); } - private static boolean isLocalNetworkAddress(String host) { - if (host == null || host.isBlank()) return false; + private static InetAddress localNetworkAddress(String host) { + if (host == null || host.isBlank()) return null; String literal = host; if (literal.length() >= 2 && literal.charAt(0) == '[' && literal.charAt(literal.length() - 1) == ']') { literal = literal.substring(1, literal.length() - 1); } int zone = literal.indexOf('%'); if (zone >= 0) literal = literal.substring(0, zone); - if (!(literal.indexOf(':') >= 0 || literal.matches("[0-9]{1,3}(\\.[0-9]{1,3}){3}"))) return false; + if (!(literal.indexOf(':') >= 0 || literal.matches("[0-9]{1,3}(\\.[0-9]{1,3}){3}"))) return null; try { InetAddress address = InetAddress.getByName(literal); byte[] bytes = address.getAddress(); boolean uniqueLocalV6 = bytes.length == 16 && (bytes[0] & 0xfe) == 0xfc; return address.isLoopbackAddress() || address.isSiteLocalAddress() - || address.isLinkLocalAddress() || uniqueLocalV6; + || address.isLinkLocalAddress() || uniqueLocalV6 ? address : null; } catch (Exception invalid) { - return false; + return null; } } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxyConfig.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxyConfig.java index f18cc07aae..153a9ef538 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxyConfig.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxyConfig.java @@ -75,6 +75,11 @@ default String getControlEndpoint() { return "http://127.0.0.1:8080"; } + /** Explicit permission for executable deployment over private-network plaintext HTTP. */ + default boolean getControlAllowInsecureHttpPluginDeployment() { + return false; + } + /** Stable enrolled identity; blank reuses ProxyServerName. */ default String getControlNodeId() { return ""; diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/bungee/BungeeConfig.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/bungee/BungeeConfig.java index 438526ea55..cadc73acf5 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/bungee/BungeeConfig.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/bungee/BungeeConfig.java @@ -56,6 +56,11 @@ public String getControlEndpoint() { return getData().getString("Control.Endpoint", "http://127.0.0.1:8080"); } + @Override + public boolean getControlAllowInsecureHttpPluginDeployment() { + return getData().getBoolean("Control.AllowInsecureHttpPluginDeployment", false); + } + @Override public String getControlNodeId() { return getData().getString("Control.NodeId", ""); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ControlConnector.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ControlConnector.java index 8ef39a28d6..0d8b94da07 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ControlConnector.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ControlConnector.java @@ -89,6 +89,7 @@ public final class ControlConnector implements AutoCloseable { private final HttpClient deploymentHttp; private final String deploymentCredential; private final boolean directLocalDeploymentEndpoint; + private final boolean allowInsecureHttpPluginDeployment; private final ExecutorService deploymentExecutor; private final Function> communicationTest; private final Runnable runtimeReplacement; @@ -150,7 +151,7 @@ private ControlConnector(Settings settings, ScheduledExecutorService scheduler, ProxyConfigurationFileService fileConfigurationService) { this(settings, scheduler, transport, snapshotSource, logger, sessionId, jitterSource, configurationService, dataDirectory, route, recovering, recoveryComplete, communicationTest, methodConfigurationService, - runtimeReplacement, fileConfigurationService, null, null, null, false); + runtimeReplacement, fileConfigurationService, null, null, null, false, false); } private ControlConnector(Settings settings, ScheduledExecutorService scheduler, Transport transport, @@ -160,7 +161,8 @@ private ControlConnector(Settings settings, ScheduledExecutorService scheduler, Function> communicationTest, ProxyMethodConfigurationService methodConfigurationService, Runnable runtimeReplacement, ProxyConfigurationFileService fileConfigurationService, PluginDeploymentService deployments, - HttpClient deploymentHttp, String deploymentCredential, boolean directLocalDeploymentEndpoint) { + HttpClient deploymentHttp, String deploymentCredential, boolean directLocalDeploymentEndpoint, + boolean allowInsecureHttpPluginDeployment) { this.settings = Objects.requireNonNull(settings, "settings"); this.scheduler = Objects.requireNonNull(scheduler, "scheduler"); this.transport = Objects.requireNonNull(transport, "transport"); @@ -177,6 +179,7 @@ private ControlConnector(Settings settings, ScheduledExecutorService scheduler, this.deploymentHttp = deploymentHttp; this.deploymentCredential = deploymentCredential; this.directLocalDeploymentEndpoint = directLocalDeploymentEndpoint; + this.allowInsecureHttpPluginDeployment = allowInsecureHttpPluginDeployment; this.deploymentExecutor = deployments == null ? null : Executors.newSingleThreadExecutor(runnable -> { Thread thread = new Thread(runnable, "votingplugin-control-proxy-deployment"); thread.setDaemon(true); @@ -259,16 +262,14 @@ public static ControlConnector create(VotingPluginProxy proxy) throws IOExceptio config.getControlHostedStartupTimeoutSeconds(), config.getControlHostedDownloadTimeoutSeconds()); boolean directLocalDeploymentEndpoint = HostedControlManager.isDirectLocalEndpoint( settings.endpoint().toString(), hosted); - boolean deploymentEndpointAllowed = PluginDeploymentService.deploymentEndpointAllowed( - settings.endpoint(), directLocalDeploymentEndpoint); + boolean allowInsecureHttpPluginDeployment = config.getControlAllowInsecureHttpPluginDeployment(); + PluginDeploymentService.DeploymentEndpointPolicy deploymentPolicy = PluginDeploymentService.deploymentEndpointPolicy( + settings.endpoint(), directLocalDeploymentEndpoint, allowInsecureHttpPluginDeployment); + boolean deploymentEndpointAllowed = deploymentPolicy.allowed(); PluginDeploymentService deployments = deploymentRouteCurrent && deploymentEndpointAllowed ? prepareDeployment(proxy) : null; - if (deploymentRouteCurrent && !deploymentEndpointAllowed) { - proxy.log("[Control] Plugin deployment staging requires HTTPS or a literal private-network HTTP endpoint"); - } - if (deployments != null && PluginDeploymentService.usesUnencryptedHttp(settings.endpoint())) { - proxy.log("[Control] Verified plugin staging is enabled over unencrypted HTTP. " - + "HTTPS is strongly recommended because node credentials and plugin artifacts cross this connection"); + if (deploymentRouteCurrent && deploymentPolicy.initializationMessage() != null) { + proxy.log("[Control] " + deploymentPolicy.initializationMessage()); } HttpClient deploymentHttp = deployments == null ? null : HttpClient.newBuilder() .connectTimeout(Duration.ofMillis(settings.connectTimeoutMillis())) @@ -279,7 +280,7 @@ public static ControlConnector create(VotingPluginProxy proxy) throws IOExceptio route, recovering, proxy::restartControlServicesAfterRecovery, server -> proxy.testBackendCommunication(server, 5000L), new ProxyMethodConfigurationService(proxy), () -> proxy.reloadCore(true), new ProxyConfigurationFileService(proxy), - deployments, deploymentHttp, credential, directLocalDeploymentEndpoint); + deployments, deploymentHttp, credential, directLocalDeploymentEndpoint, allowInsecureHttpPluginDeployment); if (recovered != null) connector.completedTasks.putAll(recovered.results()); return connector; } @@ -350,7 +351,7 @@ private CompletableFuture handleDeploymentClaim(Response response) { synchronized (operationLifecycle) { if (closed) return CompletableFuture.completedFuture(null); deploymentWork = CompletableFuture.supplyAsync(() -> deployments.deploy(task, settings.endpoint(), - directLocalDeploymentEndpoint, settings.nodeId(), sessionId, deploymentCredential, deploymentHttp, + directLocalDeploymentEndpoint, allowInsecureHttpPluginDeployment, settings.nodeId(), sessionId, deploymentCredential, deploymentHttp, Duration.ofMillis(settings.requestTimeoutMillis()), () -> !closed), deploymentExecutor); activeDeploymentWork = deploymentWork; } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VelocityConfig.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VelocityConfig.java index 03cf7de3c9..dd899e213a 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VelocityConfig.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VelocityConfig.java @@ -224,6 +224,11 @@ public String getControlEndpoint() { return getString(getNode("Control", "Endpoint"), "http://127.0.0.1:8080"); } + @Override + public boolean getControlAllowInsecureHttpPluginDeployment() { + return getBoolean(getNode("Control", "AllowInsecureHttpPluginDeployment"), false); + } + @Override public String getControlNodeId() { return getString(getNode("Control", "NodeId"), ""); diff --git a/VotingPlugin/src/main/resources/Config.yml b/VotingPlugin/src/main/resources/Config.yml index 1050b30c49..c8c43a272b 100644 --- a/VotingPlugin/src/main/resources/Config.yml +++ b/VotingPlugin/src/main/resources/Config.yml @@ -1174,6 +1174,10 @@ Webhooks: ########################################### Control: + # Allows executable VotingPlugin updates over a literal private-network HTTP Control endpoint. + # Credentials, deployment metadata, and artifacts can be intercepted or modified on that network path. + # Prefer HTTPS. Normal Control communication does not require this option. + AllowInsecureHttpPluginDeployment: false # Optionally let this Bukkit/Paper backend provision and supervise VotingPlugin Control as a separate JVM. # Enable this on only one proxy or backend in the network. Hosted: @@ -1197,8 +1201,8 @@ Control: Enabled: false # Blank reuses BungeeSettings.Server, which must be unique for every backend. NodeId: '' - # For a proxy-hosted Control, use the proxy VM/private IP. HTTP staging also accepts literal local/private IPs - # and localhost only when direct hosting on this same node is confirmed. + # For a proxy-hosted Control, use the proxy VM/private IP for normal Control communication. + # Executable staging over private HTTP requires AllowInsecureHttpPluginDeployment above. # HTTPS is strongly recommended because connector credentials and staged plugin artifacts cross this connection. Endpoint: 'http://127.0.0.1:8080' # VotingPlugin automatically generates this local credential only after confirming it can enroll through diff --git a/VotingPlugin/src/main/resources/bungeeconfig.yml b/VotingPlugin/src/main/resources/bungeeconfig.yml index 95b30ea236..fb1c074940 100644 --- a/VotingPlugin/src/main/resources/bungeeconfig.yml +++ b/VotingPlugin/src/main/resources/bungeeconfig.yml @@ -510,8 +510,12 @@ MultiProxyServers: Port: 1235 # Optional local-first VotingPlugin Control discovery. Missing keys preserve the disabled default. Control: + # Allows executable VotingPlugin updates over a literal private-network HTTP Control endpoint. + # Credentials, deployment metadata, and artifacts can be intercepted or modified on that network path. + # Prefer HTTPS. Normal Control communication does not require this option. + AllowInsecureHttpPluginDeployment: false Enabled: false - # HTTP staging accepts literal local/private IPs, or localhost when direct hosting on this same node is confirmed. + # HTTP staging accepts loopback or proven same-node localhost; private HTTP requires the opt-in above. # HTTPS is strongly recommended because connector credentials # and staged plugin artifacts cross this connection. Endpoint: 'http://127.0.0.1:8080' diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlConnectorProtocolTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlConnectorProtocolTest.java index 8509f9e7fa..04f6d77bd1 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlConnectorProtocolTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlConnectorProtocolTest.java @@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -29,6 +30,43 @@ class BackendControlConnectorProtocolTest { @TempDir Path directory; + @Test void backendDeploymentSettingDefaultsOffAndReadsExplicitTrue() { + com.bencodez.votingplugin.config.Config config = org.mockito.Mockito.mock( + com.bencodez.votingplugin.config.Config.class, org.mockito.Mockito.CALLS_REAL_METHODS); + org.bukkit.configuration.file.YamlConfiguration yaml = new org.bukkit.configuration.file.YamlConfiguration(); + org.mockito.Mockito.when(config.getData()).thenReturn(yaml); + assertFalse(config.getControlAllowInsecureHttpPluginDeployment()); + yaml.set("Control.AllowInsecureHttpPluginDeployment", true); + assertTrue(config.getControlAllowInsecureHttpPluginDeployment()); + } + + @Test void privateHttpWithoutOptInDoesNotPrepareOrAdvertiseBackendDeployment() throws Exception { + com.bencodez.votingplugin.VotingPluginMain plugin = org.mockito.Mockito.mock(com.bencodez.votingplugin.VotingPluginMain.class); + com.bencodez.votingplugin.config.Config config = org.mockito.Mockito.mock(com.bencodez.votingplugin.config.Config.class); + org.bukkit.configuration.file.YamlConfiguration yaml = new org.bukkit.configuration.file.YamlConfiguration(); + yaml.set("Control.Backend.Enabled", true); + yaml.set("Control.Backend.NodeId", "backend-a"); + yaml.set("Control.Backend.Endpoint", "http://192.168.0.50:2150"); + yaml.set("Control.Backend.CredentialFile", "test-credential.txt"); + Files.writeString(directory.resolve("test-credential.txt"), "test-credential"); + org.mockito.Mockito.when(plugin.getDataFolder()).thenReturn(directory.toFile()); + org.mockito.Mockito.when(plugin.getConfigFile()).thenReturn(config); + org.mockito.Mockito.when(config.getData()).thenReturn(yaml); + org.mockito.Mockito.when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger()); + try (BackendControlConnector connector = BackendControlConnector.create(plugin)) { + assertNotNull(connector); + java.lang.reflect.Field field = BackendControlConnector.class.getDeclaredField("deployments"); + field.setAccessible(true); + assertNull(field.get(connector)); + JsonObject body = new JsonObject(); + BackendControlConnector.addCapabilities(body, false, field.get(connector) != null); + assertFalse(body.getAsJsonArray("capabilities").asList().stream() + .anyMatch(value -> PluginDeploymentService.CAPABILITY.equals(value.getAsString()))); + org.mockito.Mockito.verify(plugin, org.mockito.Mockito.never()).getServer(); + org.mockito.Mockito.verify(plugin, org.mockito.Mockito.never()).getLoadedPluginJarFile(); + } + } + @Test void onlyTheLeaseExpiryConflictRequestsAResultReclaim() { assertTrue(BackendControlConnector.taskLeaseExpired(new BackendControlConnector.Response(409, "{\"error\":{\"code\":\"TASK_LEASE_EXPIRED\"}}"))); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/PluginDeploymentServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/PluginDeploymentServiceTest.java index 162d58f70f..0eb93e8aaf 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/PluginDeploymentServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/PluginDeploymentServiceTest.java @@ -133,45 +133,50 @@ class PluginDeploymentServiceTest { "a deleted or quarantined update must be staged again before restart"); } - @Test void credentialedDeploymentAllowsHttpsAndLiteralPrivateNetworkHttp() { - assertTrue(PluginDeploymentService.deploymentEndpointAllowed( - java.net.URI.create("https://control.example.test"))); - assertTrue(PluginDeploymentService.deploymentEndpointAllowed( - java.net.URI.create("http://192.168.0.50:8080"))); - assertTrue(PluginDeploymentService.deploymentEndpointAllowed( - java.net.URI.create("http://10.20.30.40:8080"))); - assertTrue(PluginDeploymentService.deploymentEndpointAllowed( - java.net.URI.create("http://172.31.4.5:8080"))); - assertTrue(PluginDeploymentService.deploymentEndpointAllowed( - java.net.URI.create("http://127.0.0.1:8080"))); - assertTrue(PluginDeploymentService.deploymentEndpointAllowed( - java.net.URI.create("http://[::1]:8080"))); - assertTrue(PluginDeploymentService.deploymentEndpointAllowed( - java.net.URI.create("http://[fd00::50]:8080"))); - assertFalse(PluginDeploymentService.deploymentEndpointAllowed( - java.net.URI.create("http://192.0.2.10:8080"))); - assertFalse(PluginDeploymentService.deploymentEndpointAllowed( - java.net.URI.create("http://8.8.8.8:8080"))); - assertFalse(PluginDeploymentService.deploymentEndpointAllowed( - java.net.URI.create("http://localhost:8080"))); - assertTrue(PluginDeploymentService.deploymentEndpointAllowed( - java.net.URI.create("http://localhost:8080"), true)); - assertFalse(PluginDeploymentService.deploymentEndpointAllowed( - java.net.URI.create("http://localhost:8080"), false)); - assertFalse(PluginDeploymentService.deploymentEndpointAllowed( - java.net.URI.create("http://control.example.test:8080"))); - assertFalse(PluginDeploymentService.deploymentEndpointAllowed( - java.net.URI.create("ftp://control.example.test"))); - assertFalse(PluginDeploymentService.deploymentEndpointAllowed(null)); - assertTrue(PluginDeploymentService.usesUnencryptedHttp( - java.net.URI.create("http://192.168.0.50:8080"))); - assertFalse(PluginDeploymentService.usesUnencryptedHttp( - java.net.URI.create("https://control.example.test"))); + @Test void deploymentTransportPolicyRequiresExplicitPrivateHttpOptIn() { + for (boolean optIn : new boolean[] {false, true}) { + for (String endpoint : new String[] {"https://control.example.test", "http://127.0.0.1:8080", + "http://127.0.0.2", "http://[::1]:8080", "http://[0:0:0:0:0:0:0:1]", + "http://[::ffff:127.0.0.1]"}) { + assertTrue(PluginDeploymentService.deploymentEndpointAllowed(java.net.URI.create(endpoint), false, optIn), endpoint); + } + assertTrue(PluginDeploymentService.deploymentEndpointAllowed(java.net.URI.create("http://localhost:8080"), true, optIn)); + for (String endpoint : new String[] {"http://192.168.0.50:8080", "http://10.20.30.40:8080", + "http://172.16.0.1", "http://172.31.4.5:8080", "http://[fd00::50]:8080", "http://[fc00::1]", + "http://169.254.1.2", "http://[fe80::1]"}) { + assertEquals(optIn, PluginDeploymentService.deploymentEndpointAllowed(java.net.URI.create(endpoint), false, optIn), endpoint); + assertTrue(PluginDeploymentService.deploymentEndpointAllowed(java.net.URI.create(endpoint), true, optIn), "proven same-node: " + endpoint); + assertFalse(PluginDeploymentService.deploymentEndpointAllowed(java.net.URI.create(endpoint)), "missing opt-in: " + endpoint); + } + } + } + + @Test void insecureOptInNeverAuthorizesPublicHttpOrArbitraryHostnames() { + for (boolean localProof : new boolean[] {false, true}) { + for (String endpoint : new String[] {"http://192.0.2.10", "http://8.8.8.8", "http://172.15.0.1", + "http://172.32.0.1", "http://[2001:4860:4860::8888]", "http://control.example.test", + "http://private.internal", "ftp://127.0.0.1", "ftp://192.168.0.50"}) { + assertFalse(PluginDeploymentService.deploymentEndpointAllowed(java.net.URI.create(endpoint), localProof, true), endpoint); + } + } + assertFalse(PluginDeploymentService.deploymentEndpointAllowed(java.net.URI.create("http://localhost"), false, true)); + assertFalse(PluginDeploymentService.deploymentEndpointAllowed(null, true, true)); + } + + @Test void disabledPrivateHttpDeploymentStopsBeforeArtifactNetworkAccess() throws Exception { + PluginDeploymentService service = PluginDeploymentService.backend(directory.resolve("update"), Path.of("VotingPlugin.jar")); + java.net.http.HttpClient http = org.mockito.Mockito.mock(java.net.http.HttpClient.class); + PluginDeploymentService.Result result = service.deploy(task(jar("name: VotingPlugin\n")), + java.net.URI.create("http://192.168.0.50:8080"), false, false, "backend-a", UUID.randomUUID(), + "test-credential", http, java.time.Duration.ofSeconds(1), () -> true); + assertEquals("INSECURE_ENDPOINT", result.code()); + org.mockito.Mockito.verifyNoInteractions(http); + assertFalse(Files.exists(directory.resolve("update/VotingPlugin.jar"))); } @Test void credentialEndpointRetainsTheOriginalHttpsOrProvenLoopbackRule() { assertTrue(PluginDeploymentService.deploymentEndpointAllowed( - java.net.URI.create("http://192.168.0.50:8080"), false)); + java.net.URI.create("http://192.168.0.50:8080"), false, true)); assertFalse(PluginDeploymentService.credentialEndpointAllowed( java.net.URI.create("http://192.168.0.50:8080"), false)); assertFalse(PluginDeploymentService.credentialEndpointAllowed( diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ControlConnectorTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ControlConnectorTest.java index 124dae39ca..3a12d05355 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ControlConnectorTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ControlConnectorTest.java @@ -102,6 +102,45 @@ class ControlConnectorTest { assertEquals(1, secondSnapshot.get("sequence").getAsLong()); } + @Test void bungeeDeploymentSettingDefaultsOffAndReadsExplicitTrue() { + com.bencodez.votingplugin.proxy.bungee.BungeeConfig config = mock( + com.bencodez.votingplugin.proxy.bungee.BungeeConfig.class, CALLS_REAL_METHODS); + net.md_5.bungee.config.Configuration data = new net.md_5.bungee.config.Configuration(); + when(config.getData()).thenReturn(data); + assertFalse(config.getControlAllowInsecureHttpPluginDeployment()); + data.set("Control.AllowInsecureHttpPluginDeployment", true); + assertTrue(config.getControlAllowInsecureHttpPluginDeployment()); + } + + @Test void privateHttpWithoutOptInDoesNotPrepareOrAdvertiseProxyDeployment() throws Exception { + com.bencodez.votingplugin.proxy.VotingPluginProxy proxy = mock(com.bencodez.votingplugin.proxy.VotingPluginProxy.class); + com.bencodez.votingplugin.proxy.VotingPluginProxyConfig config = mock(com.bencodez.votingplugin.proxy.VotingPluginProxyConfig.class, CALLS_REAL_METHODS); + when(proxy.getDataFolderPlugin()).thenReturn(dataDirectory.toFile()); + when(proxy.getConfig()).thenReturn(config); + when(proxy.getScheduler()).thenReturn(scheduler); + when(proxy.getProxyPlatform()).thenReturn("VELOCITY"); + when(proxy.getPluginVersion()).thenReturn("7.1.2"); + when(config.getControlEnabled()).thenReturn(true); + when(config.getControlNodeId()).thenReturn("proxy-a"); + when(config.getControlEndpoint()).thenReturn("http://192.168.0.50:2150"); + when(config.getControlCredentialFile()).thenReturn("test-credential.txt"); + Files.writeString(dataDirectory.resolve("test-credential.txt"), "test-credential"); + try (ControlConnector privateHttp = ControlConnector.create(proxy)) { + Field field = ControlConnector.class.getDeclaredField("deployments"); + field.setAccessible(true); + assertNull(field.get(privateHttp)); + JsonObject body = new JsonObject(); + ControlConnector.addCapabilities(body, true, true, true, true, field.get(privateHttp) != null); + assertFalse(body.getAsJsonArray("capabilities").asList().stream() + .anyMatch(value -> PluginDeploymentService.CAPABILITY.equals(value.getAsString()))); + verify(proxy).log(contains("Plugin deployment over private-network HTTP is disabled")); + verify(proxy, never()).log(contains("staging is unavailable")); + Field executor = ControlConnector.class.getDeclaredField("deploymentExecutor"); + executor.setAccessible(true); + assertNull(executor.get(privateHttp), "disabled deployment never allocates its worker"); + } + } + @Test void deploymentCapabilityIsAdvertisedOnlyWhenProxyStagingIsReady() { JsonObject unavailable = new JsonObject(); ControlConnector.addCapabilities(unavailable, true, true, true, true, false); @@ -1011,11 +1050,11 @@ private ControlConnector deploymentConnector(PluginDeploymentService deployments LongSupplier.class, ProxyRoutingConfigurationService.class, Path.class, ProxyControlResultStore.Route.class, boolean.class, Runnable.class, Function.class, ProxyMethodConfigurationService.class, Runnable.class, ProxyConfigurationFileService.class, PluginDeploymentService.class, HttpClient.class, String.class, - boolean.class); + boolean.class, boolean.class); constructor.setAccessible(true); return constructor.newInstance(settings(), scheduler, transport, (Supplier>) List::of, (Consumer) logs::add, UUID.randomUUID(), (LongSupplier) () -> 0L, null, null, null, false, - null, null, null, null, null, deployments, HttpClient.newHttpClient(), "credential", false); + null, null, null, null, null, deployments, HttpClient.newHttpClient(), "credential", false, false); } private void setConnectorField(String name, Object value) throws Exception { diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/velocity/VelocityConfigControlTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/velocity/VelocityConfigControlTest.java index 01339b3e53..3d799b2a1e 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/velocity/VelocityConfigControlTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/velocity/VelocityConfigControlTest.java @@ -20,6 +20,17 @@ class VelocityConfigControlTest { @TempDir Path directory; + @Test void insecureDeploymentOptInDefaultsOffAndReadsExplicitTrue() throws Exception { + Path file = directory.resolve("velocity.yml"); + Files.writeString(file, "Control:\n Enabled: true\n"); + VelocityConfig config = new VelocityConfig(file.toFile()); + config.loadControlConfiguration(); + assertFalse(config.getControlAllowInsecureHttpPluginDeployment()); + Files.writeString(file, "Control:\n AllowInsecureHttpPluginDeployment: true\n"); + config.loadControlConfiguration(); + assertTrue(config.getControlAllowInsecureHttpPluginDeployment()); + } + @Test void omittedSendVotesSettingUsesRuntimeDefaultDuringRevisionCheck() throws Exception { Path file = directory.resolve("velocity.yml"); diff --git a/docs/control-agent-contract.md b/docs/control-agent-contract.md index 1818ba1492..34d00e9950 100644 --- a/docs/control-agent-contract.md +++ b/docs/control-agent-contract.md @@ -194,12 +194,17 @@ and never restarts a proxy or backend automatically. A node advertises it only w - the connector is the currently enabled Control route, not a recovery-only connector draining an older durable result; - a safe local staging target was prepared; -- the Control endpoint uses HTTPS, HTTP with a literal loopback/link-local/private-network address, or - `http://localhost` with confirmed direct local hosting on the same node. - -HTTP remains supported for directly addressed trusted private networks. Other hostnames and public IP addresses require HTTPS, -which is strongly recommended because the artifact request carries the node bearer credential and plugin artifact in -transit. Connectors log that recommendation at startup when staging is enabled over HTTP. +- the Control endpoint uses HTTPS, literal loopback HTTP, or proven direct same-node HTTP; otherwise, a + non-loopback literal private-network/link-local HTTP endpoint requires the node to explicitly set + `Control.AllowInsecureHttpPluginDeployment: true` (missing defaults to `false`). + +Normal private-network HTTP Control communication remains supported without that opt-in. Only executable +`plugin.deploy.v1` preparation, advertising, and polling are disabled by default on those routes. Public HTTP and +arbitrary HTTP hostnames remain prohibited even with the opt-in; `localhost` still requires proven direct local hosting. +An opted-in connector emits a startup warning: node credentials, deployment metadata, and executable artifacts cross +unauthenticated plaintext HTTP. SHA-256 checks detect mismatched bytes, but cannot authenticate the source when an +attacker can substitute both the task digest and matching artifact. HTTPS is recommended. The separate credential-endpoint +policy remains HTTPS or proven same-node loopback HTTP; this opt-in does not relax that contract. Control leases deployment work separately from configuration operations: diff --git a/docs/control-connector.md b/docs/control-connector.md index 575fabe08a..d5a51ce258 100644 --- a/docs/control-connector.md +++ b/docs/control-connector.md @@ -212,11 +212,24 @@ restart. This capability is deliberately separate from configuration control and VotingPlugin never hot-reloads itself and never restarts the server or proxy automatically. Deployment is available only on the currently enabled Control route. Recovery-only connectors that exist solely to -acknowledge an older durable result never advertise or poll this capability. HTTPS endpoints can stage generally. HTTP -staging is limited to literal loopback, link-local, and private-network endpoint addresses, plus `localhost` when direct -local hosting on the same node is confirmed. Other hostnames and public IPs do not qualify. HTTPS is strongly recommended -because the artifact request carries the node bearer credential and plugin artifact in transit; connectors emit a startup -warning when verified staging is enabled over HTTP. +acknowledge an older durable result never advertise or poll this capability. HTTPS, literal loopback HTTP, and +proven direct same-node HTTP staging remain eligible by default. Other literal private-network/link-local HTTP addresses +require an explicit node setting in `Config.yml` (backend) or `bungeeconfig.yml` (Bungee/Velocity): + +```yaml +Control: + AllowInsecureHttpPluginDeployment: false +``` + +Set this to `true` only when deliberately accepting plaintext executable-deployment risk. Missing values default to +`false`. Normal Control configuration, inspection, and presence communication over private HTTP remains available without +this option. Public HTTP and arbitrary HTTP hostnames remain ineligible even with it; `localhost` requires direct same-node +hosting proof. No configuration migration or rewrite is required. Restart/recreate the connector after changing the policy. + +When private HTTP deployment is opted in, the connector warns during initialization that node credentials, deployment +metadata, and plugin artifacts cross an unauthenticated plaintext connection. A network attacker can replace both the task +SHA-256 and its matching JAR, so checksum verification alone does not authenticate a deployment. Use HTTPS where possible. +The separate credential-endpoint safety policy is unchanged. Control leases deployment work through `POST /api/v1/nodes/{nodeId}/deployments`. The node downloads the artifact through the matching deployment artifact endpoint with its bearer credential plus exact session and attempt headers, then