Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion THREAT_MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,9 @@ Relay data is public by design, so confidentiality of relay data is not an asset
128 MB read per response, every line validated (strict date, `[a-z]{2}` country, bounded numbers, no future dates),
bound parameters only. The API serves the stored rows for a validated `LocalDate`, cached per day with a bounded cache.
- Reverse DNS: at most 10 PTR names verified per IP, forward results filtered to public addresses, results cached for
6 hours with `sync = true`.
6 hours with `sync = true`. PTR and forward (A/AAAA) lookups use dnsjava with a 1 s query timeout and run on at most
8 dedicated threads with a 2 s deadline per IP. Names not verified by then are returned as unverified. When all
threads are busy, the request gets no host names and the result is not cached.
- Backend container runs as non-root `1000:1000` from a digest-pinned base image. The frontend image (static files only)
is based on the digest-pinned `nginxinc/nginx-unprivileged`, runs as non-root `101` and listens on port 8080.
- Supply chain: Maven Central and npm registry only, Yarn install scripts disabled, 3-day npm age gate, Renovate with
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,16 @@ class RelayDetailsQueryService(
return RelayDetailsDto(
details = details,
confirmedFamilyMembers = details.confirmedFamilyMembers(),
reverseDnsLookupResult = reverseDnsLookupService.lookupHostNames(details.address),
reverseDnsLookupResult = lookupHostNames(details.address),
)
}

private fun lookupHostNames(address: String): ReverseDnsLookupResult = try {
reverseDnsLookupService.lookupHostNames(address)
} catch (_: ReverseDnsLookupCapacityException) {
ReverseDnsLookupResult()
}

