[dns] move resolve code into addon

This commit is contained in:
Manuel Meitinger 2022-04-23 22:45:51 +02:00
parent c821d02e09
commit 99bcfb7f55
4 changed files with 168 additions and 162 deletions

View file

@ -1,8 +1,116 @@
import asyncio
import ipaddress
import socket
from typing import Callable, Iterable, List, Tuple, Union
from mitmproxy import ctx, dns
IP4_PTR_SUFFIX = ".in-addr.arpa"
IP6_PTR_SUFFIX = ".ip6.arpa"
class ResolveError(Exception):
"""Exception thrown by different resolve methods."""
def __init__(self, response_code: int) -> None:
assert response_code != dns.response_codes.NOERROR
self.response_code = response_code
async def resolve_question_by_name(
question: dns.Question,
loop: asyncio.AbstractEventLoop,
family: socket.AddressFamily,
ip: Callable[[str], Union[ipaddress.IPv4Address, ipaddress.IPv6Address]]
) -> Iterable[dns.ResourceRecord]:
try:
addrinfos = await loop.getaddrinfo(host=question.name, port=0, family=family)
except socket.gaierror as e:
if e.errno == socket.EAI_NONAME:
raise ResolveError(dns.response_codes.NXDOMAIN)
else:
# NOTE might fail on Windows for IPv6 queries:
# https://stackoverflow.com/questions/66755681/getaddrinfo-c-on-windows-not-handling-ipv6-correctly-returning-error-code-1
raise ResolveError(dns.response_codes.SERVFAIL)
return map(lambda addrinfo: dns.ResourceRecord(
name=question.name,
type=question.type,
class_=question.class_,
ttl=dns.ResourceRecord.DEFAULT_TTL,
data=ip(addrinfo[4][0]).packed,
), addrinfos)
async def resolve_question_by_addr(
question: dns.Question,
loop: asyncio.AbstractEventLoop,
suffix: str,
sockaddr: Callable[[List[str]], Union[Tuple[str, int], Tuple[str, int, int, int]]]
) -> Iterable[dns.ResourceRecord]:
try:
addr = sockaddr(question.name[:-len(suffix)].split(".")[::-1])
except ValueError:
raise ResolveError(dns.response_codes.FORMERR)
try:
name, _ = await loop.getnameinfo(addr, flags=socket.NI_NAMEREQD)
except socket.gaierror as e:
raise ResolveError(dns.response_codes.NXDOMAIN if e.errno == socket.EAI_NONAME else dns.response_codes.SERVFAIL)
return [dns.ResourceRecord(
name=question.name,
type=question.type,
class_=question.class_,
ttl=dns.ResourceRecord.DEFAULT_TTL,
data=dns.domain_names.pack(name),
)]
async def resolve_question(question: dns.Question) -> Iterable[dns.ResourceRecord]:
"""Resolve the question into resource record(s), throwing ResolveError if an error condition occurs."""
loop = asyncio.get_running_loop()
if question.class_ != dns.classes.IN:
raise ResolveError(dns.response_codes.NOTIMP)
if question.type == dns.types.A:
return await resolve_question_by_name(question, loop, socket.AddressFamily.AF_INET, ipaddress.IPv4Address)
elif question.type == dns.types.AAAA:
return await resolve_question_by_name(question, loop, socket.AddressFamily.AF_INET6, ipaddress.IPv6Address)
elif question.type == dns.types.PTR:
name_lower = question.name.lower()
if name_lower.endswith(IP4_PTR_SUFFIX):
return await resolve_question_by_addr(
question=question,
loop=loop,
suffix=IP4_PTR_SUFFIX,
sockaddr=lambda x: (str(ipaddress.IPv4Address(".".join(x))), 0)
)
elif name_lower.endswith(IP6_PTR_SUFFIX):
return await resolve_question_by_addr(
question=question,
loop=loop,
suffix=IP6_PTR_SUFFIX,
sockaddr=lambda x: (str(ipaddress.IPv6Address(bytes.fromhex("".join(x)))), 0, 0, 0)
)
else:
raise ResolveError(dns.response_codes.FORMERR)
else:
raise ResolveError(dns.response_codes.NOTIMP)
async def resolve_message(message: dns.Message) -> dns.Message:
try:
if not message.query:
raise ResolveError(dns.response_codes.REFUSED) # we cannot resolve an answer
if message.op_code != dns.op_codes.QUERY:
raise ResolveError(dns.response_codes.NOTIMP) # inverse queries and others are not supported
rrs: List[dns.ResourceRecord] = []
for q in message.questions:
rrs.extend(await resolve_question(q))
except ResolveError as e:
return message.fail(e.response_code)
else:
return message.succeed(rrs)
class DnsResolver:
async def dns_request(self, flow: dns.DNSFlow) -> None:
# handle regular mode requests here to not block the layer
if ctx.options.dns_mode == "regular":
flow.response = await flow.request.resolve()
flow.response = await resolve_message(flow.request)

