From c3eccc75dbd2ca1e70ac327a386b6a26546bb986 Mon Sep 17 00:00:00 2001 From: Patrick Decat Date: Fri, 15 May 2026 11:35:19 +0200 Subject: [PATCH] feat: expose SSH_MSG_USERAUTH_BANNER messages via Connection.getBanners() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Servers can send banner messages at any point before authentication completes — typically a login notice, or for managed-SSH offerings like Tailscale, a web URL the user must visit to finish authenticating. Today these banners are received and stored, but the field is package- private on `AuthenticationManager` and inaccessible from outside the library, leaving no way for callers to surface them to users. Mirror the upstream trilead-ssh2 change (trilead/trilead-ssh2#206): - `AuthenticationManager`: accumulate banners into a `List` instead of clobbering the previous value, and expose `getBanners()` returning a defensive snapshot. - `Connection`: add `getBanners()` that delegates to the auth manager, or returns an empty list when authentication hasn't been attempted yet. Threading note: neither accessor synchronizes on the `Connection` or `AuthenticationManager` monitor. The blocking `authenticateWith*` entry points already hold the `Connection` monitor for the entire duration of the auth call, so a caller that wants to surface banners live (Tailscale's web-login URL arrives in a banner BEFORE USERAUTH_SUCCESS) must be able to read them from another thread while that call is still in flight. Synchronization is scoped to the `banners` list itself. --- .../java/com/trilead/ssh2/Connection.java | 33 +++++++++++++++++++ .../ssh2/auth/AuthenticationManager.java | 30 +++++++++++++++-- .../java/com/trilead/ssh2/ConnectionTest.java | 10 ++++++ 3 files changed, 71 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/trilead/ssh2/Connection.java b/src/main/java/com/trilead/ssh2/Connection.java index 0de952a0..ad06d2fa 100644 --- a/src/main/java/com/trilead/ssh2/Connection.java +++ b/src/main/java/com/trilead/ssh2/Connection.java @@ -9,6 +9,8 @@ import java.net.SocketTimeoutException; import java.security.KeyPair; import java.security.SecureRandom; +import java.util.Collections; +import java.util.List; import java.util.Vector; import com.trilead.ssh2.auth.AuthenticationManager; @@ -495,6 +497,37 @@ private void checkRequirements(String user) } } + /** + * Returns the {@code SSH_MSG_USERAUTH_BANNER} messages sent by the server. + *

+ * The server may send banners at any time before authentication completes + * (typically used to display login notices or, in the case of Tailscale SSH, + * a web URL the user must visit to finish authenticating). Banners received + * during a blocking {@code authenticateWith*} call are accumulated and can + * be read either while the call is in progress (from another thread) or + * after it returns. + * + * @return list of banner strings in the order received, never {@code null}; + * empty if no banners have been received or authentication has not + * been attempted yet. + * @see com.trilead.ssh2.auth.AuthenticationManager#getBanners() + */ + public List getBanners() + { + // Intentionally NOT synchronized on this Connection. The other authentication + // entry points (authenticateWithNone, authenticateWithPassword, ...) hold the + // Connection monitor for the entire duration of the blocking auth call. A + // caller that wants to display banners live — e.g. Tailscale SSH's web-login + // URL, which arrives in a banner BEFORE USERAUTH_SUCCESS — must be able to + // read them from another thread while that call is still blocked. + AuthenticationManager local = am; + if (local == null) + { + return Collections.emptyList(); + } + return local.getBanners(); + } + /** * Add a {@link ConnectionMonitor} to this connection. Can be invoked at any * time, but it is best to add connection monitors before invoking diff --git a/src/main/java/com/trilead/ssh2/auth/AuthenticationManager.java b/src/main/java/com/trilead/ssh2/auth/AuthenticationManager.java index 71c51163..b3963096 100644 --- a/src/main/java/com/trilead/ssh2/auth/AuthenticationManager.java +++ b/src/main/java/com/trilead/ssh2/auth/AuthenticationManager.java @@ -54,7 +54,7 @@ public class AuthenticationManager implements MessageHandler List packets = new ArrayList<>(); boolean connectionClosed = false; - String banner; + List banners = new ArrayList<>(); String[] remainingMethods = new String[0]; boolean isPartialSuccess = false; @@ -114,7 +114,33 @@ byte[] getNextMessage() throws IOException PacketUserauthBanner sb = new PacketUserauthBanner(msg, 0, msg.length); - banner = sb.getBanner(); + synchronized (banners) + { + banners.add(sb.getBanner()); + } + } + } + + /** + * Returns the {@code SSH_MSG_USERAUTH_BANNER} messages sent by the server. + * The server may send banners at any point before authentication completes + * (typically used to display login notices or, in the case of Tailscale SSH, + * a web URL the user must visit to finish authenticating). + *

+ * Returns a defensive snapshot, so the caller is free to iterate without + * holding any locks. Synchronizes only on the {@code banners} list itself, + * NOT on the {@code AuthenticationManager} or {@code Connection} monitor, + * so it remains callable from another thread while a blocking + * {@code authenticateWith*} call is in progress. + * + * @return list of banner strings in the order received, never {@code null}; + * empty if the server has not sent any. + */ + public List getBanners() + { + synchronized (banners) + { + return new ArrayList<>(banners); } } diff --git a/src/test/java/com/trilead/ssh2/ConnectionTest.java b/src/test/java/com/trilead/ssh2/ConnectionTest.java index 500270a8..3e4e7112 100644 --- a/src/test/java/com/trilead/ssh2/ConnectionTest.java +++ b/src/test/java/com/trilead/ssh2/ConnectionTest.java @@ -310,4 +310,14 @@ public void testSetAlgorithmMethods() { String[] testKexAlgos = {"diffie-hellman-group1-sha1"}; // Basic DH group connection.setKeyExchangeAlgorithms(testKexAlgos); } + +@Test +public void testGetBannersReturnsEmptyBeforeAuthentication() { + // Before any authentication attempt, AuthenticationManager has not been created + // yet, so the accessor must return an empty list rather than throwing or + // returning null. + assertNotNull(connection.getBanners(), "getBanners() must never return null"); + assertTrue(connection.getBanners().isEmpty(), + "getBanners() must be empty before authentication"); +} }