[PATCH] Support IPv6 addresses in URLs

Demi Marie Obenour demiobenour at gmail.com
Thu Jul 16 16:06:51 AEST 2026


URLs didn't distinguish between IPv6 addresses and domain names, so a
bracketed IPv6 address was treated as a syntax error.  Fix this by
parsing them properly.  The parser also handles percent-encoded zone IDs
and properly percent-decodes them.

Full regression tests are included.
---
This isn't tested on OpenBSD, but the change should also apply to the
portable version (with fuzz 1).
---
 regress/usr.bin/ssh/connect-uri.sh | 33 ++++++++++
 usr.bin/ssh/misc.c                 | 98 ++++++++++++++++++++++++++++--
 2 files changed, 127 insertions(+), 4 deletions(-)

diff --git regress/usr.bin/ssh/connect-uri.sh regress/usr.bin/ssh/connect-uri.sh
index f13f15e660df1f2a10cfbe3eafcb27494f48906a..e545c6b3189442158e802be61d36dc32ad82c6d5 100644
--- regress/usr.bin/ssh/connect-uri.sh
+++ regress/usr.bin/ssh/connect-uri.sh
@@ -27,3 +27,36 @@ ${SSH} -F $OBJ/ssh_config "ssh://${USER}@somehost:${PORT}/${DATA}" true \
 if [ $? -eq 0 ]; then
 	fail "ssh connection succeeded, expected failure"
 fi
+
+check_v6_pass () {
+	_tag="$1"
+	_url="$2"
+	verbose "$tid: Valid IPv6 URL $_tag"
+	${SSH} -F $OBJ/ssh_config "$_url" true >/dev/null ||
+		fail "IPv6 URL connection failed: $_tag: $_url"
+}
+
+check_v6_fail () {
+	_tag="$1"
+	_url="$2"
+	verbose "$tid: Invalid IPv6 URL $_tag"
+	if ${SSH} -F $OBJ/ssh_config "$_url" true >/dev/null 2>&1; then
+		fail "IPv6 URL connection succeeded: $_tag: $_url"
+	fi
+}
+
+check_v6_pass loopback "ssh://${USER}@[::1]:${PORT}/"
+check_v6_pass 'leading double colon' "ssh://${USER}@[::a:a:a:a:a:a]:${PORT}/"
+check_v6_pass 'trailing double colon' "ssh://${USER}@[a:a:a:a:a:a::]:${PORT}/"
+check_v6_pass 'no double colon' "ssh://${USER}@[a:a:a:a:a:a:a:a]:${PORT}/"
+check_v6_pass 'good zone ID' "ssh://${USER}@[fe80::1%25lo5]:${PORT}/"
+check_v6_pass 'good encoded zone ID' "ssh://${USER}@[fe80::1%25l%6f]:${PORT}/"
+check_v6_fail 'bad zone ID: % not %-encoded' "ssh://${USER}@[::1%2]:${PORT}/"
+check_v6_fail 'bad zone ID: non-unreserved character' "ssh://${USER}@[::1%25/]:${PORT}/"
+check_v6_fail 'bad zone ID: bad % encoding' "ssh://${USER}@[::1%25a%2]:${PORT}/"
+check_v6_fail 'bad zone ID: NUL byte' "ssh://${USER}@[::1%25a%00]:${PORT}/"
+check_v6_fail 'too many hex digits' "ssh://${USER}@[::aaaaa]:${PORT}/"
+check_v6_fail 'single leading colon' "ssh://${USER}@[:0:0:0:0:0:0:0]:${PORT}/"
+check_v6_fail 'single trailing colon' "ssh://${USER}@[0:0:0:0:0:0:0:]:${PORT}/"
+check_v6_fail 'too many components' "ssh://${USER}@[0:0:0:0:0:0:0:0:0]:${PORT}/"
+check_v6_fail 'leading and trailing double colon' "ssh://${USER}@[::0::]:${PORT}/"
diff --git usr.bin/ssh/misc.c usr.bin/ssh/misc.c
index 4ce2e4e9c57e728da3f85bf66aca42c6268a953e..a47e918dc8f9d3623efecbb3f2eecb80f89c5432 100644
--- usr.bin/ssh/misc.c
+++ usr.bin/ssh/misc.c
@@ -53,6 +53,7 @@
 #include <unistd.h>
 
 #include "xmalloc.h"
+#include "addr.h"
 #include "misc.h"
 #include "log.h"
 #include "ssh.h"
@@ -1038,6 +1039,88 @@ urldecode(const char *src)
 	return ret;
 }
 
