diff --git a/okhttp-dnsoverhttps/src/main/kotlin/okhttp3/dnsoverhttps/-DnsMessageReader.kt b/okhttp-dnsoverhttps/src/main/kotlin/okhttp3/dnsoverhttps/-DnsMessageReader.kt new file mode 100644 index 000000000000..efacd7ccc36b --- /dev/null +++ b/okhttp-dnsoverhttps/src/main/kotlin/okhttp3/dnsoverhttps/-DnsMessageReader.kt @@ -0,0 +1,241 @@ +/* + * Copyright (c) 2026 OkHttp Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:Suppress("ktlint:standard:filename") + +package okhttp3.dnsoverhttps + +import java.io.IOException +import okio.Buffer +import okio.BufferedSource +import okio.ByteString +import okio.ProtocolException + +/** + * https://datatracker.ietf.org/doc/html/rfc1035 + */ +internal class DnsMessageReader( + source: BufferedSource, +) { + /** + * Keep a reference to the source at its initial offset, so we can implement name compression. + * That mechanism requires us to backtrack in the message. + */ + private val sourceOffsetZero = source + private val source = source.peek() + + fun read(): DnsMessage { + val id = source.readShort() + val flags = source.readShort().toInt() + + val questionCount = source.readShort().toUShort().toInt() + val answerCount = source.readShort().toUShort().toInt() + val authorityRecordCount = source.readShort().toUShort().toInt() + val additionalRecordCount = source.readShort().toUShort().toInt() + + val questions = ArrayList(questionCount) + for (i in 0 until questionCount) { + questions += readQuestion() + } + + val answers = ArrayList(answerCount) + for (i in 0 until answerCount) { + answers += readResourceRecord() ?: continue + } + + val authorityRecords = ArrayList(authorityRecordCount) + for (i in 0 until authorityRecordCount) { + authorityRecords += readResourceRecord() ?: continue + } + + val additionalRecords = ArrayList(additionalRecordCount) + for (i in 0 until additionalRecordCount) { + additionalRecords += readResourceRecord() ?: continue + } + + if (!source.exhausted()) { + throw ProtocolException("unexpected trailing data in message") + } + + sourceOffsetZero.skipAll() + + return DnsMessage( + id = id, + flags = flags, + questions = questions, + answers = answers, + authorityRecords = authorityRecords, + additionalRecords = additionalRecords, + ) + } + + private fun readQuestion(): Question { + val name = readName() + val type = source.readShort() + val `class` = source.readShort() + return Question( + name = name, + type = type, + `class` = `class`, + ) + } + + private fun readName(): String { + val result = Buffer() + readName(source, result) + return result.readUtf8() + } + + private tailrec fun readName( + source: BufferedSource, + sink: Buffer, + ) { + while (true) { + val labelTypeAndLength = source.readByte().toUByte().toInt() + val labelType = labelTypeAndLength and 0b11000000 + val labelLength = labelTypeAndLength and 0b00111111 + when (labelType) { + // Inline value. + 0b00_000000 -> { + if (labelLength == 0) return + if (sink.size > 0L) sink.writeByte('.'.code) + sink.write(source, labelLength.toLong()) + } + + // Compressed suffix. + 0b11_000000 -> { + val offsetLength = (labelLength shl 8) or source.readByte().toUByte().toInt() + val offsetSource = sourceOffsetZero.peek() + offsetSource.skip(offsetLength.toLong()) + return readName(offsetSource, sink) + } + + 0b01_000000, 0b10_000000 -> { + throw ProtocolException("unsupported label type") + } + } + } + } + + /** Returns null if this record type is unsupported. */ + fun readResourceRecord(): ResourceRecord? { + val name = readName() + val type = source.readShort().toInt() + val `class` = source.readShort().toInt() + val timeToLive = source.readInt() + val recordDataLength = source.readShort().toLong() + + when { + `class` == CLASS_IN && type == TYPE_A -> { + if (recordDataLength != 4L) throw ProtocolException("unexpected record length") + val address = source.readByteString(4L) + return ResourceRecord.IpAddress( + name = name, + timeToLive = timeToLive, + address = address, + ) + } + + `class` == CLASS_IN && type == TYPE_AAAA -> { + if (recordDataLength != 16L) throw ProtocolException("unexpected record length") + val address = source.readByteString(16L) + return ResourceRecord.IpAddress( + name = name, + timeToLive = timeToLive, + address = address, + ) + } + + else -> { + source.skip(recordDataLength) + return null + } + } + } + + data class DnsMessage( + val id: Short, + val flags: Int, + val questions: List, + val answers: List, + val authorityRecords: List = listOf(), + val additionalRecords: List = listOf(), + ) { + val responseCode: Int + get() = (flags and 0b0000_0000_0000_1111) + + // Avoid Short.hashCode(short) which isn't available on Android 5. + override fun hashCode(): Int { + var result = 0 + result = 31 * result + id + result = 31 * result + flags + result = 31 * result + questions.hashCode() + result = 31 * result + answers.hashCode() + result = 31 * result + authorityRecords.hashCode() + result = 31 * result + additionalRecords.hashCode() + return result + } + } + + data class Question( + val name: String, + val type: Short, + val `class`: Short, + ) { + // Avoid Short.hashCode(short) which isn't available on Android 5. + override fun hashCode(): Int { + var result = 0 + result = 31 * result + name.hashCode() + result = 31 * result + type + result = 31 * result + `class` + return result + } + } + + sealed interface ResourceRecord { + val name: String + val timeToLive: Int + + data class IpAddress( + override val name: String, + override val timeToLive: Int, + val address: ByteString, + ) : ResourceRecord { + // Avoid Int.hashCode(int) which isn't available on Android 5. + override fun hashCode(): Int { + var result = 0 + result = 31 * result + name.hashCode() + result = 31 * result + timeToLive + result = 31 * result + address.hashCode() + return result + } + } + } +} + +internal val TYPE_A = 1 +internal val TYPE_AAAA = 28 + +internal val CLASS_IN = 1 + +internal val RESPONSE_CODE_SUCCESS = 0 +internal val RESPONSE_CODE_SERVER_FAILURE = 2 + +@Throws(IOException::class) +internal fun BufferedSource.skipAll() { + while (!exhausted()) { + skip(buffer.size) + } +} diff --git a/okhttp-dnsoverhttps/src/main/kotlin/okhttp3/dnsoverhttps/DnsOverHttps.kt b/okhttp-dnsoverhttps/src/main/kotlin/okhttp3/dnsoverhttps/DnsOverHttps.kt index aaacf45ff4fd..766c034f53e4 100644 --- a/okhttp-dnsoverhttps/src/main/kotlin/okhttp3/dnsoverhttps/DnsOverHttps.kt +++ b/okhttp-dnsoverhttps/src/main/kotlin/okhttp3/dnsoverhttps/DnsOverHttps.kt @@ -17,6 +17,7 @@ package okhttp3.dnsoverhttps import java.io.IOException import java.net.InetAddress +import java.net.ProtocolException import java.net.UnknownHostException import java.util.concurrent.CountDownLatch import okhttp3.Call @@ -81,7 +82,7 @@ class DnsOverHttps internal constructor( val failures = ArrayList(2) val results = ArrayList(5) - executeRequests(hostname, networkRequests, results, failures) + executeRequests(networkRequests, results, failures) return results.ifEmpty { throwBestFailure(hostname, failures) @@ -89,7 +90,6 @@ class DnsOverHttps internal constructor( } private fun executeRequests( - hostname: String, networkRequests: List, responses: MutableList, failures: MutableList, @@ -113,7 +113,7 @@ class DnsOverHttps internal constructor( call: Call, response: Response, ) { - processResponse(response, hostname, responses, failures) + processResponse(response, responses, failures) latch.countDown() } }, @@ -129,16 +129,15 @@ class DnsOverHttps internal constructor( private fun processResponse( response: Response, - hostname: String, results: MutableList, failures: MutableList, ) { try { - val addresses = readResponse(hostname, response) + val addresses = readResponse(response) synchronized(results) { results.addAll(addresses) } - } catch (e: Exception) { + } catch (e: IOException) { synchronized(failures) { failures.add(e) } @@ -170,31 +169,44 @@ class DnsOverHttps internal constructor( throw unknownHostException } - @Throws(Exception::class) - private fun readResponse( - hostname: String, - response: Response, - ): List { - if (response.cacheResponse == null && response.protocol !== Protocol.HTTP_2 && response.protocol !== Protocol.QUIC) { + @Throws(IOException::class) + private fun readResponse(response: Response): List { + if ( + response.cacheResponse == null && + response.protocol !== Protocol.HTTP_2 && + response.protocol !== Protocol.QUIC + ) { Platform.get().log("Incorrect protocol: ${response.protocol}", Platform.WARN) } response.use { if (!response.isSuccessful) { - throw IOException("response: " + response.code + " " + response.message) + throw IOException("response: ${response.code} ${response.message}") } val body = response.body - if (body.contentLength() > MAX_RESPONSE_SIZE) { - throw IOException( + throw ProtocolException( "response size exceeds limit ($MAX_RESPONSE_SIZE bytes): ${body.contentLength()} bytes", ) } - val responseBytes = body.source().readByteString() + val dnsResponse = DnsMessageReader(body.source()).read() + when (dnsResponse.responseCode) { + RESPONSE_CODE_SUCCESS -> { + return dnsResponse.answers + .filterIsInstance() + .map { InetAddress.getByAddress(it.address.toByteArray()) } + } + + RESPONSE_CODE_SERVER_FAILURE -> { + throw UnknownHostException("DNS server failure") + } - return DnsRecordCodec.decodeAnswers(hostname, responseBytes) + else -> { + throw UnknownHostException() + } + } } } diff --git a/okhttp-dnsoverhttps/src/test/java/okhttp3/dnsoverhttps/DnsMessageReaderTest.kt b/okhttp-dnsoverhttps/src/test/java/okhttp3/dnsoverhttps/DnsMessageReaderTest.kt new file mode 100644 index 000000000000..cf6376f57e38 --- /dev/null +++ b/okhttp-dnsoverhttps/src/test/java/okhttp3/dnsoverhttps/DnsMessageReaderTest.kt @@ -0,0 +1,196 @@ +/* + * Copyright (c) 2026 OkHttp Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3.dnsoverhttps + +import assertk.assertThat +import assertk.assertions.isEqualTo +import java.net.InetAddress +import kotlin.test.Test +import okio.Buffer +import okio.ByteString.Companion.decodeHex +import okio.ByteString.Companion.toByteString + +class DnsMessageReaderTest { + @Test + fun `resource record with compressed suffix`() { + val reader = + DnsMessageReader( + "00008180000100040000000006676f6f676c6503636f6d00001c0001c00c001c00010000003c00102607f8b040" + + "2318070000000000000071c00c001c00010000003c00102607f8b0402318070000000000000065c00c001c00" + + "010000003c00102607f8b040231807000000000000008ac00c001c00010000003c00102607f8b04023180700" + + "0000000000008b", + ) + assertThat(reader.read()).isEqualTo( + DnsMessageReader.DnsMessage( + id = 0, + flags = -32384, + questions = + listOf( + DnsMessageReader.Question( + name = "google.com", + type = 28, + `class` = 1, + ), + ), + answers = + listOf( + DnsMessageReader.ResourceRecord.IpAddress( + name = "google.com", + timeToLive = 60, + address = InetAddress.getByName("2607:f8b0:4023:1807:0:0:0:71").address.toByteString(), + ), + DnsMessageReader.ResourceRecord.IpAddress( + name = "google.com", + timeToLive = 60, + address = InetAddress.getByName("2607:f8b0:4023:1807:0:0:0:65").address.toByteString(), + ), + DnsMessageReader.ResourceRecord.IpAddress( + name = "google.com", + timeToLive = 60, + address = InetAddress.getByName("2607:f8b0:4023:1807:0:0:0:8a").address.toByteString(), + ), + DnsMessageReader.ResourceRecord.IpAddress( + name = "google.com", + timeToLive = 60, + address = InetAddress.getByName("2607:f8b0:4023:1807:0:0:0:8b").address.toByteString(), + ), + ), + ), + ) + } + + @Test + fun `resource record with not compressed suffix`() { + val reader = + DnsMessageReader( + "00008180000100010000000106676f6f676c6503636f6d0000010001c00c000100010000028e0004acd917ee00" + + "002904d000000000000d000c0009585858585858585858", + ) + assertThat(reader.read()).isEqualTo( + DnsMessageReader.DnsMessage( + id = 0, + flags = -32384, + questions = + listOf( + DnsMessageReader.Question( + name = "google.com", + type = 1, + `class` = 1, + ), + ), + answers = + listOf( + DnsMessageReader.ResourceRecord.IpAddress( + name = "google.com", + timeToLive = 654, + address = InetAddress.getByName("172.217.23.238").address.toByteString(), + ), + ), + ), + ) + } + + @Test + fun `ignore cname`() { + val reader = + DnsMessageReader( + "0000818000010002000000000567726170680866616365626f6f6b03636f6d0000010001c00c0005000100000a" + + "0f000c04737461720463313072c012c030000100010000002e00041f0d5008", + ) + assertThat(reader.read()).isEqualTo( + DnsMessageReader.DnsMessage( + id = 0, + flags = -32384, + questions = + listOf( + DnsMessageReader.Question( + name = "graph.facebook.com", + type = 1, + `class` = 1, + ), + ), + answers = + listOf( + DnsMessageReader.ResourceRecord.IpAddress( + name = "star.c10r.facebook.com", + timeToLive = 46, + address = InetAddress.getByName("31.13.80.8").address.toByteString(), + ), + ), + ), + ) + } + + @Test + fun `ignore soa`() { + val reader = + DnsMessageReader( + "0000818300010000000100000e7364666c6b686673646c6b6a646602656500001c0001c01b00060001000004ff" + + "0038026e7303746c64c01b0a686f73746d61737465720d6565737469696e7465726e6574c01b6a554d2d0000" + + "0e10000003840012750000000e10", + ) + assertThat(reader.read()).isEqualTo( + DnsMessageReader.DnsMessage( + id = 0, + flags = -32381, + questions = + listOf( + DnsMessageReader.Question( + name = "sdflkhfsdlkjdf.ee", + type = 28, + `class` = 1, + ), + ), + answers = + listOf(), + ), + ) + } + + /** https://www.rfc-editor.org/info/rfc2671/ */ + @Test + fun `ignore opt`() { + val reader = + DnsMessageReader( + "00008180000100010000000106676f6f676c6503636f6d0000010001c00c000100010000028e0004acd917ee00" + + "002904d000000000000d000c0009585858585858585858", + ) + assertThat(reader.read()).isEqualTo( + DnsMessageReader.DnsMessage( + id = 0, + flags = -32384, + questions = + listOf( + DnsMessageReader.Question( + name = "google.com", + type = 1, + `class` = 1, + ), + ), + answers = + listOf( + DnsMessageReader.ResourceRecord.IpAddress( + name = "google.com", + timeToLive = 654, + address = InetAddress.getByName("172.217.23.238").address.toByteString(), + ), + ), + ), + ) + } + + private fun DnsMessageReader(hex: String) = DnsMessageReader(Buffer().write(hex.decodeHex())) +} diff --git a/okhttp-dnsoverhttps/src/test/java/okhttp3/dnsoverhttps/DnsOverHttpsTest.kt b/okhttp-dnsoverhttps/src/test/java/okhttp3/dnsoverhttps/DnsOverHttpsTest.kt index 6d6f9aa5dbe3..a304a6a7813f 100644 --- a/okhttp-dnsoverhttps/src/test/java/okhttp3/dnsoverhttps/DnsOverHttpsTest.kt +++ b/okhttp-dnsoverhttps/src/test/java/okhttp3/dnsoverhttps/DnsOverHttpsTest.kt @@ -30,6 +30,7 @@ import java.net.InetAddress import java.net.UnknownHostException import java.util.concurrent.TimeUnit import kotlin.reflect.KClass +import kotlin.test.assertFailsWith import mockwebserver3.MockResponse import mockwebserver3.MockWebServer import mockwebserver3.junit5.StartStop @@ -134,11 +135,8 @@ class DnsOverHttpsTest { "2c100000e10000003840012750000000e10", ), ) - try { + assertFailsWith { dns.lookup("google.com") - fail() - } catch (uhe: UnknownHostException) { - assertThat(uhe.message).isEqualTo("google.com: NXDOMAIN") } val recordedRequest = server.takeRequest() assertThat(recordedRequest.method).isEqualTo("GET")