Skip to content

Commit d49f750

Browse files
committed
Fix IPv6 resolver parsing corruption, block IPv6 input
Fix naive split(":") that corrupted IPv6 addresses like [fe80::]:53 into [fe80:53. Use bracket-aware parseHostPort() and formatResolver() for correct round-tripping. Block IPv6 at validation since the tunnel stack doesn't support it.
1 parent e638688 commit d49f750

1 file changed

Lines changed: 42 additions & 39 deletions

File tree

app/src/main/java/app/slipnet/presentation/profiles/EditProfileViewModel.kt

Lines changed: 42 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -261,15 +261,15 @@ class EditProfileViewModel @Inject constructor(
261261
val defaults = profile.defaultResolvers.ifEmpty {
262262
if (profile.resolversHidden) profile.resolvers else emptyList()
263263
}
264-
val defaultKeys = defaults.map { "${it.host}:${it.port}" }.toSet()
265-
val currentKeys = profile.resolvers.map { "${it.host}:${it.port}" }.toSet()
264+
val defaultKeys = defaults.map { formatResolver(it) }.toSet()
265+
val currentKeys = profile.resolvers.map { formatResolver(it) }.toSet()
266266
val hasCustom = profile.resolversHidden && defaults.isNotEmpty() && currentKeys != defaultKeys
267267
if (hasCustom) {
268-
profile.resolvers.joinToString(",") { "${it.host}:${it.port}" }
268+
profile.resolvers.joinToString(",") { formatResolver(it) }
269269
} else if (profile.resolversHidden) {
270270
""
271271
} else {
272-
profile.resolvers.joinToString(",") { "${it.host}:${it.port}" }
272+
profile.resolvers.joinToString(",") { formatResolver(it) }
273273
}
274274
},
275275
defaultResolversList = profile.defaultResolvers.ifEmpty {
@@ -279,8 +279,8 @@ class EditProfileViewModel @Inject constructor(
279279
val defaults = profile.defaultResolvers.ifEmpty {
280280
if (profile.resolversHidden) profile.resolvers else emptyList()
281281
}
282-
val defaultKeys = defaults.map { "${it.host}:${it.port}" }.toSet()
283-
val currentKeys = profile.resolvers.map { "${it.host}:${it.port}" }.toSet()
282+
val defaultKeys = defaults.map { formatResolver(it) }.toSet()
283+
val currentKeys = profile.resolvers.map { formatResolver(it) }.toSet()
284284
profile.resolversHidden && defaults.isNotEmpty() && currentKeys != defaultKeys
285285
},
286286
authoritativeMode = profile.authoritativeMode,
@@ -1245,15 +1245,44 @@ class EditProfileViewModel @Inject constructor(
12451245
.map { it.trim() }
12461246
.filter { it.isNotBlank() }
12471247
.map { resolver ->
1248-
val parts = resolver.split(":")
1248+
val (host, port) = parseHostPort(resolver)
12491249
DnsResolver(
1250-
host = parts[0].trim(),
1251-
port = parts.getOrNull(1)?.trim()?.toIntOrNull() ?: 53,
1250+
host = host,
1251+
port = port,
12521252
authoritative = authoritativeMode
12531253
)
12541254
}
12551255
}
12561256

1257+
/** Format a DnsResolver as host:port, using bracket notation for IPv6. */
1258+
private fun formatResolver(r: DnsResolver): String {
1259+
return if (r.host.contains(':')) "[${r.host}]:${r.port}" else "${r.host}:${r.port}"
1260+
}
1261+
1262+
/** Parse host:port supporting IPv6 bracket notation like [fe80::]:53 */
1263+
private fun parseHostPort(input: String): Pair<String, Int> {
1264+
val trimmed = input.trim()
1265+
if (trimmed.startsWith("[")) {
1266+
val closeBracket = trimmed.indexOf(']')
1267+
if (closeBracket != -1) {
1268+
val host = trimmed.substring(1, closeBracket)
1269+
val port = if (closeBracket + 2 < trimmed.length) {
1270+
trimmed.substring(closeBracket + 2).toIntOrNull() ?: 53
1271+
} else 53
1272+
return host to port
1273+
}
1274+
}
1275+
val lastColon = trimmed.lastIndexOf(':')
1276+
// Only treat as host:port if there's exactly one colon (IPv4 or hostname)
1277+
if (lastColon != -1 && trimmed.indexOf(':') == lastColon) {
1278+
val host = trimmed.substring(0, lastColon).trim()
1279+
val port = trimmed.substring(lastColon + 1).trim().toIntOrNull() ?: 53
1280+
return host to port
1281+
}
1282+
// Bare host (no port) — could be IPv6 without brackets
1283+
return trimmed to 53
1284+
}
1285+
12571286
/**
12581287
* Validates domain format for DNSTT and Slipstream tunnel types.
12591288
* These require a proper DNS domain name (e.g., "t.example.com").
@@ -1393,41 +1422,15 @@ class EditProfileViewModel @Inject constructor(
13931422
return "Resolver cannot be empty"
13941423
}
13951424

1396-
// Handle IPv6 with port: [2001:db8::1]:53
1397-
if (trimmed.startsWith("[")) {
1398-
val closeBracket = trimmed.indexOf("]")
1399-
if (closeBracket == -1) {
1400-
return "Invalid IPv6 format: missing closing bracket in '$trimmed'"
1401-
}
1402-
1403-
val ipv6 = trimmed.substring(1, closeBracket)
1404-
if (!isValidIPv6(ipv6)) {
1405-
return "Invalid IPv6 address: '$ipv6'"
1406-
}
1407-
1408-
// Check for port after ]
1409-
if (closeBracket < trimmed.length - 1) {
1410-
if (trimmed[closeBracket + 1] != ':') {
1411-
return "Invalid format: expected ':' after ']' in '$trimmed'"
1412-
}
1413-
val portStr = trimmed.substring(closeBracket + 2)
1414-
val portError = validatePort(portStr, trimmed)
1415-
if (portError != null) return portError
1416-
}
1417-
1418-
return null
1425+
// Block IPv6 — not supported by the tunnel stack
1426+
if (trimmed.startsWith("[") || trimmed.count { it == ':' } > 1) {
1427+
return "IPv6 resolvers are not supported"
14191428
}
14201429

1421-
// Count colons to distinguish IPv4:port from IPv6
1430+
// Count colons to distinguish IPv4:port from host:port
14221431
val colonCount = trimmed.count { it == ':' }
14231432

14241433
when {
1425-
// IPv6 without port (multiple colons)
1426-
colonCount > 1 -> {
1427-
if (!isValidIPv6(trimmed)) {
1428-
return "Invalid IPv6 address: '$trimmed'"
1429-
}
1430-
}
14311434
// IPv4:port or host:port (single colon)
14321435
colonCount == 1 -> {
14331436
val parts = trimmed.split(":")

0 commit comments

Comments
 (0)