a Fabric Minecraft mod that connects the game to the AT Protocol.

ssrf

nekomimi.pet 74bf7093 c0873456

verified
Changed files
+67 -2
src
main
kotlin
com
jollywhoppers
+67 -2
src/main/kotlin/com/jollywhoppers/atproto/AtProtoClient.kt
··· 159 159 */ 160 160 suspend fun resolveDid(did: String): Result<DidDocument> = runCatching { 161 161 logger.info("Resolving DID: $did") 162 - 162 + 163 163 val url = when { 164 164 did.startsWith("did:plc:") -> { 165 165 val identifier = did.removePrefix("did:plc:") ··· 167 167 } 168 168 did.startsWith("did:web:") -> { 169 169 val domain = did.removePrefix("did:web:") 170 + 171 + // Validate domain format (no IPs, only valid hostnames) 172 + if (!domain.matches(Regex("^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$"))) { 173 + throw IllegalArgumentException("Invalid did:web domain format: must be a valid hostname") 174 + } 175 + 176 + // Block private IP ranges and localhost 177 + validateNotPrivateNetwork(domain) 178 + 170 179 "https://$domain/.well-known/did.json" 171 180 } 172 181 else -> throw IllegalArgumentException("Unsupported DID method: $did") ··· 363 372 ?: throw Exception("No handle found in DID document") 364 373 val pdsService = didDoc.service.firstOrNull { it.type == "AtprotoPersonalDataServer" } 365 374 ?: throw Exception("No PDS service found in DID document") 366 - Triple(identifier, handle, pdsService.serviceEndpoint) 375 + 376 + // Validate serviceEndpoint per AT Protocol spec 377 + val serviceEndpoint = pdsService.serviceEndpoint 378 + val uri = try { 379 + URI.create(serviceEndpoint) 380 + } catch (e: Exception) { 381 + throw Exception("Invalid serviceEndpoint URI: ${e.message}") 382 + } 383 + 384 + // Validate per AT Protocol spec 385 + require(uri.scheme in listOf("http", "https")) { 386 + "serviceEndpoint must use HTTP or HTTPS scheme, got: ${uri.scheme}" 387 + } 388 + require(uri.host != null) { 389 + "serviceEndpoint must have a valid host" 390 + } 391 + require(uri.path.isNullOrEmpty() || uri.path == "/") { 392 + "serviceEndpoint must not contain path, got: ${uri.path}" 393 + } 394 + require(uri.query == null) { 395 + "serviceEndpoint must not contain query parameters" 396 + } 397 + require(uri.fragment == null) { 398 + "serviceEndpoint must not contain fragment" 399 + } 400 + require(uri.userInfo == null) { 401 + "serviceEndpoint must not contain userinfo" 402 + } 403 + 404 + // Block private IP ranges 405 + validateNotPrivateNetwork(uri.host) 406 + 407 + // Reconstruct clean URL 408 + val cleanPdsUrl = "${uri.scheme}://${uri.host}${uri.port.takeIf { it != -1 }?.let { ":$it" } ?: ""}" 409 + Triple(identifier, handle, cleanPdsUrl) 367 410 } 368 411 } 369 412 else -> { ··· 381 424 .resolve(value) 382 425 .rawSchemeSpecificPart 383 426 .replace("+", "%20") 427 + } 428 + 429 + /** 430 + * Validates that a hostname or domain is not a private network address. 431 + * Throws IllegalArgumentException if the address is localhost or a private IP range. 432 + */ 433 + private fun validateNotPrivateNetwork(host: String) { 434 + val blockedPatterns = listOf( 435 + Regex("^localhost$", RegexOption.IGNORE_CASE), 436 + Regex("^127\\."), 437 + Regex("^10\\."), 438 + Regex("^172\\.(1[6-9]|2[0-9]|3[01])\\."), 439 + Regex("^192\\.168\\."), 440 + Regex("^169\\.254\\."), 441 + Regex("^::1$"), 442 + Regex("^fc00:"), 443 + Regex("^fe80:") 444 + ) 445 + 446 + if (blockedPatterns.any { it.containsMatchIn(host) }) { 447 + throw IllegalArgumentException("Access to private networks is not allowed: $host") 448 + } 384 449 } 385 450 }