Test the DNS filter decision flow without Android (FR-18)
A blocked host becomes an NXDOMAIN reply addressed to the querying client. The VPN consent dialog stays manual; this covers the runtime-verifiable core. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -0,0 +1,79 @@
|
|||||||
|
package au.audasmedia.webrestrict.kids
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The VPN's decision path without Android: a DNS query packet goes in, and a
|
||||||
|
* blocked host comes out as an NXDOMAIN reply to the exact client that asked.
|
||||||
|
* This is the runtime-verifiable part of FR-18; the consent dialog itself is
|
||||||
|
* manual (see android/README.md).
|
||||||
|
*/
|
||||||
|
class DnsFilterFlowTest {
|
||||||
|
|
||||||
|
private val clientIp = byteArrayOf(10, 111, 222.toByte(), 1) // interface address
|
||||||
|
private val dnsIp = byteArrayOf(10, 111, 222.toByte(), 2) // fake resolver
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a blocked host becomes an NXDOMAIN answer`() {
|
||||||
|
val reply = evaluate("www.youtube.com", DeviceConfig(locked = true, block = listOf("youtube.com")))
|
||||||
|
assertTrue(reply != null)
|
||||||
|
|
||||||
|
val udp = IpPacket.parseUdp(reply!!, reply.size)!!
|
||||||
|
// The reply is addressed back to the client, from the resolver.
|
||||||
|
assertEquals(53, udp.srcPort)
|
||||||
|
assertEquals(40000, udp.dstPort)
|
||||||
|
assertEquals(0x03, udp.payload[3].toInt() and 0x0F) // RCODE = NXDOMAIN
|
||||||
|
assertEquals(0x80, udp.payload[2].toInt() and 0x80) // QR = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an allowlisted host is not blocked`() {
|
||||||
|
val config = DeviceConfig(
|
||||||
|
locked = true,
|
||||||
|
block = listOf("youtube.com"),
|
||||||
|
allow = listOf("safe.youtube.com"),
|
||||||
|
)
|
||||||
|
assertFalse(SiteFilter.isBlocked("safe.youtube.com", config))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `nothing is blocked while unlocked`() {
|
||||||
|
val config = DeviceConfig(locked = false, block = listOf("youtube.com"))
|
||||||
|
assertFalse(SiteFilter.isBlocked("www.youtube.com", config))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs the same steps [SiteFilterVpnService.handle] runs, minus the socket:
|
||||||
|
* parse the query, decide, and build the reply. It returns null when the
|
||||||
|
* host is allowed (the real service would forward it upstream instead).
|
||||||
|
*/
|
||||||
|
private fun evaluate(host: String, config: DeviceConfig): ByteArray? {
|
||||||
|
val query = dnsQuery(host)
|
||||||
|
val clientPort = 40000
|
||||||
|
val packet = IpPacket.buildUdp(clientIp, dnsIp, clientPort, 53, query)
|
||||||
|
|
||||||
|
val udp = IpPacket.parseUdp(packet, packet.size)!!
|
||||||
|
val question = DnsMessage.parseQuestion(udp.payload, udp.payload.size)!!
|
||||||
|
assertEquals(host, question.name)
|
||||||
|
|
||||||
|
if (!SiteFilter.isBlocked(question.name, config)) return null
|
||||||
|
val answer = DnsMessage.nxdomain(udp.payload, question.end)
|
||||||
|
return IpPacket.buildUdp(udp.dstIp, udp.srcIp, udp.dstPort, udp.srcPort, answer)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dnsQuery(name: String): ByteArray {
|
||||||
|
val out = ByteArrayOutputStream()
|
||||||
|
out.write(byteArrayOf(0x12, 0x34, 0x01, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0))
|
||||||
|
for (label in name.split('.')) {
|
||||||
|
out.write(label.length)
|
||||||
|
out.write(label.toByteArray(Charsets.US_ASCII))
|
||||||
|
}
|
||||||
|
out.write(0)
|
||||||
|
out.write(byteArrayOf(0x00, 0x01, 0x00, 0x01))
|
||||||
|
return out.toByteArray()
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user