View file

@ -1,14 +1,11 @@
from __future__ import annotations
import asyncio
from dataclasses import dataclass
import ipaddress
import itertools
import random
import socket
import struct
from ipaddress import IPv4Address, IPv6Address
import time
from typing import Callable, Iterable, List, Optional, Tuple, Union
from typing import List, Optional, Tuple, Union
from mitmproxy import connection, flow, stateobject
from mitmproxy.net.dns import classes, domain_names, op_codes, response_codes, types
@ -16,18 +13,9 @@ from mitmproxy.net.dns import classes, domain_names, op_codes, response_codes, t
# DNS parameters taken from https://www.iana.org/assignments/dns-parameters/dns-parameters.xml
class ResolveError(Exception):
"""Exception thrown by different resolve methods."""
def __init__(self, response_code: int) -> None:
assert response_code != response_codes.NOERROR
self.response_code = response_code
@dataclass
class Question(stateobject.StateObject):
HEADER = struct.Struct("!HH")
IP4_PTR_SUFFIX = ".in-addr.arpa"
IP6_PTR_SUFFIX = ".ip6.arpa"
name: str
type: int
@ -42,80 +30,6 @@ class Question(stateobject.StateObject):
def __str__(self) -> str:
return self.name
async def _resolve_by_name(
self,
loop: asyncio.AbstractEventLoop,
family: socket.AddressFamily,
ip: Callable[[str], Union[ipaddress.IPv4Address, ipaddress.IPv6Address]]
) -> Iterable[ResourceRecord]:
try:
addrinfos = await loop.getaddrinfo(host=self.name, port=0, family=family)
except socket.gaierror as e:
if e.errno == socket.EAI_NONAME:
raise ResolveError(response_codes.NXDOMAIN)
else:
# NOTE might fail on Windows for IPv6 queries:
# https://stackoverflow.com/questions/66755681/getaddrinfo-c-on-windows-not-handling-ipv6-correctly-returning-error-code-1
raise ResolveError(response_codes.SERVFAIL)
return map(lambda addrinfo: ResourceRecord(
name=self.name,
type=self.type,
class_=self.class_,
ttl=ResourceRecord.DEFAULT_TTL,
data=ip(addrinfo[4][0]).packed,
), addrinfos)
async def _resolve_by_addr(
self,
loop: asyncio.AbstractEventLoop,
suffix: str,
sockaddr: Callable[[List[str]], Union[Tuple[str, int], Tuple[str, int, int, int]]]
) -> Iterable[ResourceRecord]:
try:
addr = sockaddr(self.name[:-len(suffix)].split(".")[::-1])
except ValueError:
raise ResolveError(response_codes.FORMERR)
try:
name, _ = await loop.getnameinfo(addr, flags=socket.NI_NAMEREQD)
except socket.gaierror as e:
raise ResolveError(response_codes.NXDOMAIN if e.errno == socket.EAI_NONAME else response_codes.SERVFAIL)
return [ResourceRecord(
name=self.name,
type=self.type,
class_=self.class_,
ttl=ResourceRecord.DEFAULT_TTL,
data=domain_names.pack(name),
)]
async def resolve(self) -> Iterable[ResourceRecord]:
"""Resolve the question into resource record(s), throwing ResolveError if an error condition occurs."""
loop = asyncio.get_running_loop()
if self.class_ != classes.IN:
raise ResolveError(response_codes.NOTIMP)
if self.type == types.A:
return await self._resolve_by_name(loop, socket.AddressFamily.AF_INET, ipaddress.IPv4Address)
elif self.type == types.AAAA:
return await self._resolve_by_name(loop, socket.AddressFamily.AF_INET6, ipaddress.IPv6Address)
elif self.type == types.PTR:
name_lower = self.name.lower()
if name_lower.endswith(Question.IP4_PTR_SUFFIX):
return await self._resolve_by_addr(
loop=loop,
suffix=Question.IP4_PTR_SUFFIX,
sockaddr=lambda x: (str(ipaddress.IPv4Address(".".join(x))), 0)
)
elif name_lower.endswith(Question.IP6_PTR_SUFFIX):
return await self._resolve_by_addr(
loop=loop,
suffix=Question.IP6_PTR_SUFFIX,
sockaddr=lambda x: (str(ipaddress.IPv6Address(bytes.fromhex("".join(x)))), 0, 0, 0)
)
else:
raise ResolveError(response_codes.FORMERR)
else:
raise ResolveError(response_codes.NOTIMP)
def to_json(self) -> dict:
"""
Converts the question into json for mitmweb.
@ -350,21 +264,6 @@ class Message(stateobject.StateObject):
additionals=[],
)
async def resolve(self) -> Message:
"""Resolves the message and return the result in form of a response message."""
try:
if not self.query:
raise ResolveError(response_codes.REFUSED) # we cannot resolve an answer
if self.op_code != op_codes.QUERY:
raise ResolveError(response_codes.NOTIMP) # inverse queries and others are not supported
rrs: List[ResourceRecord] = []
for q in self.questions:
rrs.extend(await q.resolve())
except ResolveError as e:
return self.fail(e.response_code)
else:
return self.succeed(rrs)
@classmethod
def unpack(cls, buffer: bytes) -> Message:
"""Converts the entire given buffer into a DNS message."""

View file

@ -1,12 +1,17 @@
import asyncio
import ipaddress
import platform
from typing import Callable
import pytest
from mitmproxy import dns
from mitmproxy.addons import dns_resolver, proxyserver
from mitmproxy.test import taddons, tflow
from mitmproxy.test import taddons, tflow, tutils
async def test_simple(monkeypatch):
monkeypatch.setattr(dns.Message, "resolve", lambda _: asyncio.sleep(0, "resp"))
monkeypatch.setattr(dns_resolver, "resolve_message", lambda _: asyncio.sleep(0, "resp"))
dr = dns_resolver.DnsResolver()
with taddons.context(dr, proxyserver.Proxyserver()) as tctx:
@ -18,3 +23,54 @@ async def test_simple(monkeypatch):
f = tflow.tdnsflow()
await dr.dns_request(f)
assert not f.response
@pytest.mark.skip("requires internet connection")
async def test_resolve():
async def fail_with(question: dns.Question, code: int):
with pytest.raises(dns_resolver.ResolveError) as ex:
await dns_resolver.resolve_question(question)
assert ex.value.response_code == code
async def succeed_with(question: dns.Question, check: Callable[[dns.ResourceRecord], bool]):
assert any(map(check, await dns_resolver.resolve_question(question)))
await fail_with(dns.Question("dns.google", dns.types.A, dns.classes.CH), dns.response_codes.NOTIMP)
await fail_with(dns.Question("not.exists", dns.types.A, dns.classes.IN), dns.response_codes.NXDOMAIN)
await fail_with(dns.Question("dns.google", dns.types.SOA, dns.classes.IN), dns.response_codes.NOTIMP)
await fail_with(dns.Question("totally.invalid", dns.types.PTR, dns.classes.IN), dns.response_codes.FORMERR)
await fail_with(dns.Question("invalid.in-addr.arpa", dns.types.PTR, dns.classes.IN), dns.response_codes.FORMERR)
await fail_with(dns.Question("0.0.0.1.in-addr.arpa", dns.types.PTR, dns.classes.IN), dns.response_codes.NXDOMAIN)
await succeed_with(
dns.Question("dns.google", dns.types.A, dns.classes.IN),
lambda rr: rr.ipv4_address == ipaddress.IPv4Address("8.8.8.8")
)
if platform.system() == "Linux": # will fail on Windows, apparently returns empty on Mac
await succeed_with(
dns.Question("dns.google", dns.types.AAAA, dns.classes.IN),
lambda rr: rr.ipv6_address == ipaddress.IPv6Address("2001:4860:4860::8888")
)
await succeed_with(
dns.Question("8.8.8.8.in-addr.arpa", dns.types.PTR, dns.classes.IN),
lambda rr: rr.domain_name == "dns.google"
)
await succeed_with(
dns.Question("8.8.8.8.in-addr.arpa", dns.types.PTR, dns.classes.IN),
lambda rr: rr.domain_name == "dns.google"
)
await succeed_with(
dns.Question("8.8.8.8.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.6.8.4.0.6.8.4.1.0.0.2.ip6.arpa", dns.types.PTR, dns.classes.IN),
lambda rr: rr.domain_name == "dns.google"
)
req = tutils.tdnsreq()
req.query = False
assert (await dns_resolver.resolve_message(req)).response_code == dns.response_codes.REFUSED
req.query = True
req.op_code = dns.op_codes.IQUERY
assert (await dns_resolver.resolve_message(req)).response_code == dns.response_codes.NOTIMP
req.op_code = dns.op_codes.QUERY
resp = await dns_resolver.resolve_message(req)
assert resp.response_code == dns.response_codes.NOERROR
assert filter(lambda rr: str(rr.ipv4_address) == "8.8.8.8", resp.answers)

View file

@ -1,7 +1,5 @@
import ipaddress
import platform
import struct
from typing import Callable
import pytest
from mitmproxy import dns
@ -35,63 +33,8 @@ class TestResourceRecord:
assert rr.text == "sample text"
class TestQuestion:
@pytest.mark.skip("requires internet connection")
async def test_resolve(self):
async def fail_with(question: dns.Question, code: int):
with pytest.raises(dns.ResolveError) as ex:
await question.resolve()
assert ex.value.response_code == code
async def succeed_with(question: dns.Question, check: Callable[[dns.ResourceRecord], bool]):
assert any(map(check, await question.resolve()))
await fail_with(dns.Question("dns.google", dns.types.A, dns.classes.CH), dns.response_codes.NOTIMP)
await fail_with(dns.Question("not.exists", dns.types.A, dns.classes.IN), dns.response_codes.NXDOMAIN)
await fail_with(dns.Question("dns.google", dns.types.SOA, dns.classes.IN), dns.response_codes.NOTIMP)
await fail_with(dns.Question("totally.invalid", dns.types.PTR, dns.classes.IN), dns.response_codes.FORMERR)
await fail_with(dns.Question("invalid.in-addr.arpa", dns.types.PTR, dns.classes.IN), dns.response_codes.FORMERR)
await fail_with(dns.Question("0.0.0.1.in-addr.arpa", dns.types.PTR, dns.classes.IN), dns.response_codes.NXDOMAIN)
await succeed_with(
dns.Question("dns.google", dns.types.A, dns.classes.IN),
lambda rr: rr.ipv4_address == ipaddress.IPv4Address("8.8.8.8")
)
if platform.system() == "Linux": # will fail on Windows, apparently returns empty on Mac
await succeed_with(
dns.Question("dns.google", dns.types.AAAA, dns.classes.IN),
lambda rr: rr.ipv6_address == ipaddress.IPv6Address("2001:4860:4860::8888")
)
await succeed_with(
dns.Question("8.8.8.8.in-addr.arpa", dns.types.PTR, dns.classes.IN),
lambda rr: rr.domain_name == "dns.google"
)
await succeed_with(
dns.Question("8.8.8.8.in-addr.arpa", dns.types.PTR, dns.classes.IN),
lambda rr: rr.domain_name == "dns.google"
)
await succeed_with(
dns.Question("8.8.8.8.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.6.8.4.0.6.8.4.1.0.0.2.ip6.arpa", dns.types.PTR, dns.classes.IN),
lambda rr: rr.domain_name == "dns.google"
)
class TestMessage:
@pytest.mark.asyncio
async def test_resolve(self):
req = tutils.tdnsreq()
req.query = False
assert (await req.resolve()).response_code == dns.response_codes.REFUSED
req.query = True
req.op_code = dns.op_codes.IQUERY
assert (await req.resolve()).response_code == dns.response_codes.NOTIMP
req.op_code = dns.op_codes.QUERY
resp = await req.resolve()
assert resp.response_code == dns.response_codes.NOERROR
assert filter(lambda rr: str(rr.ipv4_address) == "8.8.8.8", resp.answers)
def test_responses(self):
req = tutils.tdnsreq()
resp = tutils.tdnsresp()