From e7965151c7364313dc591fad48150a31be7271f6 Mon Sep 17 00:00:00 2001 From: Adam Kalisz Date: Fri, 25 Sep 2026 02:59:48 +0200 Subject: [PATCH 1/7] Expand OpenSSH config Include directives --- .../java/com/jcraft/jsch/OpenSSHConfig.java | 318 +++++++++++++++--- .../com/jcraft/jsch/OpenSSHConfigTest.java | 79 +++++ 2 files changed, 342 insertions(+), 55 deletions(-) diff --git a/src/main/java/com/jcraft/jsch/OpenSSHConfig.java b/src/main/java/com/jcraft/jsch/OpenSSHConfig.java index 1a5dea34a..caabe3d72 100644 --- a/src/main/java/com/jcraft/jsch/OpenSSHConfig.java +++ b/src/main/java/com/jcraft/jsch/OpenSSHConfig.java @@ -32,8 +32,14 @@ import java.io.StringReader; import java.nio.charset.StandardCharsets; import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.PathMatcher; import java.nio.file.Paths; +import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashSet; import java.util.Hashtable; import java.util.List; import java.util.Locale; @@ -51,6 +57,7 @@ *
  • User
  • *
  • Hostname
  • *
  • Port
  • + *
  • Include
  • *
  • PreferredAuthentications
  • *
  • PubkeyAcceptedAlgorithms
  • *
  • FingerprintHash
  • @@ -92,7 +99,7 @@ public class OpenSSHConfig implements ConfigRepository { public static OpenSSHConfig parse(String conf) throws IOException { try (Reader r = new StringReader(conf)) { try (BufferedReader br = new BufferedReader(r)) { - return new OpenSSHConfig(br); + return new OpenSSHConfig(br, userSshDirectory()); } } } @@ -104,47 +111,271 @@ public static OpenSSHConfig parse(String conf) throws IOException { * @return an instanceof OpenSSHConfig */ public static OpenSSHConfig parseFile(String file) throws IOException { - try (BufferedReader br = - Files.newBufferedReader(Paths.get(Util.checkTilde(file)), StandardCharsets.UTF_8)) { - return new OpenSSHConfig(br); + return parseFile(file, userSshDirectory()); + } + + static OpenSSHConfig parseFile(String file, Path includeBase) throws IOException { + Path path = Paths.get(Util.checkTilde(file)); + try (BufferedReader br = Files.newBufferedReader(path, StandardCharsets.UTF_8)) { + Set activeFiles = new HashSet<>(); + activeFiles.add(path.toRealPath()); + return new OpenSSHConfig(br, includeBase, activeFiles); } } - OpenSSHConfig(BufferedReader br) throws IOException { - _parse(br); + private static Path userSshDirectory() { + return Paths.get(System.getProperty("user.home"), ".ssh"); + } + + OpenSSHConfig(BufferedReader br, Path includeBase) throws IOException { + this(br, includeBase, new HashSet<>()); + } + + private OpenSSHConfig(BufferedReader br, Path includeBase, Set activeFiles) + throws IOException { + Section global = new Section(""); + sections.add(global); + parse(br, includeBase, activeFiles, 0, global); + } + + private static final class Section { + final String host; + final Vector options = new Vector<>(); + + Section(String host) { + this.host = host; + } } - private final Hashtable> config = new Hashtable<>(); - private final Vector hosts = new Vector<>(); + private final Vector
    sections = new Vector<>(); - private void _parse(BufferedReader br) throws IOException { - String host = ""; - Vector kv = new Vector<>(); - String l = null; + private void parse(BufferedReader br, Path includeBase, Set activeFiles, int depth, + Section current) throws IOException { + String line; + while ((line = br.readLine()) != null) { + current = parseLine(line, includeBase, activeFiles, depth, current); + } + } + + private Section parseLine(String line, Path includeBase, Set activeFiles, int depth, + Section current) throws IOException { + line = line.trim(); + if (line.isEmpty() || line.startsWith("#")) { + return current; + } + String[] keyValue = line.split("[= \t]", 2); + if (keyValue.length < 2) { + return current; + } + String key = keyValue[0].trim(); + String value = keyValue[1].trim(); + if (value.startsWith("=")) { + value = value.substring(1).trim(); + } + if (key.equalsIgnoreCase("Host")) { + Section next = new Section(value); + sections.add(next); + return next; + } + if (key.equalsIgnoreCase("Include")) { + includeFiles(value, includeBase, activeFiles, depth, current); + Section next = new Section(current.host); + sections.add(next); + return next; + } + current.options.addElement(new String[] {key, value}); + return current; + } - while ((l = br.readLine()) != null) { - l = l.trim(); - if (l.length() == 0 || l.startsWith("#")) - continue; + private void includeFiles(String value, Path includeBase, Set activeFiles, int depth, + Section current) throws IOException { + for (String pattern : includeArguments(value)) { + for (Path path : expandInclude(pattern, includeBase)) { + includeFile(path, includeBase, activeFiles, depth, current.host); + } + } + } + + private void includeFile(Path path, Path includeBase, Set activeFiles, int depth, + String host) throws IOException { + if (depth >= 16) { + throw new IOException("Too many recursive configuration includes: " + path); + } + Path realPath = path.toRealPath(); + if (!activeFiles.add(realPath)) { + throw new IOException("Recursive configuration include: " + path); + } + try (BufferedReader included = Files.newBufferedReader(path, StandardCharsets.UTF_8)) { + Section includedContext = new Section(host); + sections.add(includedContext); + parse(included, includeBase, activeFiles, depth + 1, includedContext); + } finally { + activeFiles.remove(realPath); + } + } + + private static List includeArguments(String value) throws IOException { + IncludeArgumentParser parser = new IncludeArgumentParser(); + for (int i = 0; i < value.length(); i++) { + if (!parser.accept(value.charAt(i))) { + break; + } + } + return parser.finish(value); + } + + private static final class IncludeArgumentParser { + private final List paths = new ArrayList<>(); + private final StringBuilder path = new StringBuilder(); + private char quote; + private boolean escaped; + + boolean accept(char ch) { + if (escaped) { + path.append(ch); + escaped = false; + } else if (ch == '\\') { + escaped = true; + } else if (quote != 0) { + if (ch == quote) { + quote = 0; + } else { + path.append(ch); + } + } else if (ch == '"' || ch == '\'') { + quote = ch; + } else if (Character.isWhitespace(ch)) { + flush(); + } else if (ch == '#' && path.length() == 0) { + return false; + } else { + path.append(ch); + } + return true; + } + + List finish(String value) throws IOException { + if (escaped || quote != 0) { + throw new IOException("Unterminated Include path: " + value); + } + flush(); + if (paths.isEmpty()) { + throw new IOException("Include requires at least one path"); + } + return paths; + } - String[] key_value = l.split("[= \t]", 2); - for (int i = 0; i < key_value.length; i++) - key_value[i] = key_value[i].trim(); + private void flush() { + if (path.length() > 0) { + paths.add(path.toString()); + path.setLength(0); + } + } + } - if (key_value.length <= 1) - continue; + private static List expandInclude(String pattern, Path includeBase) throws IOException { + String name = expandIncludeTokens(pattern); + if (name.startsWith("~") && !name.equals("~") && !name.startsWith("~/")) { + throw new IOException("Unsupported Include home path: " + pattern); + } + if (name.equals("~") || name.startsWith("~/")) { + name = System.getProperty("user.home") + name.substring(1); + } + Path path = Paths.get(name); + if (!path.isAbsolute()) { + path = includeBase.resolve(path); + } + return matchIncludeFiles(path.toAbsolutePath().normalize(), pattern); + } - if (key_value[0].equalsIgnoreCase("Host")) { - config.put(host, kv); - hosts.addElement(host); - host = key_value[1]; - kv = new Vector<>(); + private static String expandIncludeTokens(String pattern) throws IOException { + StringBuilder expanded = new StringBuilder(); + for (int i = 0; i < pattern.length(); i++) { + char ch = pattern.charAt(i); + if (ch == '%') { + if (++i == pattern.length()) { + throw new IOException("Incomplete Include token: " + pattern); + } + expanded.append(expandPercentToken(pattern.charAt(i), pattern)); + } else if (ch == '$' && i + 1 < pattern.length() && pattern.charAt(i + 1) == '{') { + int end = pattern.indexOf('}', i + 2); + if (end < 0) { + throw new IOException("Incomplete Include environment variable: " + pattern); + } + String name = pattern.substring(i + 2, end); + expanded.append(expandEnvironmentVariable(name)); + i = end; } else { - kv.addElement(key_value); + expanded.append(ch); } } - config.put(host, kv); - hosts.addElement(host); + return expanded.toString(); + } + + private static String expandPercentToken(char token, String pattern) throws IOException { + if (token == 'd') { + return System.getProperty("user.home"); + } + if (token == '%') { + return "%"; + } + throw new IOException("Unsupported Include token %" + token + " in " + pattern); + } + + private static String expandEnvironmentVariable(String name) throws IOException { + String value = System.getenv(name); + if (value == null) { + throw new IOException("Undefined Include environment variable: " + name); + } + return value; + } + + private static List matchIncludeFiles(Path path, String pattern) throws IOException { + Path prefix = literalPrefix(path); + if (prefix.equals(path)) { + return Files.exists(path) ? Collections.singletonList(path) : Collections.emptyList(); + } + if (!Files.exists(prefix)) { + return Collections.emptyList(); + } + PathMatcher matcher; + try { + matcher = path.getFileSystem().getPathMatcher("glob:" + path); + } catch (IllegalArgumentException e) { + throw new IOException("Invalid Include pattern: " + pattern, e); + } + try (Stream candidates = Files.walk(prefix)) { + return candidates.filter(matcher::matches).filter(Files::isRegularFile) + .sorted(Comparator.comparing(Path::toString)).collect(Collectors.toList()); + } + } + + private static Path literalPrefix(Path path) { + Path prefix = path.getRoot(); + for (Path part : path) { + String segment = part.toString(); + if (segment.indexOf('*') >= 0 || segment.indexOf('?') >= 0 || segment.indexOf('[') >= 0 + || segment.indexOf('{') >= 0) { + break; + } + prefix = prefix.resolve(part); + } + return prefix; + } + + private static boolean matchesHostPatterns(String patternList, byte[] host) { + boolean positive = false; + for (String pattern : patternList.split("[ \t]")) { + boolean negate = pattern.startsWith("!"); + String candidate = negate ? pattern.substring(1) : pattern; + if (Util.glob(Util.str2byte(candidate.trim()), host)) { + if (negate) { + return false; + } + positive = true; + } + } + return positive; } @Override @@ -185,33 +416,10 @@ class MyConfig implements Config { MyConfig(String host) { this.host = host; - _configs.addElement(config.get("")); - byte[] _host = Util.str2byte(host); - if (hosts.size() > 1) { - for (int i = 1; i < hosts.size(); i++) { - boolean anyPositivePatternMatches = false; - boolean anyNegativePatternMatches = false; - String patterns[] = hosts.elementAt(i).split("[ \t]"); - for (int j = 0; j < patterns.length; j++) { - boolean negate = false; - String foo = patterns[j].trim(); - if (foo.startsWith("!")) { - negate = true; - foo = foo.substring(1).trim(); - } - if (Util.glob(Util.str2byte(foo), _host)) { - if (negate) { - anyNegativePatternMatches = true; - } else { - anyPositivePatternMatches = true; - } - } - } - - if (anyPositivePatternMatches && !anyNegativePatternMatches) { - _configs.addElement(config.get(hosts.elementAt(i))); - } + for (Section section : sections) { + if (section.host.isEmpty() || matchesHostPatterns(section.host, _host)) { + _configs.addElement(section.options); } } } diff --git a/src/test/java/com/jcraft/jsch/OpenSSHConfigTest.java b/src/test/java/com/jcraft/jsch/OpenSSHConfigTest.java index 018ea03e9..34404257a 100644 --- a/src/test/java/com/jcraft/jsch/OpenSSHConfigTest.java +++ b/src/test/java/com/jcraft/jsch/OpenSSHConfigTest.java @@ -2,20 +2,28 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import java.io.IOException; import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import java.nio.file.Paths; import java.util.Locale; import java.util.Map; import java.util.Optional; import java.util.stream.Collectors; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; class OpenSSHConfigTest { + @TempDir + Path tempDir; + Map keyMap = OpenSSHConfig.getKeymap().entrySet().stream().collect(Collectors .toMap(entry -> entry.getValue().toUpperCase(Locale.ROOT), Map.Entry::getKey, (s, s2) -> s2)); @@ -31,6 +39,73 @@ void parseFile() throws IOException, URISyntaxException { assertEquals("~/.ssh/old_keys/host2_key", config.getValue("IdentityFile")); } + @Test + void includesGlobsInLexicalOrderAndRestoresHostScope() throws IOException { + Path sshDir = Files.createDirectory(tempDir.resolve("ssh")); + Path snippets = Files.createDirectory(sshDir.resolve("snippets")); + write(snippets.resolve("20.conf"), "Host target\n User second\n"); + write(snippets.resolve("10.conf"), "User first\nHost elsewhere\n Port 2222\n"); + Path main = tempDir.resolve("config"); + write(main, "Host target\n Include snippets/*.conf\n HostName after.example\n"); + + OpenSSHConfig config = OpenSSHConfig.parseFile(main.toString(), sshDir); + assertEquals("first", config.getConfig("target").getUser()); + assertEquals("after.example", config.getConfig("target").getHostname()); + assertEquals(2222, config.getConfig("elsewhere").getPort()); + } + + @Test + void nestedIncludesAndQuotedPathsRetainFirstValue() throws IOException { + Path nested = tempDir.resolve("nested file.conf"); + write(nested, "User nested\n"); + Path middle = tempDir.resolve("middle.conf"); + write(middle, "Include \"" + nested + "\"\nUser middle\n"); + Path main = tempDir.resolve("config"); + write(main, "Host target\n Include " + middle + "\n User outer\n"); + + OpenSSHConfig config = OpenSSHConfig.parseFile(main.toString()); + assertEquals("nested", config.getConfig("target").getUser()); + } + + @Test + void includeAcceptsMultiplePathsOnOneLine() throws IOException { + Path first = tempDir.resolve("first.conf"); + Path second = tempDir.resolve("second.conf"); + write(first, "Host target\n User from-first\n"); + write(second, "Host target\n Port 2200\n"); + Path main = tempDir.resolve("config"); + write(main, "Include = " + first + " " + second + "\n"); + + OpenSSHConfig config = OpenSSHConfig.parseFile(main.toString()); + assertEquals("from-first", config.getConfig("target").getUser()); + assertEquals(2200, config.getConfig("target").getPort()); + } + + @Test + void unsupportedIncludeTokenIsRejected() throws IOException { + Path main = tempDir.resolve("config"); + write(main, "Include %h/something.conf\n"); + assertThrows(IOException.class, () -> OpenSSHConfig.parseFile(main.toString())); + } + + @Test + void repeatedHostBlocksDoNotOverwriteEachOther() throws IOException { + OpenSSHConfig config = OpenSSHConfig + .parse("Host target\n User first\nHost target\n HostName destination\n User second\n"); + assertEquals("first", config.getConfig("target").getUser()); + assertEquals("destination", config.getConfig("target").getHostname()); + } + + @Test + void missingIncludeIsIgnoredAndCycleIsRejected() throws IOException { + Path main = tempDir.resolve("config"); + write(main, "Include missing/*.conf\nHost target\n User someone\n"); + assertEquals("someone", OpenSSHConfig.parseFile(main.toString()).getConfig("target").getUser()); + + write(main, "Include " + main + "\n"); + assertThrows(IOException.class, () -> OpenSSHConfig.parseFile(main.toString())); + } + @ParameterizedTest @ValueSource(strings = {"MACs", "Macs"}) void parseMacsCaseInsensitive(String key) throws IOException { @@ -117,4 +192,8 @@ private void assertUserEquals(OpenSSHConfig openSSHConfig, String host, String e assertEquals(expected, actual, String.format(Locale.ROOT, "Expected user for host %s to be %s, but was %s", host, expected, actual)); } + + private static void write(Path path, String value) throws IOException { + Files.write(path, value.getBytes(StandardCharsets.UTF_8)); + } } From cec5d11158daab3f720043addd58232c6ecfa547 Mon Sep 17 00:00:00 2001 From: Adam Kalisz Date: Fri, 25 Sep 2026 03:46:50 +0200 Subject: [PATCH 2/7] Preserve Include host scope and tighten path expansion --- .../java/com/jcraft/jsch/OpenSSHConfig.java | 78 ++++++++++++++----- .../com/jcraft/jsch/OpenSSHConfigTest.java | 53 ++++++++++++- 2 files changed, 110 insertions(+), 21 deletions(-) diff --git a/src/main/java/com/jcraft/jsch/OpenSSHConfig.java b/src/main/java/com/jcraft/jsch/OpenSSHConfig.java index caabe3d72..bcf9ebf9f 100644 --- a/src/main/java/com/jcraft/jsch/OpenSSHConfig.java +++ b/src/main/java/com/jcraft/jsch/OpenSSHConfig.java @@ -28,9 +28,11 @@ import java.io.BufferedReader; import java.io.IOException; +import java.io.UncheckedIOException; import java.io.Reader; import java.io.StringReader; import java.nio.charset.StandardCharsets; +import java.nio.file.FileVisitOption; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.PathMatcher; @@ -133,32 +135,34 @@ private static Path userSshDirectory() { private OpenSSHConfig(BufferedReader br, Path includeBase, Set activeFiles) throws IOException { - Section global = new Section(""); + Section global = new Section("", Collections.emptyList()); sections.add(global); - parse(br, includeBase, activeFiles, 0, global); + parse(br, includeBase, activeFiles, 0, global, Collections.emptyList()); } private static final class Section { final String host; + final List enclosingHosts; final Vector options = new Vector<>(); - Section(String host) { + Section(String host, List enclosingHosts) { this.host = host; + this.enclosingHosts = enclosingHosts; } } private final Vector
    sections = new Vector<>(); private void parse(BufferedReader br, Path includeBase, Set activeFiles, int depth, - Section current) throws IOException { + Section current, List enclosingHosts) throws IOException { String line; while ((line = br.readLine()) != null) { - current = parseLine(line, includeBase, activeFiles, depth, current); + current = parseLine(line, includeBase, activeFiles, depth, current, enclosingHosts); } } private Section parseLine(String line, Path includeBase, Set activeFiles, int depth, - Section current) throws IOException { + Section current, List enclosingHosts) throws IOException { line = line.trim(); if (line.isEmpty() || line.startsWith("#")) { return current; @@ -173,13 +177,16 @@ private Section parseLine(String line, Path includeBase, Set activeFiles, value = value.substring(1).trim(); } if (key.equalsIgnoreCase("Host")) { - Section next = new Section(value); + if (value.isEmpty()) { + throw new IOException("Host requires at least one pattern"); + } + Section next = new Section(value, enclosingHosts); sections.add(next); return next; } if (key.equalsIgnoreCase("Include")) { includeFiles(value, includeBase, activeFiles, depth, current); - Section next = new Section(current.host); + Section next = new Section(current.host, enclosingHosts); sections.add(next); return next; } @@ -189,15 +196,19 @@ private Section parseLine(String line, Path includeBase, Set activeFiles, private void includeFiles(String value, Path includeBase, Set activeFiles, int depth, Section current) throws IOException { + List enclosingHosts = new ArrayList<>(current.enclosingHosts); + if (!current.host.isEmpty()) { + enclosingHosts.add(current.host); + } for (String pattern : includeArguments(value)) { for (Path path : expandInclude(pattern, includeBase)) { - includeFile(path, includeBase, activeFiles, depth, current.host); + includeFile(path, includeBase, activeFiles, depth, enclosingHosts); } } } private void includeFile(Path path, Path includeBase, Set activeFiles, int depth, - String host) throws IOException { + List enclosingHosts) throws IOException { if (depth >= 16) { throw new IOException("Too many recursive configuration includes: " + path); } @@ -206,9 +217,9 @@ private void includeFile(Path path, Path includeBase, Set activeFiles, int throw new IOException("Recursive configuration include: " + path); } try (BufferedReader included = Files.newBufferedReader(path, StandardCharsets.UTF_8)) { - Section includedContext = new Section(host); + Section includedContext = new Section("", enclosingHosts); sections.add(includedContext); - parse(included, includeBase, activeFiles, depth + 1, includedContext); + parse(included, includeBase, activeFiles, depth + 1, includedContext, enclosingHosts); } finally { activeFiles.remove(realPath); } @@ -217,7 +228,7 @@ private void includeFile(Path path, Path includeBase, Set activeFiles, int private static List includeArguments(String value) throws IOException { IncludeArgumentParser parser = new IncludeArgumentParser(); for (int i = 0; i < value.length(); i++) { - if (!parser.accept(value.charAt(i))) { + if (!parser.accept(value.charAt(i), i + 1 < value.length() ? value.charAt(i + 1) : 0)) { break; } } @@ -230,12 +241,16 @@ private static final class IncludeArgumentParser { private char quote; private boolean escaped; - boolean accept(char ch) { + boolean accept(char ch, char next) { if (escaped) { path.append(ch); escaped = false; } else if (ch == '\\') { - escaped = true; + if (next == '\\' || next == '"' || next == '\'' || (quote == 0 && next == ' ')) { + escaped = true; + } else { + path.append(ch); + } } else if (quote != 0) { if (ch == quote) { quote = 0; @@ -255,7 +270,7 @@ boolean accept(char ch) { } List finish(String value) throws IOException { - if (escaped || quote != 0) { + if (quote != 0) { throw new IOException("Unterminated Include path: " + value); } flush(); @@ -340,16 +355,35 @@ private static List matchIncludeFiles(Path path, String pattern) throws IO } PathMatcher matcher; try { - matcher = path.getFileSystem().getPathMatcher("glob:" + path); + String glob = path.toString().replace('\\', '/').replace("{", "\\{").replace("}", "\\}"); + matcher = path.getFileSystem().getPathMatcher("glob:" + glob); } catch (IllegalArgumentException e) { throw new IOException("Invalid Include pattern: " + pattern, e); } - try (Stream candidates = Files.walk(prefix)) { - return candidates.filter(matcher::matches).filter(Files::isRegularFile) + int depth = path.getNameCount() - prefix.getNameCount(); + try (Stream candidates = Files.walk(prefix, depth, FileVisitOption.FOLLOW_LINKS)) { + return candidates.filter(candidate -> matchesIncludeGlob(path, prefix, candidate, matcher)) + .filter(Files::isRegularFile) .sorted(Comparator.comparing(Path::toString)).collect(Collectors.toList()); + } catch (UncheckedIOException e) { + throw e.getCause(); } } + private static boolean matchesIncludeGlob(Path pattern, Path prefix, Path candidate, + PathMatcher matcher) { + if (!matcher.matches(candidate)) { + return false; + } + for (int i = prefix.getNameCount(); i < candidate.getNameCount(); i++) { + if (candidate.getName(i).toString().startsWith(".") + && !pattern.getName(i).toString().startsWith(".")) { + return false; + } + } + return true; + } + private static Path literalPrefix(Path path) { Path prefix = path.getRoot(); for (Path part : path) { @@ -418,7 +452,11 @@ class MyConfig implements Config { byte[] _host = Util.str2byte(host); for (Section section : sections) { - if (section.host.isEmpty() || matchesHostPatterns(section.host, _host)) { + boolean matches = section.host.isEmpty() || matchesHostPatterns(section.host, _host); + for (String enclosingHost : section.enclosingHosts) { + matches &= matchesHostPatterns(enclosingHost, _host); + } + if (matches) { _configs.addElement(section.options); } } diff --git a/src/test/java/com/jcraft/jsch/OpenSSHConfigTest.java b/src/test/java/com/jcraft/jsch/OpenSSHConfigTest.java index 34404257a..fb3e00353 100644 --- a/src/test/java/com/jcraft/jsch/OpenSSHConfigTest.java +++ b/src/test/java/com/jcraft/jsch/OpenSSHConfigTest.java @@ -51,7 +51,58 @@ void includesGlobsInLexicalOrderAndRestoresHostScope() throws IOException { OpenSSHConfig config = OpenSSHConfig.parseFile(main.toString(), sshDir); assertEquals("first", config.getConfig("target").getUser()); assertEquals("after.example", config.getConfig("target").getHostname()); - assertEquals(2222, config.getConfig("elsewhere").getPort()); + assertEquals(-1, config.getConfig("elsewhere").getPort()); + } + + @Test + void nestedIncludeCannotEscapeEnclosingHost() throws IOException { + Path nested = tempDir.resolve("nested.conf"); + Path middle = tempDir.resolve("middle.conf"); + write(nested, "Host other\n Port 2222\nHost target\n User nested\n"); + write(middle, "Include " + nested + "\n"); + Path main = tempDir.resolve("config"); + write(main, "Host target\n Include " + middle + "\n"); + + OpenSSHConfig config = OpenSSHConfig.parseFile(main.toString()); + assertEquals(-1, config.getConfig("other").getPort()); + assertEquals("nested", config.getConfig("target").getUser()); + } + + @Test + void includeGlobsSkipDotfiles() throws IOException { + Path snippets = Files.createDirectory(tempDir.resolve("snippets")); + write(snippets.resolve(".hidden.conf"), "User hidden\n"); + write(snippets.resolve("visible.conf"), "User visible\n"); + Path main = tempDir.resolve("config"); + write(main, "Include " + snippets + "/*.conf\n"); + assertEquals("visible", OpenSSHConfig.parseFile(main.toString()).getConfig("any").getUser()); + } + + @Test + void includeExpandsHomeToken() throws IOException { + Path main = tempDir.resolve("config"); + Path included = tempDir.resolve("included.conf"); + String originalHome = System.getProperty("user.home"); + try { + System.setProperty("user.home", tempDir.toString()); + write(included, "Host target\n User from-home\n"); + write(main, "Include %d/" + included.getFileName() + "\n"); + assertEquals("from-home", OpenSSHConfig.parseFile(main.toString()).getConfig("target").getUser()); + } finally { + System.setProperty("user.home", originalHome); + } + } + + @Test + void includeArgumentPreservesUnrecognizedBackslashEscapes() throws IOException { + Path main = tempDir.resolve("config"); + write(main, "Include " + tempDir + "/missing\\name.conf\nHost target\n User someone\n"); + assertEquals("someone", OpenSSHConfig.parseFile(main.toString()).getConfig("target").getUser()); + } + + @Test + void emptyHostPatternIsRejected() { + assertThrows(IOException.class, () -> OpenSSHConfig.parse("Host =\n User someone\n")); } @Test From 30aba1e607f14dddf378a97cb62d77bf003c8e7a Mon Sep 17 00:00:00 2001 From: Adam Kalisz Date: Fri, 25 Sep 2026 03:55:55 +0200 Subject: [PATCH 3/7] Keep Include token expansion out of parser branch --- .../java/com/jcraft/jsch/OpenSSHConfig.java | 44 +------------------ .../com/jcraft/jsch/OpenSSHConfigTest.java | 23 ++++------ 2 files changed, 10 insertions(+), 57 deletions(-) diff --git a/src/main/java/com/jcraft/jsch/OpenSSHConfig.java b/src/main/java/com/jcraft/jsch/OpenSSHConfig.java index bcf9ebf9f..39813aff4 100644 --- a/src/main/java/com/jcraft/jsch/OpenSSHConfig.java +++ b/src/main/java/com/jcraft/jsch/OpenSSHConfig.java @@ -289,7 +289,7 @@ private void flush() { } private static List expandInclude(String pattern, Path includeBase) throws IOException { - String name = expandIncludeTokens(pattern); + String name = pattern; if (name.startsWith("~") && !name.equals("~") && !name.startsWith("~/")) { throw new IOException("Unsupported Include home path: " + pattern); } @@ -303,48 +303,6 @@ private static List expandInclude(String pattern, Path includeBase) throws return matchIncludeFiles(path.toAbsolutePath().normalize(), pattern); } - private static String expandIncludeTokens(String pattern) throws IOException { - StringBuilder expanded = new StringBuilder(); - for (int i = 0; i < pattern.length(); i++) { - char ch = pattern.charAt(i); - if (ch == '%') { - if (++i == pattern.length()) { - throw new IOException("Incomplete Include token: " + pattern); - } - expanded.append(expandPercentToken(pattern.charAt(i), pattern)); - } else if (ch == '$' && i + 1 < pattern.length() && pattern.charAt(i + 1) == '{') { - int end = pattern.indexOf('}', i + 2); - if (end < 0) { - throw new IOException("Incomplete Include environment variable: " + pattern); - } - String name = pattern.substring(i + 2, end); - expanded.append(expandEnvironmentVariable(name)); - i = end; - } else { - expanded.append(ch); - } - } - return expanded.toString(); - } - - private static String expandPercentToken(char token, String pattern) throws IOException { - if (token == 'd') { - return System.getProperty("user.home"); - } - if (token == '%') { - return "%"; - } - throw new IOException("Unsupported Include token %" + token + " in " + pattern); - } - - private static String expandEnvironmentVariable(String name) throws IOException { - String value = System.getenv(name); - if (value == null) { - throw new IOException("Undefined Include environment variable: " + name); - } - return value; - } - private static List matchIncludeFiles(Path path, String pattern) throws IOException { Path prefix = literalPrefix(path); if (prefix.equals(path)) { diff --git a/src/test/java/com/jcraft/jsch/OpenSSHConfigTest.java b/src/test/java/com/jcraft/jsch/OpenSSHConfigTest.java index fb3e00353..d72c7c55c 100644 --- a/src/test/java/com/jcraft/jsch/OpenSSHConfigTest.java +++ b/src/test/java/com/jcraft/jsch/OpenSSHConfigTest.java @@ -79,18 +79,13 @@ void includeGlobsSkipDotfiles() throws IOException { } @Test - void includeExpandsHomeToken() throws IOException { + void includeDoesNotExpandPercentTokens() throws IOException { Path main = tempDir.resolve("config"); - Path included = tempDir.resolve("included.conf"); - String originalHome = System.getProperty("user.home"); - try { - System.setProperty("user.home", tempDir.toString()); - write(included, "Host target\n User from-home\n"); - write(main, "Include %d/" + included.getFileName() + "\n"); - assertEquals("from-home", OpenSSHConfig.parseFile(main.toString()).getConfig("target").getUser()); - } finally { - System.setProperty("user.home", originalHome); - } + Path included = tempDir.resolve("%d.conf"); + write(included, "Host target\n User from-literal\n"); + write(main, "Include %d.conf\n"); + assertEquals("from-literal", + OpenSSHConfig.parseFile(main.toString(), tempDir).getConfig("target").getUser()); } @Test @@ -133,10 +128,10 @@ void includeAcceptsMultiplePathsOnOneLine() throws IOException { } @Test - void unsupportedIncludeTokenIsRejected() throws IOException { + void unresolvedIncludeTokenIsNotAnError() throws IOException { Path main = tempDir.resolve("config"); - write(main, "Include %h/something.conf\n"); - assertThrows(IOException.class, () -> OpenSSHConfig.parseFile(main.toString())); + write(main, "Include %h/something.conf\nHost target\n User someone\n"); + assertEquals("someone", OpenSSHConfig.parseFile(main.toString()).getConfig("target").getUser()); } @Test From c68d0098100b1e9d3faac27c9baac33c572dcb2a Mon Sep 17 00:00:00 2001 From: Adam Kalisz Date: Fri, 25 Sep 2026 04:34:33 +0200 Subject: [PATCH 4/7] Evaluate Match safely and expand Include globs per segment --- .../com/jcraft/jsch/ConfigRepository.java | 4 + .../java/com/jcraft/jsch/OpenSSHConfig.java | 303 ++++++++++++++---- src/main/java/com/jcraft/jsch/Session.java | 6 +- .../com/jcraft/jsch/OpenSSHConfigTest.java | 41 +++ 4 files changed, 291 insertions(+), 63 deletions(-) diff --git a/src/main/java/com/jcraft/jsch/ConfigRepository.java b/src/main/java/com/jcraft/jsch/ConfigRepository.java index b516d9085..4bda26a11 100644 --- a/src/main/java/com/jcraft/jsch/ConfigRepository.java +++ b/src/main/java/com/jcraft/jsch/ConfigRepository.java @@ -30,6 +30,10 @@ public interface ConfigRepository { public Config getConfig(String host); + default Config getConfig(String host, String user) { + return getConfig(host); + } + public interface Config { public String getHostname(); diff --git a/src/main/java/com/jcraft/jsch/OpenSSHConfig.java b/src/main/java/com/jcraft/jsch/OpenSSHConfig.java index 39813aff4..9d76b3234 100644 --- a/src/main/java/com/jcraft/jsch/OpenSSHConfig.java +++ b/src/main/java/com/jcraft/jsch/OpenSSHConfig.java @@ -28,12 +28,12 @@ import java.io.BufferedReader; import java.io.IOException; -import java.io.UncheckedIOException; import java.io.Reader; import java.io.StringReader; import java.nio.charset.StandardCharsets; -import java.nio.file.FileVisitOption; +import java.nio.file.DirectoryStream; import java.nio.file.Files; +import java.nio.file.InvalidPathException; import java.nio.file.Path; import java.nio.file.PathMatcher; import java.nio.file.Paths; @@ -56,6 +56,7 @@ * *
      *
    • Host
    • + *
    • Match (all, host, originalhost, user, localuser)
    • *
    • User
    • *
    • Hostname
    • *
    • Port
    • @@ -95,6 +96,11 @@ public class OpenSSHConfig implements ConfigRepository { /** * Parses the given string, and returns an instance of ConfigRepository. * + * Include directives in {@code conf} may read additional files. Only parse trusted config text. + * JSch does not impose OpenSSH's file owner/mode checks because embedded applications may use + * application-managed files or filesystems without POSIX permissions; callers must enforce + * their own trust policy. + * * @param conf string, which includes OpenSSH's config * @return an instanceof OpenSSHConfig */ @@ -108,6 +114,8 @@ public static OpenSSHConfig parse(String conf) throws IOException { /** * Parses the given file, and returns an instance of ConfigRepository. + * Included files are read without owner/mode checks; callers choose which config files are + * trusted, including on platforms without POSIX ownership metadata. * * @param file OpenSSH's config file * @return an instanceof OpenSSHConfig @@ -135,34 +143,43 @@ private static Path userSshDirectory() { private OpenSSHConfig(BufferedReader br, Path includeBase, Set activeFiles) throws IOException { - Section global = new Section("", Collections.emptyList()); + Section global = new Section("", null, Collections.emptyList(), Collections.emptyList()); sections.add(global); - parse(br, includeBase, activeFiles, 0, global, Collections.emptyList()); + parse(br, includeBase, activeFiles, 0, global, Collections.emptyList(), + Collections.emptyList()); } private static final class Section { final String host; + final MatchExpression match; final List enclosingHosts; + final List enclosingMatches; final Vector options = new Vector<>(); - Section(String host, List enclosingHosts) { + Section(String host, MatchExpression match, List enclosingHosts, + List enclosingMatches) { this.host = host; + this.match = match; this.enclosingHosts = enclosingHosts; + this.enclosingMatches = enclosingMatches; } } private final Vector
      sections = new Vector<>(); private void parse(BufferedReader br, Path includeBase, Set activeFiles, int depth, - Section current, List enclosingHosts) throws IOException { + Section current, List enclosingHosts, List enclosingMatches) + throws IOException { String line; while ((line = br.readLine()) != null) { - current = parseLine(line, includeBase, activeFiles, depth, current, enclosingHosts); + current = parseLine(line, includeBase, activeFiles, depth, current, enclosingHosts, + enclosingMatches); } } private Section parseLine(String line, Path includeBase, Set activeFiles, int depth, - Section current, List enclosingHosts) throws IOException { + Section current, List enclosingHosts, List enclosingMatches) + throws IOException { line = line.trim(); if (line.isEmpty() || line.startsWith("#")) { return current; @@ -180,13 +197,18 @@ private Section parseLine(String line, Path includeBase, Set activeFiles, if (value.isEmpty()) { throw new IOException("Host requires at least one pattern"); } - Section next = new Section(value, enclosingHosts); + Section next = new Section(value, null, enclosingHosts, enclosingMatches); + sections.add(next); + return next; + } + if (key.equalsIgnoreCase("Match")) { + Section next = new Section("", parseMatch(value), enclosingHosts, enclosingMatches); sections.add(next); return next; } if (key.equalsIgnoreCase("Include")) { includeFiles(value, includeBase, activeFiles, depth, current); - Section next = new Section(current.host, enclosingHosts); + Section next = new Section(current.host, current.match, enclosingHosts, enclosingMatches); sections.add(next); return next; } @@ -197,18 +219,22 @@ private Section parseLine(String line, Path includeBase, Set activeFiles, private void includeFiles(String value, Path includeBase, Set activeFiles, int depth, Section current) throws IOException { List enclosingHosts = new ArrayList<>(current.enclosingHosts); + List enclosingMatches = new ArrayList<>(current.enclosingMatches); if (!current.host.isEmpty()) { enclosingHosts.add(current.host); } + if (current.match != null) { + enclosingMatches.add(current.match); + } for (String pattern : includeArguments(value)) { for (Path path : expandInclude(pattern, includeBase)) { - includeFile(path, includeBase, activeFiles, depth, enclosingHosts); + includeFile(path, includeBase, activeFiles, depth, enclosingHosts, enclosingMatches); } } } private void includeFile(Path path, Path includeBase, Set activeFiles, int depth, - List enclosingHosts) throws IOException { + List enclosingHosts, List enclosingMatches) throws IOException { if (depth >= 16) { throw new IOException("Too many recursive configuration includes: " + path); } @@ -217,9 +243,10 @@ private void includeFile(Path path, Path includeBase, Set activeFiles, int throw new IOException("Recursive configuration include: " + path); } try (BufferedReader included = Files.newBufferedReader(path, StandardCharsets.UTF_8)) { - Section includedContext = new Section("", enclosingHosts); + Section includedContext = new Section("", null, enclosingHosts, enclosingMatches); sections.add(includedContext); - parse(included, includeBase, activeFiles, depth + 1, includedContext, enclosingHosts); + parse(included, includeBase, activeFiles, depth + 1, includedContext, enclosingHosts, + enclosingMatches); } finally { activeFiles.remove(realPath); } @@ -235,6 +262,95 @@ private static List includeArguments(String value) throws IOException { return parser.finish(value); } + private static MatchExpression parseMatch(String value) throws IOException { + List arguments = includeArguments(value); + List criteria = new ArrayList<>(); + for (int i = 0; i < arguments.size(); i++) { + String attribute = arguments.get(i); + boolean negated = attribute.startsWith("!"); + if (negated) { + attribute = attribute.substring(1); + } + int equals = attribute.indexOf('='); + String pattern = equals < 0 ? null : attribute.substring(equals + 1); + String type = (equals < 0 ? attribute : attribute.substring(0, equals)) + .toLowerCase(Locale.ROOT); + if (type.equals("all")) { + if (arguments.size() != 1) { + throw new IOException("Match all cannot be combined with other criteria"); + } + criteria.add(new MatchCriterion(type, null, negated)); + continue; + } + if (!type.equals("host") && !type.equals("originalhost") && !type.equals("user") + && !type.equals("localuser")) { + throw new IOException("Unsupported Match criterion: " + type); + } + if (pattern == null && ++i < arguments.size()) { + pattern = arguments.get(i); + } + if (pattern == null || pattern.isEmpty()) { + throw new IOException("Missing Match pattern for " + type); + } + criteria.add(new MatchCriterion(type, pattern, negated)); + } + return new MatchExpression(criteria); + } + + private static final class MatchCriterion { + final String type; + final String pattern; + final boolean negated; + + MatchCriterion(String type, String pattern, boolean negated) { + this.type = type; + this.pattern = pattern; + this.negated = negated; + } + + boolean matches(String originalHost, String effectiveHost, String remoteUser) { + String candidate; + switch (type) { + case "all": + return !negated; + case "host": + candidate = effectiveHost; + break; + case "originalhost": + candidate = originalHost; + break; + case "user": + candidate = remoteUser; + break; + case "localuser": + candidate = System.getProperty("user.name"); + break; + default: + return false; + } + boolean matched = candidate != null + && matchesHostPatterns(pattern, Util.str2byte(candidate)); + return negated ? !matched : matched; + } + } + + private static final class MatchExpression { + final List criteria; + + MatchExpression(List criteria) { + this.criteria = criteria; + } + + boolean matches(String originalHost, String effectiveHost, String remoteUser) { + for (MatchCriterion criterion : criteria) { + if (!criterion.matches(originalHost, effectiveHost, remoteUser)) { + return false; + } + } + return true; + } + } + private static final class IncludeArgumentParser { private final List paths = new ArrayList<>(); private final StringBuilder path = new StringBuilder(); @@ -296,63 +412,106 @@ private static List expandInclude(String pattern, Path includeBase) throws if (name.equals("~") || name.startsWith("~/")) { name = System.getProperty("user.home") + name.substring(1); } - Path path = Paths.get(name); - if (!path.isAbsolute()) { - path = includeBase.resolve(path); + try { + return matchIncludeFiles(name, includeBase); + } catch (InvalidPathException e) { + throw new IOException("Invalid Include path: " + pattern, e); } - return matchIncludeFiles(path.toAbsolutePath().normalize(), pattern); } - private static List matchIncludeFiles(Path path, String pattern) throws IOException { - Path prefix = literalPrefix(path); - if (prefix.equals(path)) { - return Files.exists(path) ? Collections.singletonList(path) : Collections.emptyList(); - } - if (!Files.exists(prefix)) { - return Collections.emptyList(); - } - PathMatcher matcher; - try { - String glob = path.toString().replace('\\', '/').replace("{", "\\{").replace("}", "\\}"); - matcher = path.getFileSystem().getPathMatcher("glob:" + glob); - } catch (IllegalArgumentException e) { - throw new IOException("Invalid Include pattern: " + pattern, e); - } - int depth = path.getNameCount() - prefix.getNameCount(); - try (Stream candidates = Files.walk(prefix, depth, FileVisitOption.FOLLOW_LINKS)) { - return candidates.filter(candidate -> matchesIncludeGlob(path, prefix, candidate, matcher)) - .filter(Files::isRegularFile) - .sorted(Comparator.comparing(Path::toString)).collect(Collectors.toList()); - } catch (UncheckedIOException e) { - throw e.getCause(); + private static List matchIncludeFiles(String pattern, Path includeBase) throws IOException { + int wildcard = firstWildcard(pattern); + if (wildcard < 0) { + Path path = Paths.get(pattern); + if (!path.isAbsolute()) { + path = includeBase.resolve(path); + } + return Files.isRegularFile(path) ? Collections.singletonList(path) : Collections.emptyList(); + } + + int separator = lastSeparatorBefore(pattern, wildcard); + Path prefix = separator < 0 ? includeBase : Paths.get(pattern.substring(0, separator + 1)); + if (!prefix.isAbsolute()) { + prefix = includeBase.resolve(prefix); + } + List candidates = new ArrayList<>(); + candidates.add(prefix); + for (String segment : splitSegments(pattern.substring(separator + 1))) { + List next = new ArrayList<>(); + if (firstWildcard(segment) < 0) { + for (Path directory : candidates) { + Path path = directory.resolve(segment); + if (Files.exists(path)) { + next.add(path); + } + } + } else { + PathMatcher matcher; + try { + matcher = prefix.getFileSystem().getPathMatcher("glob:" + + segment.replace("{", "\\{").replace("}", "\\}")); + } catch (IllegalArgumentException e) { + throw new IOException("Invalid Include pattern: " + pattern, e); + } + for (Path directory : candidates) { + if (!Files.isDirectory(directory)) { + continue; + } + try (DirectoryStream entries = Files.newDirectoryStream(directory)) { + for (Path entry : entries) { + String filename = entry.getFileName().toString(); + if ((segment.startsWith(".") || !filename.startsWith(".")) + && matcher.matches(entry.getFileName())) { + next.add(entry); + } + } + } catch (IOException e) { + // An unreadable branch does not prevent other glob matches. + } + } + } + candidates = next; + if (candidates.isEmpty()) { + break; + } } + return candidates.stream().filter(Files::isRegularFile) + .sorted(Comparator.comparing(Path::toString)).collect(Collectors.toList()); } - private static boolean matchesIncludeGlob(Path pattern, Path prefix, Path candidate, - PathMatcher matcher) { - if (!matcher.matches(candidate)) { - return false; + private static int firstWildcard(String value) { + for (int i = 0; i < value.length(); i++) { + char ch = value.charAt(i); + if (ch == '*' || ch == '?' || ch == '[') { + return i; + } } - for (int i = prefix.getNameCount(); i < candidate.getNameCount(); i++) { - if (candidate.getName(i).toString().startsWith(".") - && !pattern.getName(i).toString().startsWith(".")) { - return false; + return -1; + } + + private static int lastSeparatorBefore(String value, int limit) { + for (int i = limit - 1; i >= 0; i--) { + char ch = value.charAt(i); + if (ch == '/' || (java.io.File.separatorChar == '\\' && ch == '\\')) { + return i; } } - return true; + return -1; } - private static Path literalPrefix(Path path) { - Path prefix = path.getRoot(); - for (Path part : path) { - String segment = part.toString(); - if (segment.indexOf('*') >= 0 || segment.indexOf('?') >= 0 || segment.indexOf('[') >= 0 - || segment.indexOf('{') >= 0) { - break; + private static List splitSegments(String pattern) { + List segments = new ArrayList<>(); + int start = 0; + for (int i = 0; i <= pattern.length(); i++) { + if (i == pattern.length() || pattern.charAt(i) == '/' + || (java.io.File.separatorChar == '\\' && pattern.charAt(i) == '\\')) { + if (i > start) { + segments.add(pattern.substring(start, i)); + } + start = i + 1; } - prefix = prefix.resolve(part); } - return prefix; + return segments; } private static boolean matchesHostPatterns(String patternList, byte[] host) { @@ -372,7 +531,12 @@ private static boolean matchesHostPatterns(String patternList, byte[] host) { @Override public Config getConfig(String host) { - return new MyConfig(host); + return new MyConfig(host, null); + } + + @Override + public Config getConfig(String host, String user) { + return new MyConfig(host, user); } /** @@ -405,17 +569,36 @@ class MyConfig implements Config { private String host; private Vector> _configs = new Vector<>(); - MyConfig(String host) { + MyConfig(String host, String user) { this.host = host; byte[] _host = Util.str2byte(host); + String effectiveHost = host; + String remoteUser = user != null ? user : System.getProperty("user.name"); + boolean hostnameSet = false; + boolean userSet = user != null; for (Section section : sections) { boolean matches = section.host.isEmpty() || matchesHostPatterns(section.host, _host); for (String enclosingHost : section.enclosingHosts) { matches &= matchesHostPatterns(enclosingHost, _host); } + if (section.match != null) { + matches &= section.match.matches(host, effectiveHost, remoteUser); + } + for (MatchExpression enclosingMatch : section.enclosingMatches) { + matches &= enclosingMatch.matches(host, effectiveHost, remoteUser); + } if (matches) { _configs.addElement(section.options); + for (String[] option : section.options) { + if (!hostnameSet && option[0].equalsIgnoreCase("HostName")) { + effectiveHost = option[1].replace("%h", host); + hostnameSet = true; + } else if (!userSet && option[0].equalsIgnoreCase("User")) { + remoteUser = option[1]; + userSet = true; + } + } } } } diff --git a/src/main/java/com/jcraft/jsch/Session.java b/src/main/java/com/jcraft/jsch/Session.java index 65dacac5e..36230ce42 100644 --- a/src/main/java/com/jcraft/jsch/Session.java +++ b/src/main/java/com/jcraft/jsch/Session.java @@ -3647,7 +3647,7 @@ private void applyConfig() throws JSchException { return; } - ConfigRepository.Config config = configRepository.getConfig(org_host); + ConfigRepository.Config config = configRepository.getConfig(org_host, username); String value = null; @@ -3767,7 +3767,7 @@ private void applyConfigChannel(ChannelSession channel) throws JSchException { return; } - ConfigRepository.Config config = configRepository.getConfig(org_host); + ConfigRepository.Config config = configRepository.getConfig(org_host, username); String value = null; @@ -3792,7 +3792,7 @@ private void requestPortForwarding() throws JSchException { return; } - ConfigRepository.Config config = configRepository.getConfig(org_host); + ConfigRepository.Config config = configRepository.getConfig(org_host, username); String[] values = config.getValues("LocalForward"); if (values != null) { diff --git a/src/test/java/com/jcraft/jsch/OpenSSHConfigTest.java b/src/test/java/com/jcraft/jsch/OpenSSHConfigTest.java index d72c7c55c..88182997f 100644 --- a/src/test/java/com/jcraft/jsch/OpenSSHConfigTest.java +++ b/src/test/java/com/jcraft/jsch/OpenSSHConfigTest.java @@ -68,6 +68,47 @@ void nestedIncludeCannotEscapeEnclosingHost() throws IOException { assertEquals("nested", config.getConfig("target").getUser()); } + @Test + void matchConditionsUseEffectiveHostAndRemoteUser() throws IOException { + OpenSSHConfig config = OpenSSHConfig.parse("Host alias\n HostName real.example\n" + + "Match host real.example user deploy\n Port 2222\n" + + "Match originalhost alias localuser " + System.getProperty("user.name") + + "\n User matched\nMatch all\n ForwardAgent yes\n"); + + assertEquals(2222, config.getConfig("alias", "deploy").getPort()); + assertEquals(-1, config.getConfig("alias", "other").getPort()); + assertEquals("matched", config.getConfig("alias").getUser()); + assertEquals("yes", config.getConfig("elsewhere").getValue("ForwardAgent")); + } + + @Test + void sessionPassesExplicitUserToMatch() throws Exception { + JSch jsch = new JSch(); + jsch.setConfigRepository( + OpenSSHConfig.parse("Match user deploy\n Port 2222\nMatch all\n User configured\n")); + + assertEquals(2222, jsch.getSession("deploy", "example.com").getPort()); + assertEquals(22, jsch.getSession("other", "example.com").getPort()); + } + + @Test + void includedMatchCannotEscapeEnclosingHost() throws IOException { + Path included = tempDir.resolve("child.conf"); + write(included, "Match all\n Port 2222\n"); + Path main = tempDir.resolve("config"); + write(main, "Host alias\n Include " + included + "\n"); + + OpenSSHConfig config = OpenSSHConfig.parseFile(main.toString()); + assertEquals(2222, config.getConfig("alias").getPort()); + assertEquals(-1, config.getConfig("elsewhere").getPort()); + } + + @Test + void unsupportedMatchConditionFailsClosed() { + assertThrows(IOException.class, + () -> OpenSSHConfig.parse("Match exec true\n StrictHostKeyChecking no\n")); + } + @Test void includeGlobsSkipDotfiles() throws IOException { Path snippets = Files.createDirectory(tempDir.resolve("snippets")); From 231fe286ec01d081e4a60716fed3c6566c0f1aa3 Mon Sep 17 00:00:00 2001 From: Adam Kalisz Date: Fri, 25 Sep 2026 06:08:08 +0200 Subject: [PATCH 5/7] Keep Match decisions stable and parse pattern lists like OpenSSH --- .../java/com/jcraft/jsch/OpenSSHConfig.java | 32 ++++++++++-- src/main/java/com/jcraft/jsch/Session.java | 14 +++--- .../com/jcraft/jsch/OpenSSHConfigTest.java | 50 +++++++++++++++++++ 3 files changed, 85 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/jcraft/jsch/OpenSSHConfig.java b/src/main/java/com/jcraft/jsch/OpenSSHConfig.java index 9d76b3234..d971315a5 100644 --- a/src/main/java/com/jcraft/jsch/OpenSSHConfig.java +++ b/src/main/java/com/jcraft/jsch/OpenSSHConfig.java @@ -43,8 +43,10 @@ import java.util.Comparator; import java.util.HashSet; import java.util.Hashtable; +import java.util.IdentityHashMap; import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.Set; import java.util.Vector; import java.util.stream.Collectors; @@ -186,6 +188,10 @@ private Section parseLine(String line, Path includeBase, Set activeFiles, } String[] keyValue = line.split("[= \t]", 2); if (keyValue.length < 2) { + if (line.equalsIgnoreCase("Host") || line.equalsIgnoreCase("Match") + || line.equalsIgnoreCase("Include")) { + throw new IOException(line + " requires an argument"); + } return current; } String key = keyValue[0].trim(); @@ -263,6 +269,9 @@ private static List includeArguments(String value) throws IOException { } private static MatchExpression parseMatch(String value) throws IOException { + if (value.isEmpty()) { + throw new IOException("Match requires at least one criterion"); + } List arguments = includeArguments(value); List criteria = new ArrayList<>(); for (int i = 0; i < arguments.size(); i++) { @@ -329,7 +338,7 @@ boolean matches(String originalHost, String effectiveHost, String remoteUser) { return false; } boolean matched = candidate != null - && matchesHostPatterns(pattern, Util.str2byte(candidate)); + && matchesPatternList(pattern, Util.str2byte(candidate), ","); return negated ? !matched : matched; } } @@ -515,8 +524,12 @@ private static List splitSegments(String pattern) { } private static boolean matchesHostPatterns(String patternList, byte[] host) { + return matchesPatternList(patternList, host, "[ \\t]"); + } + + private static boolean matchesPatternList(String patternList, byte[] host, String separator) { boolean positive = false; - for (String pattern : patternList.split("[ \t]")) { + for (String pattern : patternList.split(separator)) { boolean negate = pattern.startsWith("!"); String candidate = negate ? pattern.substring(1) : pattern; if (Util.glob(Util.str2byte(candidate.trim()), host)) { @@ -577,16 +590,17 @@ class MyConfig implements Config { String remoteUser = user != null ? user : System.getProperty("user.name"); boolean hostnameSet = false; boolean userSet = user != null; + Map matchResults = new IdentityHashMap<>(); for (Section section : sections) { boolean matches = section.host.isEmpty() || matchesHostPatterns(section.host, _host); for (String enclosingHost : section.enclosingHosts) { matches &= matchesHostPatterns(enclosingHost, _host); } if (section.match != null) { - matches &= section.match.matches(host, effectiveHost, remoteUser); + matches &= matchOnce(section.match, host, effectiveHost, remoteUser, matchResults); } for (MatchExpression enclosingMatch : section.enclosingMatches) { - matches &= enclosingMatch.matches(host, effectiveHost, remoteUser); + matches &= matchOnce(enclosingMatch, host, effectiveHost, remoteUser, matchResults); } if (matches) { _configs.addElement(section.options); @@ -603,6 +617,16 @@ class MyConfig implements Config { } } + private boolean matchOnce(MatchExpression expression, String originalHost, + String effectiveHost, String remoteUser, Map results) { + Boolean result = results.get(expression); + if (result == null) { + result = expression.matches(originalHost, effectiveHost, remoteUser); + results.put(expression, result); + } + return result; + } + private String find(String key) { String originalKey = key; if (keymap.get(key) != null) { diff --git a/src/main/java/com/jcraft/jsch/Session.java b/src/main/java/com/jcraft/jsch/Session.java index 36230ce42..2f163417e 100644 --- a/src/main/java/com/jcraft/jsch/Session.java +++ b/src/main/java/com/jcraft/jsch/Session.java @@ -149,6 +149,7 @@ public class Session { SocketFactory socket_factory = null; private Hashtable config = null; + private ConfigRepository.Config resolvedConfig; private Proxy proxy = null; private UserInfo userinfo; @@ -3648,6 +3649,7 @@ private void applyConfig() throws JSchException { } ConfigRepository.Config config = configRepository.getConfig(org_host, username); + resolvedConfig = config; String value = null; @@ -3761,13 +3763,12 @@ private void applyConfig() throws JSchException { } } - private void applyConfigChannel(ChannelSession channel) throws JSchException { - ConfigRepository configRepository = jsch.getConfigRepository(); - if (configRepository == null) { + void applyConfigChannel(ChannelSession channel) throws JSchException { + if (resolvedConfig == null) { return; } - ConfigRepository.Config config = configRepository.getConfig(org_host, username); + ConfigRepository.Config config = resolvedConfig; String value = null; @@ -3787,12 +3788,11 @@ private void requestPortForwarding() throws JSchException { if (getConfig("ClearAllForwardings").equals("yes")) return; - ConfigRepository configRepository = jsch.getConfigRepository(); - if (configRepository == null) { + if (resolvedConfig == null) { return; } - ConfigRepository.Config config = configRepository.getConfig(org_host, username); + ConfigRepository.Config config = resolvedConfig; String[] values = config.getValues("LocalForward"); if (values != null) { diff --git a/src/test/java/com/jcraft/jsch/OpenSSHConfigTest.java b/src/test/java/com/jcraft/jsch/OpenSSHConfigTest.java index 88182997f..03690e817 100644 --- a/src/test/java/com/jcraft/jsch/OpenSSHConfigTest.java +++ b/src/test/java/com/jcraft/jsch/OpenSSHConfigTest.java @@ -1,8 +1,11 @@ package com.jcraft.jsch; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.IOException; import java.net.URISyntaxException; @@ -81,6 +84,53 @@ void matchConditionsUseEffectiveHostAndRemoteUser() throws IOException { assertEquals("yes", config.getConfig("elsewhere").getValue("ForwardAgent")); } + @Test + void matchPatternListsUseCommasAndRespectNegation() throws IOException { + OpenSSHConfig config = OpenSSHConfig.parse("Match !host bastion,jump\n" + + " StrictHostKeyChecking no\nMatch host *.corp,!legacy.corp\n Port 2222\n"); + + assertNull(config.getConfig("bastion").getValue("StrictHostKeyChecking")); + assertNull(config.getConfig("jump").getValue("StrictHostKeyChecking")); + assertEquals("no", config.getConfig("other").getValue("StrictHostKeyChecking")); + assertEquals(2222, config.getConfig("new.corp").getPort()); + assertEquals(-1, config.getConfig("legacy.corp").getPort()); + } + + @Test + void enclosingMatchIsEvaluatedOnceBeforeIncludedHostNameChanges() throws IOException { + Path child = tempDir.resolve("child.conf"); + write(child, "HostName real.example\nHost *\n Port 2222\n"); + Path main = tempDir.resolve("config"); + write(main, "Match host alias\n Include " + child + "\n"); + + assertEquals(2222, OpenSSHConfig.parseFile(main.toString()).getConfig("alias").getPort()); + } + + @Test + void channelUsesOriginalMatchUserEvaluation() throws Exception { + String remoteUser = "jsch-match-remote-user"; + JSch jsch = new JSch(); + jsch.setConfigRepository(OpenSSHConfig.parse("Match user " + remoteUser + + "\n ForwardAgent yes\nHost prod\n User " + remoteUser + "\n")); + + Session inferredUser = jsch.getSession("prod"); + ChannelExec inferredChannel = new ChannelExec(); + inferredUser.applyConfigChannel(inferredChannel); + assertFalse(inferredChannel.agent_forwarding); + + Session explicitUser = jsch.getSession(remoteUser, "prod"); + ChannelExec explicitChannel = new ChannelExec(); + explicitUser.applyConfigChannel(explicitChannel); + assertTrue(explicitChannel.agent_forwarding); + } + + @Test + void bareScopeDirectivesFailInsteadOfLeakingPreviousScope() { + assertThrows(IOException.class, () -> OpenSSHConfig.parse("Host target\nMatch\n Port 2222\n")); + assertThrows(IOException.class, () -> OpenSSHConfig.parse("Host target\nInclude\n")); + assertThrows(IOException.class, () -> OpenSSHConfig.parse("Host\n")); + } + @Test void sessionPassesExplicitUserToMatch() throws Exception { JSch jsch = new JSch(); From 79c380706033870f5c49b323549a606075388110 Mon Sep 17 00:00:00 2001 From: Adam Kalisz Date: Fri, 25 Sep 2026 21:17:53 +0200 Subject: [PATCH 6/7] Format OpenSSHConfig with the project formatter --- .../java/com/jcraft/jsch/OpenSSHConfig.java | 26 +++++++++---------- .../com/jcraft/jsch/OpenSSHConfigTest.java | 12 ++++----- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/src/main/java/com/jcraft/jsch/OpenSSHConfig.java b/src/main/java/com/jcraft/jsch/OpenSSHConfig.java index d971315a5..24c055a9b 100644 --- a/src/main/java/com/jcraft/jsch/OpenSSHConfig.java +++ b/src/main/java/com/jcraft/jsch/OpenSSHConfig.java @@ -100,8 +100,8 @@ public class OpenSSHConfig implements ConfigRepository { * * Include directives in {@code conf} may read additional files. Only parse trusted config text. * JSch does not impose OpenSSH's file owner/mode checks because embedded applications may use - * application-managed files or filesystems without POSIX permissions; callers must enforce - * their own trust policy. + * application-managed files or filesystems without POSIX permissions; callers must enforce their + * own trust policy. * * @param conf string, which includes OpenSSH's config * @return an instanceof OpenSSHConfig @@ -115,9 +115,9 @@ public static OpenSSHConfig parse(String conf) throws IOException { } /** - * Parses the given file, and returns an instance of ConfigRepository. - * Included files are read without owner/mode checks; callers choose which config files are - * trusted, including on platforms without POSIX ownership metadata. + * Parses the given file, and returns an instance of ConfigRepository. Included files are read + * without owner/mode checks; callers choose which config files are trusted, including on + * platforms without POSIX ownership metadata. * * @param file OpenSSH's config file * @return an instanceof OpenSSHConfig @@ -282,8 +282,8 @@ private static MatchExpression parseMatch(String value) throws IOException { } int equals = attribute.indexOf('='); String pattern = equals < 0 ? null : attribute.substring(equals + 1); - String type = (equals < 0 ? attribute : attribute.substring(0, equals)) - .toLowerCase(Locale.ROOT); + String type = + (equals < 0 ? attribute : attribute.substring(0, equals)).toLowerCase(Locale.ROOT); if (type.equals("all")) { if (arguments.size() != 1) { throw new IOException("Match all cannot be combined with other criteria"); @@ -337,8 +337,8 @@ boolean matches(String originalHost, String effectiveHost, String remoteUser) { default: return false; } - boolean matched = candidate != null - && matchesPatternList(pattern, Util.str2byte(candidate), ","); + boolean matched = + candidate != null && matchesPatternList(pattern, Util.str2byte(candidate), ","); return negated ? !matched : matched; } } @@ -457,8 +457,8 @@ private static List matchIncludeFiles(String pattern, Path includeBase) th } else { PathMatcher matcher; try { - matcher = prefix.getFileSystem().getPathMatcher("glob:" - + segment.replace("{", "\\{").replace("}", "\\}")); + matcher = prefix.getFileSystem() + .getPathMatcher("glob:" + segment.replace("{", "\\{").replace("}", "\\}")); } catch (IllegalArgumentException e) { throw new IOException("Invalid Include pattern: " + pattern, e); } @@ -617,8 +617,8 @@ class MyConfig implements Config { } } - private boolean matchOnce(MatchExpression expression, String originalHost, - String effectiveHost, String remoteUser, Map results) { + private boolean matchOnce(MatchExpression expression, String originalHost, String effectiveHost, + String remoteUser, Map results) { Boolean result = results.get(expression); if (result == null) { result = expression.matches(originalHost, effectiveHost, remoteUser); diff --git a/src/test/java/com/jcraft/jsch/OpenSSHConfigTest.java b/src/test/java/com/jcraft/jsch/OpenSSHConfigTest.java index 03690e817..2ac8b42f4 100644 --- a/src/test/java/com/jcraft/jsch/OpenSSHConfigTest.java +++ b/src/test/java/com/jcraft/jsch/OpenSSHConfigTest.java @@ -73,10 +73,10 @@ void nestedIncludeCannotEscapeEnclosingHost() throws IOException { @Test void matchConditionsUseEffectiveHostAndRemoteUser() throws IOException { - OpenSSHConfig config = OpenSSHConfig.parse("Host alias\n HostName real.example\n" - + "Match host real.example user deploy\n Port 2222\n" - + "Match originalhost alias localuser " + System.getProperty("user.name") - + "\n User matched\nMatch all\n ForwardAgent yes\n"); + OpenSSHConfig config = OpenSSHConfig.parse( + "Host alias\n HostName real.example\n" + "Match host real.example user deploy\n Port 2222\n" + + "Match originalhost alias localuser " + System.getProperty("user.name") + + "\n User matched\nMatch all\n ForwardAgent yes\n"); assertEquals(2222, config.getConfig("alias", "deploy").getPort()); assertEquals(-1, config.getConfig("alias", "other").getPort()); @@ -110,8 +110,8 @@ void enclosingMatchIsEvaluatedOnceBeforeIncludedHostNameChanges() throws IOExcep void channelUsesOriginalMatchUserEvaluation() throws Exception { String remoteUser = "jsch-match-remote-user"; JSch jsch = new JSch(); - jsch.setConfigRepository(OpenSSHConfig.parse("Match user " + remoteUser - + "\n ForwardAgent yes\nHost prod\n User " + remoteUser + "\n")); + jsch.setConfigRepository(OpenSSHConfig.parse( + "Match user " + remoteUser + "\n ForwardAgent yes\nHost prod\n User " + remoteUser + "\n")); Session inferredUser = jsch.getSession("prod"); ChannelExec inferredChannel = new ChannelExec(); From 20fa78625e342509d2edf65a36ad01111c01eb81 Mon Sep 17 00:00:00 2001 From: Adam Kalisz Date: Fri, 25 Sep 2026 21:35:49 +0200 Subject: [PATCH 7/7] Ignore trailing comments in config values like OpenSSH --- .../java/com/jcraft/jsch/OpenSSHConfig.java | 31 +++++++++++++++++-- .../com/jcraft/jsch/OpenSSHConfigTest.java | 10 ++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/jcraft/jsch/OpenSSHConfig.java b/src/main/java/com/jcraft/jsch/OpenSSHConfig.java index 24c055a9b..cba3af8ea 100644 --- a/src/main/java/com/jcraft/jsch/OpenSSHConfig.java +++ b/src/main/java/com/jcraft/jsch/OpenSSHConfig.java @@ -195,9 +195,9 @@ private Section parseLine(String line, Path includeBase, Set activeFiles, return current; } String key = keyValue[0].trim(); - String value = keyValue[1].trim(); + String value = stripComment(keyValue[1].trim()); if (value.startsWith("=")) { - value = value.substring(1).trim(); + value = stripComment(value.substring(1).trim()); } if (key.equalsIgnoreCase("Host")) { if (value.isEmpty()) { @@ -222,6 +222,33 @@ private Section parseLine(String line, Path includeBase, Set activeFiles, return current; } + /** + * Drops a trailing comment like OpenSSH: an unquoted, unescaped {@code #} that starts a word ends + * the value, while one inside a word ({@code h#1}) or inside quotes is kept. + */ + private static String stripComment(String value) { + char quote = 0; + for (int i = 0; i < value.length(); i++) { + char ch = value.charAt(i); + if (ch == '\\' && i + 1 < value.length() && isEscapable(value.charAt(i + 1), quote)) { + i++; + } else if (quote != 0) { + if (ch == quote) { + quote = 0; + } + } else if (ch == '"' || ch == '\'') { + quote = ch; + } else if (ch == '#' && (i == 0 || Character.isWhitespace(value.charAt(i - 1)))) { + return value.substring(0, i).trim(); + } + } + return value; + } + + private static boolean isEscapable(char next, char quote) { + return next == '\\' || next == '"' || next == '\'' || (quote == 0 && next == ' '); + } + private void includeFiles(String value, Path includeBase, Set activeFiles, int depth, Section current) throws IOException { List enclosingHosts = new ArrayList<>(current.enclosingHosts); diff --git a/src/test/java/com/jcraft/jsch/OpenSSHConfigTest.java b/src/test/java/com/jcraft/jsch/OpenSSHConfigTest.java index 2ac8b42f4..b24bd91c2 100644 --- a/src/test/java/com/jcraft/jsch/OpenSSHConfigTest.java +++ b/src/test/java/com/jcraft/jsch/OpenSSHConfigTest.java @@ -333,4 +333,14 @@ private void assertUserEquals(OpenSSHConfig openSSHConfig, String host, String e private static void write(Path path, String value) throws IOException { Files.write(path, value.getBytes(StandardCharsets.UTF_8)); } + + @Test + void trailingCommentsAreIgnoredLikeOpenSsh() throws IOException { + OpenSSHConfig config = OpenSSHConfig.parse(String.join("\n", "Host other # alias", " Port 20", + "Host alias", " User bob # comment", " HostName h#1", " Port # none", "")); + assertEquals(-1, config.getConfig("alias").getPort()); + assertEquals("bob", config.getConfig("alias").getUser()); + assertEquals("h#1", config.getConfig("alias").getHostname()); + assertEquals(20, config.getConfig("other").getPort()); + } }