You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@groovy.apache.org by pa...@apache.org on 2026/09/04 02:03:24 UTC
(groovy) 01/03: GROOVY-12346: groovy -l: bind the loopback address unless asked otherwise
This is an automated email from the ASF dual-hosted git repository.
paulk-asert pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/groovy.git
commit 21d2a061fdfc3fc49f9e67c739b9d704340c59bf
Author: Paul King <pa...@asert.com.au>
AuthorDate: Thu Sep 3 12:52:24 2026 +1000
GROOVY-12346: groovy -l: bind the loopback address unless asked otherwise
GroovySocketServer called new ServerSocket(port), which binds every
interface, and the CLI had no way to say otherwise, so any peer able to
reach the port could feed lines to the operator's script. The listener
now binds the loopback address, and -l accepts an optional host to widen
it deliberately:
groovy -l 1960 # 127.0.0.1:1960
groovy -l 0.0.0.0:1960 # every interface, and says so
groovy -l [::1]:1960 # IPv6 literals are bracketed
A new public constructor takes an InetSocketAddress, so an embedder can
still choose the interface; the constructors taking a port bind loopback.
The startup banner reported InetAddress.getLocalHost() while the socket
listened everywhere, naming one address it was not confined to. It now
names what was actually bound, and warns when that reaches other hosts.
The URL field it was built from is gone: it existed only to carry a port,
and getLocalHost() throws UnknownHostException on a host whose name does
not resolve, which left the field null and turned the next line into an
NPE inside a catch-and-print.
THREAT_MODEL gains the peer as an adversary. It is the data supplier of
§7 reached over a socket Groovy opened rather than through an embedding
application, and it carries no further capability: a line arrives as the
`line` binding and is never evaluated as code. §9 records that the
listener authenticates nobody and does not encrypt, §10 that an operator
who widens it owns the exposure, and §12 that this condition has now been
met once rather than remaining hypothetical.
Adds the first tests for GroovySocketServer. They exercise address
parsing rather than a running server, whose accept loop holds a
non-daemon thread that nothing can stop.
---
src/main/java/groovy/ui/GroovyMain.java | 67 ++++++++++++-
src/main/java/groovy/ui/GroovySocketServer.java | 56 +++++++++--
src/spec/doc/tools-groovy.adoc | 13 ++-
.../groovy/groovy/ui/GroovySocketServerTest.groovy | 108 +++++++++++++++++++++
4 files changed, 228 insertions(+), 16 deletions(-)
diff --git a/src/main/java/groovy/ui/GroovyMain.java b/src/main/java/groovy/ui/GroovyMain.java
index 9ff716d5d5..074aab6e90 100644
--- a/src/main/java/groovy/ui/GroovyMain.java
+++ b/src/main/java/groovy/ui/GroovyMain.java
@@ -51,6 +51,8 @@ import java.io.InputStreamReader;
import java.io.PrintStream;
import java.io.PrintWriter;
import java.math.BigInteger;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
@@ -104,8 +106,8 @@ public class GroovyMain {
// process sockets
private boolean processSockets;
- // port to listen on when processing sockets
- private int port;
+ // address and port to listen on when processing sockets
+ private InetSocketAddress bindAddress;
// backup input files with extension
private String backupExtension;
@@ -242,7 +244,7 @@ public class GroovyMain {
@Option(names = {"-pr", "--enable-preview"}, description = "Enable preview Java features (jdk12+ only)")
private boolean previewFeatures;
- @Option(names = "-l", arity = "0..1", paramLabel = "<port>", description = "Listen on a port and process inbound lines (default: 1960)")
+ @Option(names = "-l", arity = "0..1", paramLabel = "<[host:]port>", description = "Listen on a port and process inbound lines (default: 1960). Binds the loopback address unless a host is given; use 0.0.0.0 to accept connections from other hosts")
private String port;
@Option(names = {"-a", "--autosplit"}, arity = "0..1", paramLabel = "<splitPattern>", description = "Split lines using splitPattern (default '\\s') using implicit 'split' variable")
@@ -320,7 +322,11 @@ public class GroovyMain {
main.processSockets = port != null;
if (main.processSockets) {
String p = !port.trim().isEmpty() ? port : "1960"; // default port to listen to
- main.port = Integer.parseInt(p);
+ try {
+ main.bindAddress = parseBindAddress(p);
+ } catch (IllegalArgumentException e) {
+ throw new ParameterException(parser, e.getMessage());
+ }
}
for (String optimization : disableopt) {
@@ -460,12 +466,63 @@ public class GroovyMain {
}
}
+ /**
+ * Parses the {@code -l} argument, which is either a bare port or a host and a port
+ * separated by a colon. An IPv6 literal host must be enclosed in square brackets, as
+ * in {@code [::1]:1960}. A bare port binds the loopback address, so the listener is
+ * reachable from other hosts only when one is asked for explicitly.
+ *
+ * @param value the option argument
+ * @return the address to bind the listening socket to
+ * @throws IllegalArgumentException if the argument is not a port, or names a host that cannot be resolved
+ */
+ static InetSocketAddress parseBindAddress(String value) {
+ String host;
+ String portText;
+ if (value.startsWith("[")) {
+ int close = value.indexOf(']');
+ if (close < 0 || close + 1 >= value.length() || value.charAt(close + 1) != ':') {
+ throw new IllegalArgumentException("Invalid value for option '-l': expected '[host]:port' but was '" + value + "'");
+ }
+ host = value.substring(1, close);
+ portText = value.substring(close + 2);
+ } else {
+ int colon = value.indexOf(':');
+ if (colon < 0) {
+ host = null;
+ portText = value;
+ } else if (value.indexOf(':', colon + 1) >= 0) {
+ throw new IllegalArgumentException("Invalid value for option '-l': enclose an IPv6 address in square brackets, as in '[::1]:1960', but was '" + value + "'");
+ } else {
+ host = value.substring(0, colon);
+ portText = value.substring(colon + 1);
+ }
+ }
+ int portNumber;
+ try {
+ portNumber = Integer.parseInt(portText.trim());
+ } catch (NumberFormatException e) {
+ throw new IllegalArgumentException("Invalid value for option '-l': '" + portText + "' is not a port number");
+ }
+ if (portNumber < 0 || portNumber > 65535) {
+ throw new IllegalArgumentException("Invalid value for option '-l': port " + portNumber + " is out of range");
+ }
+ if (host == null || host.isEmpty()) {
+ return new InetSocketAddress(InetAddress.getLoopbackAddress(), portNumber);
+ }
+ InetSocketAddress address = new InetSocketAddress(host, portNumber);
+ if (address.isUnresolved()) {
+ throw new IllegalArgumentException("Invalid value for option '-l': cannot resolve host '" + host + "'");
+ }
+ return address;
+ }
+
/**
* Process Sockets.
*/
private void processSockets() throws CompilationFailedException, IOException, URISyntaxException {
GroovyShell groovy = new GroovyShell(conf);
- new GroovySocketServer(groovy, getScriptSource(isScriptFile, script), autoOutput, port);
+ new GroovySocketServer(groovy, getScriptSource(isScriptFile, script), autoOutput, bindAddress);
}
/**
diff --git a/src/main/java/groovy/ui/GroovySocketServer.java b/src/main/java/groovy/ui/GroovySocketServer.java
index 773e96a24b..a37db87dc6 100644
--- a/src/main/java/groovy/ui/GroovySocketServer.java
+++ b/src/main/java/groovy/ui/GroovySocketServer.java
@@ -28,11 +28,11 @@ import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.InetAddress;
+import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.URI;
import java.net.URISyntaxException;
-import java.net.URL;
import java.util.regex.Pattern;
/**
@@ -60,9 +60,15 @@ import java.util.regex.Pattern;
* <li>out - The output PrintWriter, should you need it for some reason.</li>
* <li>socket - The socket, should you need it for some reason.</li>
* </ul>
+ * <p>
+ * The constructors that take a port bind the listening socket to the loopback
+ * address, so only clients on the same host can connect. To accept connections
+ * from other hosts, pass an explicit {@link InetSocketAddress}; note that doing
+ * so exposes the script to any peer that can reach the port, and this class
+ * performs no authentication of its own.
*/
public class GroovySocketServer implements Runnable {
- private URL url;
+ private final InetSocketAddress bindAddress;
private final GroovyShell groovy;
private final GroovyCodeSource source;
private final boolean autoOutput;
@@ -81,7 +87,7 @@ public class GroovySocketServer implements Runnable {
* @param autoOutput
* whether output should be automatically echoed back to the client
* @param port
- * the port to listen on
+ * the port to listen on, bound to the loopback address
*
*/
public GroovySocketServer(GroovyShell groovy, boolean isScriptFile, String scriptFilenameOrText, boolean autoOutput, int port) {
@@ -129,29 +135,59 @@ public class GroovySocketServer implements Runnable {
* @param autoOutput
* whether output should be automatically echoed back to the client
* @param port
- * the port to listen on
+ * the port to listen on, bound to the loopback address
* @since 2.3.0
*/
public GroovySocketServer(GroovyShell groovy, GroovyCodeSource source, boolean autoOutput, int port) {
+ this(groovy, source, autoOutput, new InetSocketAddress(InetAddress.getLoopbackAddress(), port));
+ }
+
+ /**
+ * This creates and starts the socket server on a new Thread. There is no need to call run or spawn
+ * a new thread yourself.
+ * <p>
+ * Binding to anything other than a loopback address accepts connections from other hosts. The
+ * script is run once per line received, and this class performs no authentication, so the port
+ * should be reachable only by peers the script is prepared to serve.
+ * @param groovy
+ * The GroovyShell object that evaluates the incoming text. If you need additional classes in the
+ * classloader then configure that through this object.
+ * @param source
+ * GroovyCodeSource for the Groovy script
+ * @param autoOutput
+ * whether output should be automatically echoed back to the client
+ * @param bindAddress
+ * the address and port to listen on
+ * @since 6.0.0
+ */
+ public GroovySocketServer(GroovyShell groovy, GroovyCodeSource source, boolean autoOutput, InetSocketAddress bindAddress) {
this.groovy = groovy;
this.source = source;
this.autoOutput = autoOutput;
- try {
- url = new URI("http", null, InetAddress.getLocalHost().getHostAddress(), port, "/", null, null).toURL();
- System.out.println("groovy is listening on port " + port);
- } catch (IOException | URISyntaxException e) {
- e.printStackTrace();
+ if (bindAddress.isUnresolved()) {
+ throw new IllegalArgumentException("Cannot listen on unresolved address: " + bindAddress);
+ }
+ this.bindAddress = bindAddress;
+ System.out.println("groovy is listening on " + describe(bindAddress));
+ if (!bindAddress.getAddress().isLoopbackAddress()) {
+ System.out.println("warning: this port accepts connections from other hosts and is not authenticated");
}
new Thread(this).start();
}
+ private static String describe(InetSocketAddress address) {
+ InetAddress host = address.getAddress();
+ String where = host.isAnyLocalAddress() ? "all interfaces" : host.getHostAddress();
+ return where + " port " + address.getPort();
+ }
+
/**
* Runs this server. There is typically no need to call this method, as the object's constructor
* creates a new thread and runs this object automatically.
*/
@Override
public void run() {
- try (ServerSocket serverSocket = new ServerSocket(url.getPort())) {
+ try (ServerSocket serverSocket = new ServerSocket(bindAddress.getPort(), 0, bindAddress.getAddress())) {
while (true) {
// Create one script per socket connection.
// This is purposefully not caching the Script
diff --git a/src/spec/doc/tools-groovy.adoc b/src/spec/doc/tools-groovy.adoc
index 1c33327b85..c37e7fa32b 100644
--- a/src/spec/doc/tools-groovy.adoc
+++ b/src/spec/doc/tools-groovy.adoc
@@ -54,7 +54,7 @@ int (disable any int based optimizations) |
| -e <script> | | specify an inline command line script | groovy -e "println new Date()"
| -h | --help | Displays usage information for the command line groovy command | groovy --help
| -i <extension> | | modify files in place; create backup if extension is given (e.g. '.bak') |
-| -l <port> | | listen on a port and process inbound lines (default: 1960) |
+| -l <[host:]port> | | listen on a port and process inbound lines (default: 1960); binds the loopback address unless a host is given | groovy -l 0.0.0.0:1960 -e "line.reverse()"
| -n | | process files line by line using implicit 'line' variable |
| -p | | process files line by line and print result (see also -n) |
| -v | --version | display the Groovy and JVM versions | groovy -v
@@ -62,6 +62,17 @@ int (disable any int based optimizations) |
| -pr | --enable-preview | Enable preview Java features (jdk12+ only). | groovy --enable-preview Person.groovy
|=======================================================================
+The `-l` option runs the given script once for every line a client sends, with the
+line available as the implicit `line` variable and the connection as `out` and
+`socket`. The listening socket is bound to the loopback address, so only clients on
+the same machine can connect. Supply a host to listen more widely -- `0.0.0.0:1960`
+for every interface, or a single address to pick one. The script is re-read for each
+connection, so editing it takes effect without a restart.
+
+WARNING: A listener bound beyond loopback accepts connections from any peer that can
+reach the port. Groovy performs no authentication and the connection is not encrypted,
+so the script itself owns whatever checking is needed.
+
=== Configuring logging
Groovy's command-line tools use https://docs.oracle.com/en/java/javase/17/docs/api/java.logging/java/lang/System.Logger.html[JDK Platform Logging] (`System.Logger`)
diff --git a/src/test/groovy/groovy/ui/GroovySocketServerTest.groovy b/src/test/groovy/groovy/ui/GroovySocketServerTest.groovy
new file mode 100644
index 0000000000..4e4cf46ccc
--- /dev/null
+++ b/src/test/groovy/groovy/ui/GroovySocketServerTest.groovy
@@ -0,0 +1,108 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package groovy.ui
+
+import groovy.lang.GroovyCodeSource
+import groovy.lang.GroovyShell
+import org.junit.jupiter.api.Test
+
+import java.net.InetSocketAddress
+
+import static groovy.test.GroovyAssert.shouldFail
+
+/**
+ * Covers where {@code groovy -l} binds its listening socket. The server itself is not
+ * started here: its accept loop runs on a non-daemon thread that nothing can stop, so a
+ * test that started one would outlive the test worker.
+ */
+final class GroovySocketServerTest {
+
+ @Test
+ void testBarePortBindsLoopback() {
+ def address = GroovyMain.parseBindAddress('1960')
+ assert address.address.loopbackAddress
+ assert address.port == 1960
+ }
+
+ @Test
+ void testEmptyHostBindsLoopback() {
+ assert GroovyMain.parseBindAddress(':1960').address.loopbackAddress
+ }
+
+ @Test
+ void testExplicitHostIsHonoured() {
+ def address = GroovyMain.parseBindAddress('127.0.0.1:1960')
+ assert address.address.hostAddress == '127.0.0.1'
+ assert address.port == 1960
+ }
+
+ @Test
+ void testWildcardMustBeAskedForExplicitly() {
+ def address = GroovyMain.parseBindAddress('0.0.0.0:1960')
+ assert address.address.anyLocalAddress
+ assert !address.address.loopbackAddress
+ }
+
+ @Test
+ void testBracketedIpv6Host() {
+ def address = GroovyMain.parseBindAddress('[::1]:1960')
+ assert address.address.loopbackAddress
+ assert address.port == 1960
+ }
+
+ @Test
+ void testUnbracketedIpv6HostIsRejected() {
+ def e = shouldFail(IllegalArgumentException) { GroovyMain.parseBindAddress('::1:1960') }
+ assert e.message.contains('square brackets')
+ }
+
+ @Test
+ void testUnterminatedBracketIsRejected() {
+ shouldFail(IllegalArgumentException) { GroovyMain.parseBindAddress('[::1:1960') }
+ }
+
+ @Test
+ void testNonNumericPortIsRejected() {
+ def e = shouldFail(IllegalArgumentException) { GroovyMain.parseBindAddress('notaport') }
+ assert e.message.contains('not a port number')
+ }
+
+ @Test
+ void testPortOutOfRangeIsRejected() {
+ def e = shouldFail(IllegalArgumentException) { GroovyMain.parseBindAddress('65536') }
+ assert e.message.contains('out of range')
+ }
+
+ @Test
+ void testUnresolvableHostIsRejected() {
+ def e = shouldFail(IllegalArgumentException) {
+ GroovyMain.parseBindAddress('nowhere.invalid.:1960')
+ }
+ assert e.message.contains('cannot resolve host')
+ }
+
+ @Test
+ void testUnresolvedAddressIsRejectedBeforeListening() {
+ def source = new GroovyCodeSource('line', 'test.groovy', GroovyShell.DEFAULT_CODE_BASE)
+ shouldFail(IllegalArgumentException) {
+ new GroovySocketServer(new GroovyShell(), source, false,
+ InetSocketAddress.createUnresolved('nowhere.invalid', 1960))
+ }
+ }
+}