Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<UUID, StoredResult> completed = new LinkedHashMap<>();
private final boolean recovering;
Expand Down Expand Up @@ -142,21 +143,20 @@ 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(),
plugin.getLoadedPluginJarFile().toPath());
} 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;
}
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -448,22 +453,61 @@ private static void forceDirectory(Path directory) throws IOException {
/**
* True when the configured Control transport can carry a deployment request.
*
* <p>HTTP remains supported for literal loopback, link-local, and private network
* addresses. The overload also permits {@code localhost} when direct local hosting
* <p>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.</p>
*/
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. */
Expand All @@ -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;
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 "";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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", "");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, CompletableFuture<VotingPluginProxy.CommunicationTestResult>> communicationTest;
private final Runnable runtimeReplacement;
Expand Down Expand Up @@ -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,
Expand All @@ -160,7 +161,8 @@ private ControlConnector(Settings settings, ScheduledExecutorService scheduler,
Function<String, CompletableFuture<VotingPluginProxy.CommunicationTestResult>> 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");
Expand All @@ -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);
Expand Down Expand Up @@ -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()))
Expand All @@ -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;
}
Expand Down Expand Up @@ -350,7 +351,7 @@ private CompletableFuture<Void> 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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"), "");
Expand Down
8 changes: 6 additions & 2 deletions VotingPlugin/src/main/resources/Config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
6 changes: 5 additions & 1 deletion VotingPlugin/src/main/resources/bungeeconfig.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Loading
Loading