+static char *
+decode_ipv6(const char *address)
+{
+	const char *tmp_percent;
+	struct xaddr n;
+	char *output, *percent;
+
+	percent = strchr(address, '%');
+	if (percent != NULL) {
+		/*
+		 * Only the fragment specifier is allowed to be %-encoded.
+		 * Therefore, the first %-encoded octet must itself be %,
+		 * which is encoded as %25.
+		 */
+		if (percent[1] != '2' || percent[2] != '5')
+			fatal("%s has first %% not part of %%25", address);
+
+		/*
+		 * RFC6874 says that non-unreserved characters MUST be percent-encoded.
+		 * Validate that here.  urldecode() will check for bad characters after
+		 * the '%', as well as for %00.
+		 */
+		for (tmp_percent = percent; *tmp_percent != '\0'; tmp_percent++) {
+			if (!(isalnum((unsigned char)*tmp_percent) ||
+			      *tmp_percent == '-' ||
+			      *tmp_percent == '.' ||
+			      *tmp_percent == '_' ||
+			      *tmp_percent == '~' ||
+			      *tmp_percent == '%'))
+				fatal("Non-unreserved, non-%% character in encoded IPv6 zone ID %s",
+				      address);
+		}
+	}
+
+	/* URL-decode the fragment portion. */
+	output = urldecode(address);
+	if (output == NULL)
+		fatal("Invalid %%-encoding character in IPv6 address %s",
+		      address);
+
+	/*
+	 * glibc getaddrinfo() validates that zone IDs are only found on
+	 * link-scope addresses and correspond to links that actually
+	 * exist.  However, a parsing function should not depend on what
+	 * links are present.  Therefore, only pass the part of the
+	 * string before the zone ID to getaddrinfo().
+	 *
+	 * "%25" decodes to "%", so the output buffer and the
+	 * address have their first "%" at the same offset from
+	 * the start of the string.
+	 */
+	if (percent != NULL) {
+		/*
+		 * Update percent to point into the decoded buffer.
+		 * Use output + (percent - address) because pointer
+		 * arithmetic between pointers to different buffers
+		 * is undefined behavior in C.
+		 */
+		percent = output + (percent - address);
+		*percent = '\0';
+	}
+
+	if (addr_pton(output, &n) == -1)
+		fatal("%s is not a valid IPv6 address", output);
+
+	/*
+	 * The only caller already checks for there being a ':' in the address,
+	 * and ':' after the first '%' must be %-encoded.  Therefore, this must
+	 * be an IPv6 address, not an IPv4 address.  Nevertheless, check for
+	 * AF_INET6 as defense in depth.
+	 */
+	if (n.af != AF_INET6)
+		fatal("internal error: %s is an IP address, but not an IPv6 address",
+		      output);
+
+	/* Restore the zone identifier if present. */
+	if (percent != NULL)
+		*percent = '%';
+
+	return output;
+}
+
 /*
  * Parse an (scp|ssh|sftp)://[user@]host[:port][/path] URI.
  * See https://tools.ietf.org/html/draft-ietf-secsh-scp-sftp-ssh-uri-04
@@ -1054,7 +1137,7 @@ int
 parse_uri(const char *scheme, const char *uri, char **userp, char **hostp,
     int *portp, char **pathp)
 {
-	char *uridup, *cp, *tmp, ch;
+	char *uridup, *cp, *tmp_host, *tmp, ch;
 	char *user = NULL, *host = NULL, *path = NULL;
 	int port = -1, ret = -1;
 	size_t len;
@@ -1097,9 +1180,16 @@ parse_uri(const char *scheme, const char *uri, char **userp, char **hostp,
 	/* Extract mandatory hostname */
 	if ((cp = hpdelim2(&tmp, &ch)) == NULL || *cp == '\0')
 		goto out;
-	host = xstrdup(cleanhostname(cp));
-	if (!valid_domain(host, 0, NULL))
-		goto out;
+	tmp_host = cleanhostname(cp);
+	if (strchr(tmp_host, ':') != NULL) {
+		/* Validate the IPv6 address and URL-decode the zone ID. */
+		if ((host = decode_ipv6(tmp_host)) == NULL)
+			goto out;
+	} else {
+		if (!valid_domain(tmp_host, 0, NULL))
+			goto out;
+		host = xstrdup(tmp_host);
+	}
 
 	if (tmp != NULL && *tmp != '\0') {
 		if (ch == ':') {
-- 
2.55.0


More information about the openssh-unix-dev mailing list