diff --git a/mitmproxy/addons/dns_resolver.py b/mitmproxy/addons/dns_resolver.py index cf25eee33..a0fb51d39 100644 --- a/mitmproxy/addons/dns_resolver.py +++ b/mitmproxy/addons/dns_resolver.py @@ -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) diff --git a/mitmproxy/dns.py b/mitmproxy/dns.py index c9d5a4d4d..0f4313c72 100644 --- a/mitmproxy/dns.py +++ b/mitmproxy/dns.py @@ -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.""" diff --git a/test/mitmproxy/addons/test_dns_resolver.py b/test/mitmproxy/addons/test_dns_resolver.py index 68eb7a711..0af4bc9e4 100644 --- a/test/mitmproxy/addons/test_dns_resolver.py +++ b/test/mitmproxy/addons/test_dns_resolver.py @@ -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) diff --git a/test/mitmproxy/test_dns.py b/test/mitmproxy/test_dns.py index 599eeecf1..401e16b43 100644 --- a/test/mitmproxy/test_dns.py +++ b/test/mitmproxy/test_dns.py @@ -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()