private fun RelayDetails.confirmedFamilyMembers(): List<RelayIdentifiersDto> {
val currentRelayId = id ?: return emptyList()
val currentFamilyId = familyId ?: return emptyList()
Expand Down
121 changes: 106 additions & 15 deletions backend/src/main/kotlin/org/tormap/service/ReverseDnsLookupService.kt
Original file line number Diff line number Diff line change
@@ -1,16 +1,30 @@
package org.tormap.service

import org.springframework.beans.factory.DisposableBean
import org.springframework.cache.annotation.Cacheable
import org.springframework.stereotype.Component
import org.springframework.stereotype.Service
import org.tormap.config.CacheConfig
import org.tormap.util.logger
import org.xbill.DNS.AAAARecord
import org.xbill.DNS.ARecord
import org.xbill.DNS.ExtendedResolver
import org.xbill.DNS.Lookup
import org.xbill.DNS.Name
import org.xbill.DNS.PTRRecord
import org.xbill.DNS.Record
import org.xbill.DNS.ReverseMap
import org.xbill.DNS.Type
import java.net.Inet6Address
import java.net.InetAddress
import java.time.Duration
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ExecutionException
import java.util.concurrent.RejectedExecutionException
import java.util.concurrent.SynchronousQueue
import java.util.concurrent.ThreadPoolExecutor
import java.util.concurrent.TimeUnit
import java.util.concurrent.TimeoutException

data class ReverseDnsLookupResult(
val verifiedHostNames: List<String> = emptyList(),
Expand All @@ -22,15 +36,26 @@ interface ReverseDnsResolver {
fun lookupAddresses(hostName: String): List<String>
}

/**
* Thrown when all reverse DNS lookup slots are busy. Exceptions are not cached, so a later request retries the lookup.
*/
class ReverseDnsLookupCapacityException : RuntimeException("All reverse DNS lookup slots are busy")

@Component
class DNSJavaReverseDnsResolver : ReverseDnsResolver {
companion object {
// Per query, across all configured name servers
private val QUERY_TIMEOUT: Duration = Duration.ofSeconds(1)
}

private val logger = logger()
private val dnsResolver = ExtendedResolver().apply { timeout = QUERY_TIMEOUT }

override fun lookupPtrRecords(ipAddress: String): List<String> {
return try {
val records = Lookup(ReverseMap.fromAddress(ipAddress), Type.PTR).run()
?: return emptyList()
records.filterIsInstance<PTRRecord>().map { it.target.toString().trimEnd('.') }
lookup(ReverseMap.fromAddress(ipAddress), Type.PTR)
.filterIsInstance<PTRRecord>()
.map { it.target.toString().trimEnd('.') }
} catch (e: Exception) {
logger.debug("PTR record lookup for IP {} failed! {}: {}", ipAddress, e.javaClass, e.message)
emptyList()
Expand All @@ -39,7 +64,17 @@ class DNSJavaReverseDnsResolver : ReverseDnsResolver {

override fun lookupAddresses(hostName: String): List<String> {
return try {
InetAddress.getAllByName(hostName)
// Absolute name, so the search path of the host is not applied
val name = Name.fromString(hostName, Name.root)
listOf(Type.A, Type.AAAA)
.flatMap { lookup(name, it) }
.mapNotNull {
when (it) {
is ARecord -> it.address
is AAAARecord -> it.address
else -> null
}
}
.filter { it.isPubliclyRoutable() }
.map { it.hostAddress }
} catch (e: Exception) {
Expand All @@ -48,6 +83,9 @@ class DNSJavaReverseDnsResolver : ReverseDnsResolver {
}
}

private fun lookup(name: Name, type: Int): List<Record> =
Lookup(name, type).apply { setResolver(dnsResolver) }.run()?.toList() ?: emptyList()

private fun InetAddress.isULA(): Boolean =
this is Inet6Address &&
(address[0].toInt() and 0xFE) == 0xFC // fc00::/7
Expand All @@ -68,29 +106,82 @@ class DNSJavaReverseDnsResolver : ReverseDnsResolver {
@Service
class ReverseDnsLookupService(
private val reverseDnsResolver: ReverseDnsResolver,
) {
private val lookupTimeout: Duration = Duration.ofSeconds(2),
maxConcurrentLookups: Int = 8,
) : DisposableBean {
companion object {
private const val MAX_PTR_HOSTNAMES_TO_VERIFY = 10
}

private val logger = logger()

// Without a queue, so a lookup either starts right away or is rejected
private val lookupExecutor = ThreadPoolExecutor(
maxConcurrentLookups,
maxConcurrentLookups,
0L,
TimeUnit.MILLISECONDS,
SynchronousQueue(),
Thread.ofPlatform().name("reverse-dns-", 0).daemon().factory(),
)

/**
* Looks up the PTR names of [ipAddress] and verifies them with forward lookups, within [lookupTimeout].
* Host names that are not verified before the deadline are returned as unverified.
*
* @throws ReverseDnsLookupCapacityException if all lookup slots are busy
*/
@Cacheable(CacheConfig.REVERSE_DNS_LOOKUPS, key = "#ipAddress", sync = true)
fun lookupHostNames(ipAddress: String): ReverseDnsLookupResult {
val hostNames = reverseDnsResolver.lookupPtrRecords(ipAddress)
val deadline = System.nanoTime() + lookupTimeout.toNanos()
val progress = LookupProgress()
val task = try {
lookupExecutor.submit { lookupHostNames(ipAddress, deadline, progress) }
} catch (_: RejectedExecutionException) {
throw ReverseDnsLookupCapacityException()
}
try {
task.get(lookupTimeout.toNanos(), TimeUnit.NANOSECONDS)
} catch (_: TimeoutException) {
task.cancel(true)
logger.debug("Reverse DNS lookup for IP {} timed out after {}", ipAddress, lookupTimeout)
} catch (e: ExecutionException) {
logger.debug("Reverse DNS lookup for IP {} failed! {}: {}", ipAddress, e.cause?.javaClass, e.cause?.message)
} catch (_: InterruptedException) {
task.cancel(true)
Thread.currentThread().interrupt()
}
return progress.toResult()
}

override fun destroy() {
lookupExecutor.shutdownNow()
}

private fun lookupHostNames(ipAddress: String, deadline: Long, progress: LookupProgress) {
progress.hostNames = reverseDnsResolver.lookupPtrRecords(ipAddress)
.map { it.trim().trimEnd('.') }
.filter { it.isNotBlank() }
.distinct()
.take(MAX_PTR_HOSTNAMES_TO_VERIFY)

if (hostNames.isEmpty()) {
return ReverseDnsLookupResult()
for (hostName in progress.hostNames) {
if (Thread.currentThread().isInterrupted || System.nanoTime() - deadline >= 0) return
if (reverseDnsResolver.lookupAddresses(hostName).any { it == ipAddress }) {
progress.verifiedHostNames += hostName
}
}
}

val (verifiedHostNames, unverifiedHostNames) = hostNames.partition { hostName ->
reverseDnsResolver.lookupAddresses(hostName).any { it == ipAddress }
}
/** Written by the lookup thread, read by the request thread, also after a timeout. */
private class LookupProgress {
@Volatile
var hostNames: List<String> = emptyList()
val verifiedHostNames: MutableSet<String> = ConcurrentHashMap.newKeySet()

return ReverseDnsLookupResult(
verifiedHostNames = verifiedHostNames,
unverifiedHostNames = unverifiedHostNames,
)
fun toResult(): ReverseDnsLookupResult {
val (verified, unverified) = hostNames.partition { it in verifiedHostNames }
return ReverseDnsLookupResult(verifiedHostNames = verified, unverifiedHostNames = unverified)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package org.tormap.service
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
import io.mockk.every
import io.mockk.mockk
import org.tormap.adapter.controller.exception.RelayNotFoundException
import org.tormap.database.entity.RelayDetails
import org.tormap.mockRelayDetails
Expand Down Expand Up @@ -63,6 +65,27 @@ class RelayDetailsQueryServiceTest : StringSpec({
response.confirmedFamilyMembers shouldBe emptyList()
}

"getRelay returns empty reverse DNS names when all lookup slots are busy" {
val relay = mockRelayDetails('A').apply {
id = 1L
familyId = null
address = "1.2.3.4"
}
val reverseDnsLookupService = mockk<ReverseDnsLookupService> {
every { lookupHostNames("1.2.3.4") } throws ReverseDnsLookupCapacityException()
}

val relayDetailsQueryService = RelayDetailsQueryService(
relayDetailsLookup = FakeRelayDetailsLookup(relaysById = mapOf(1L to relay)),
reverseDnsLookupService = reverseDnsLookupService,
)

val response = relayDetailsQueryService.getRelay(1L)

response.verifiedHostNames shouldBe emptyList()
response.unverifiedHostNames shouldBe emptyList()
}

"getRelay throws when relay does not exist" {
val relayDetailsQueryService = RelayDetailsQueryService(
relayDetailsLookup = FakeRelayDetailsLookup(),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package org.tormap.service

import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
import org.springframework.boot.test.context.SpringBootTest
Expand Down Expand Up @@ -69,11 +70,47 @@ class ReverseDnsLookupServiceIntegrationTest(

controllableResolver.lookupPtrRecordsCallCount.get() shouldBe 1
}

"lookupHostNames rejects lookups when all slots are busy and does not cache the rejection" {
val busyLookupCount = 8
controllableResolver.configure(
ptrRecords = listOf("host1.example."),
addresses = mapOf("host1.example" to listOf("1.2.3.4")),
delayMs = 1_000,
)

val executor = Executors.newFixedThreadPool(busyLookupCount)
try {
repeat(busyLookupCount) {
executor.submit { reverseDnsLookupService.lookupHostNames("10.0.0.${it + 1}") }
}
while (controllableResolver.lookupPtrRecordsCallCount.get() < busyLookupCount) Thread.sleep(10)

shouldThrow<ReverseDnsLookupCapacityException> {
reverseDnsLookupService.lookupHostNames("1.2.3.4")
}

executor.shutdown()
executor.awaitTermination(10, TimeUnit.SECONDS)
} finally {
executor.shutdownNow()
}

controllableResolver.configure(
ptrRecords = listOf("host1.example."),
addresses = mapOf("host1.example" to listOf("1.2.3.4")),
)
reverseDnsLookupService.lookupHostNames("1.2.3.4").verifiedHostNames shouldBe listOf("host1.example")
controllableResolver.lookupPtrRecordsCallCount.get() shouldBe busyLookupCount + 1
}
})

class ControllableReverseDnsResolver : ReverseDnsResolver {
@Volatile
private var ptrRecords: List<String> = emptyList()
@Volatile
private var addresses: Map<String, List<String>> = emptyMap()
@Volatile
private var delayMs: Long = 0

val lookupPtrRecordsCallCount = AtomicInteger(0)
Expand Down
Loading
Loading