Repository: goauthentik/authentik

Issue:
DNS Resolution in Expressions
**Is your feature request related to a problem? Please describe.**
I would like to be able to resolve a hostname in expressions. In particular, my docker container resolves `host.docker.internal` to network address of the machine the container is running on. I would like to be able to check for requests from this hostname, but there is no way to detect this in expressions.

**Describe the solution you'd like**
I would like a function to be exposed in expressions that resolves a hostname to an address or list of addresses. Such a function could be implemented in `authentik/lib/expression/evaluator.py` like this:
```py
    def expr_resolve_host(host: str) -> List[str]:
        """Resolve a hostname to a list of IP addresses."""
        
        return [
            sockaddr[0]
            for family, type, proto, canonname, sockaddr in 
            socket.getaddrinfo(host, None)
        ]
```

**Describe alternatives you've considered**
It would be possible for me to set up the docker network statically, and then hardcode the address, but this is fragile, and I would prefer for Docker to manage its network allocation.

**Additional context**
I currently have an expression that checks for requests for my local/trusted networks. This works well, but if I access Authentik directly from the docker host, the source address is the "host gateway" address of the docker container's network, which is not in my trusted networks. (The host address of the docker internal network.) I can imagine this functionality would also be useful for getting the addresses other containers the docker container is connected to.

At my scale and use case, it doesn't really matter, but it may be worth caching this information for larger installations. I'm not sure if `socket.getaddrinfo` does any caching of it's own, but if it decides to do real DNS resolution it could be quite slow. If an expression is getting hit tens or hundreds of times a minute this could be a serious issue. (This could alternatively be resolved with a local DNS caching server on the host.)

Any caching should be conservatively short, at most 60 seconds or so, since an change in address could also cause serious problems. A timeout may also be in order, since some use cases would prefer a fast empty response over an always correct answer. Ideally, these would be configurable with function parameters so users can determine their own caching and timeout needs.


Discussion:

Resolving patch:
diff --git a/authentik/lib/expression/evaluator.py b/authentik/lib/expression/evaluator.py
--- a/authentik/lib/expression/evaluator.py
+++ b/authentik/lib/expression/evaluator.py
@@ -1,9 +1,11 @@
 """authentik expression policy evaluator"""
 import re
+import socket
 from ipaddress import ip_address, ip_network
 from textwrap import indent
 from typing import Any, Iterable, Optional
 
+from cachetools import TLRUCache, cached
 from django.core.exceptions import FieldError
 from django_otp import devices_for_user
 from rest_framework.serializers import ValidationError
@@ -41,6 +43,8 @@ def __init__(self, filename: Optional[str] = None):
             "ak_is_group_member": BaseEvaluator.expr_is_group_member,
             "ak_user_by": BaseEvaluator.expr_user_by,
             "ak_user_has_authenticator": BaseEvaluator.expr_func_user_has_authenticator,
+            "resolve_dns": BaseEvaluator.expr_resolve_dns,
+            "reverse_dns": BaseEvaluator.expr_reverse_dns,
             "ak_create_event": self.expr_event_create,
             "ak_logger": get_logger(self._filename).bind(),
             "requests": get_http_session(),
@@ -49,6 +53,39 @@ def __init__(self, filename: Optional[str] = None):
         }
         self._context = {}
 
+    @cached(cache=TLRUCache(maxsize=32, ttu=lambda key, value, now: now + 180))
+    @staticmethod
+    def expr_resolve_dns(host: str, ip_version: Optional[int] = None) -> list[str]:
+        """Resolve host to a list of IPv4 and/or IPv6 addresses."""
+        # Although it seems to be fine (raising OSError), docs warn
+        # against passing `None` for both the host and the port
+        # https://docs.python.org/3/library/socket.html#socket.getaddrinfo
+        host = host or ""
+
+        ip_list = []
+
+        family = 0
+        if ip_version == 4:
+            family = socket.AF_INET
+        if ip_version == 6:
+            family = socket.AF_INET6
+
+        try:
+            for ip_addr in socket.getaddrinfo(host, None, family=family):
+                ip_list.append(str(ip_addr[4][0]))
+        except OSError:
+            pass
+        return list(set(ip_list))
+
+    @cached(cache=TLRUCache(maxsize=32, ttu=lambda key, value, now: now + 180))
+    @staticmethod
+    def expr_reverse_dns(ip_addr: str) -> str:
+        """Perform a reverse DNS lookup."""
+        try:
+            return socket.getfqdn(ip_addr)
+        except OSError:
+            return ip_addr
+
     @staticmethod
     def expr_flatten(value: list[Any] | Any) -> Optional[Any]:
         """Flatten `value` if its a list"""


Test patch: