1
0
Fork 0
mirror of https://github.com/Kozea/Radicale.git synced 2025-09-15 20:36:55 +00:00

auth: dovecot: pass remote IP (rip=) to auth server

If known, let the auth server know where the client came
from, using REMOTE_ADDR or, optionally/configurably, the
X-Remote-Addr header value (which is needed when running
behind a trusted proxy.)

Addresses #1859.
This commit is contained in:
Johannes Berg 2025-08-30 21:59:22 +02:00
parent 1bac038f5a
commit b5a1ea911d
8 changed files with 136 additions and 15 deletions

View file

@ -4,6 +4,7 @@
* Fix: broken start when UID does not exist (potential container startup case) * Fix: broken start when UID does not exist (potential container startup case)
* Improve: user/group retrievement for running service and directories * Improve: user/group retrievement for running service and directories
* Extend/Improve: [auth] ldap: group membership lookup * Extend/Improve: [auth] ldap: group membership lookup
* Add: option [auth] dovecot_rip_x_remote_addr
## 3.5.5 ## 3.5.5
* Improve: [auth] ldap: do not read server info by bind to avoid needless network traffic * Improve: [auth] ldap: do not read server info by bind to avoid needless network traffic

View file

@ -1187,6 +1187,26 @@ Port of via network exposed dovecot socket
Default: `12345` Default: `12345`
##### dovecot_rip_x_remote_addr
_(>= 3.5.6)_
Use the `X-Remote-Addr` value for the remote IP (rip) parameter in the
dovecot authentication protocol.
If set, Radicale must be running behind a proxy that you control and
that sets/overwrites the `X-Remote-Addr` header (doesn't pass it) so
that the value passed to dovecot is reliable. For example, for nginx,
add
```
proxy_set_header X-Remote-Addr $remote_addr;
```
to the configuration sample.
Default: `False`
##### imap_host ##### imap_host
_(>= 3.4.1)_ _(>= 3.4.1)_

3
config
View file

@ -136,6 +136,9 @@
# Port of via network exposed dovecot socket # Port of via network exposed dovecot socket
#dovecot_port = 12345 #dovecot_port = 12345
# Use X-Remote-Addr for remote IP (rip) in dovecot authentication
#dovecot_rip_x_remote_addr = False
# IMAP server hostname # IMAP server hostname
# Syntax: address | address:port | [address]:port | imap.server.tld # Syntax: address | address:port | [address]:port | imap.server.tld
#imap_host = localhost #imap_host = localhost

View file

@ -49,6 +49,7 @@ from radicale.app.propfind import ApplicationPartPropfind
from radicale.app.proppatch import ApplicationPartProppatch from radicale.app.proppatch import ApplicationPartProppatch
from radicale.app.put import ApplicationPartPut from radicale.app.put import ApplicationPartPut
from radicale.app.report import ApplicationPartReport from radicale.app.report import ApplicationPartReport
from radicale.auth import AuthContext
from radicale.log import logger from radicale.log import logger
# Combination of types.WSGIStartResponse and WSGI application return value # Combination of types.WSGIStartResponse and WSGI application return value
@ -156,6 +157,8 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
unsafe_path = environ.get("PATH_INFO", "") unsafe_path = environ.get("PATH_INFO", "")
https = environ.get("HTTPS", "") https = environ.get("HTTPS", "")
context = AuthContext()
"""Manage a request.""" """Manage a request."""
def response(status: int, headers: types.WSGIResponseHeaders, def response(status: int, headers: types.WSGIResponseHeaders,
answer: Union[None, str, bytes]) -> _IntermediateResponse: answer: Union[None, str, bytes]) -> _IntermediateResponse:
@ -201,12 +204,16 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
remote_host = "unknown" remote_host = "unknown"
if environ.get("REMOTE_HOST"): if environ.get("REMOTE_HOST"):
remote_host = repr(environ["REMOTE_HOST"]) remote_host = repr(environ["REMOTE_HOST"])
elif environ.get("REMOTE_ADDR"): if environ.get("REMOTE_ADDR"):
remote_host = environ["REMOTE_ADDR"] if remote_host == 'unknown':
remote_host = environ["REMOTE_ADDR"]
context.remote_addr = environ["REMOTE_ADDR"]
if environ.get("HTTP_X_FORWARDED_FOR"): if environ.get("HTTP_X_FORWARDED_FOR"):
reverse_proxy = True reverse_proxy = True
remote_host = "%s (forwarded for %r)" % ( remote_host = "%s (forwarded for %r)" % (
remote_host, environ["HTTP_X_FORWARDED_FOR"]) remote_host, environ["HTTP_X_FORWARDED_FOR"])
if environ.get("HTTP_X_REMOTE_ADDR"):
context.x_remote_addr = environ["HTTP_X_REMOTE_ADDR"]
if environ.get("HTTP_X_FORWARDED_HOST") or environ.get("HTTP_X_FORWARDED_PROTO") or environ.get("HTTP_X_FORWARDED_SERVER"): if environ.get("HTTP_X_FORWARDED_HOST") or environ.get("HTTP_X_FORWARDED_PROTO") or environ.get("HTTP_X_FORWARDED_SERVER"):
reverse_proxy = True reverse_proxy = True
remote_useragent = "" remote_useragent = ""
@ -295,7 +302,7 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
self.configuration, environ, base64.b64decode( self.configuration, environ, base64.b64decode(
authorization.encode("ascii"))).split(":", 1) authorization.encode("ascii"))).split(":", 1)
(user, info) = self._auth.login(login, password) or ("", "") if login else ("", "") (user, info) = self._auth.login(login, password, context) or ("", "") if login else ("", "")
if self.configuration.get("auth", "type") == "ldap": if self.configuration.get("auth", "type") == "ldap":
try: try:
logger.debug("Groups received from LDAP: %r", ",".join(self._auth._ldap_groups)) logger.debug("Groups received from LDAP: %r", ",".join(self._auth._ldap_groups))

View file

@ -91,6 +91,15 @@ def load(configuration: "config.Configuration") -> "BaseAuth":
configuration) configuration)
class AuthContext:
remote_addr: str
x_remote_addr: str
def __init__(self):
self.remote_addr = None
self.x_remote_addr = None
class BaseAuth: class BaseAuth:
_ldap_groups: Set[str] = set([]) _ldap_groups: Set[str] = set([])
@ -187,6 +196,21 @@ class BaseAuth:
raise NotImplementedError raise NotImplementedError
def _login_ext(self, login: str, password: str, context: AuthContext) -> str:
"""Check credentials and map login to internal user
``login`` the login name
``password`` the password
``context`` additional data for the login, e.g. IP address used
Returns the username or ``""`` for invalid credentials.
"""
# override this method instead of _login() if you want the context
return self._login(login, password)
def _sleep_for_constant_exec_time(self, time_ns_begin: int): def _sleep_for_constant_exec_time(self, time_ns_begin: int):
"""Sleep some time to reach a constant execution time for failed logins """Sleep some time to reach a constant execution time for failed logins
@ -216,7 +240,7 @@ class BaseAuth:
time.sleep(sleep) time.sleep(sleep)
@final @final
def login(self, login: str, password: str) -> Tuple[str, str]: def login(self, login: str, password: str, context: AuthContext) -> Tuple[str, str]:
time_ns_begin = time.time_ns() time_ns_begin = time.time_ns()
result_from_cache = False result_from_cache = False
if self._lc_username: if self._lc_username:
@ -284,7 +308,7 @@ class BaseAuth:
if result == "": if result == "":
# verify login+password via configured backend # verify login+password via configured backend
logger.debug("Login verification for user+password via backend: '%s'", login) logger.debug("Login verification for user+password via backend: '%s'", login)
result = self._login(login, password) result = self._login_ext(login, password, context)
if result != "": if result != "":
logger.debug("Login successful for user+password via backend: '%s'", login) logger.debug("Login successful for user+password via backend: '%s'", login)
if digest == "": if digest == "":
@ -314,7 +338,7 @@ class BaseAuth:
return (result, self._type) return (result, self._type)
else: else:
# self._cache_logins is False # self._cache_logins is False
result = self._login(login, password) result = self._login_ext(login, password, context)
if result == "": if result == "":
self._sleep_for_constant_exec_time(time_ns_begin) self._sleep_for_constant_exec_time(time_ns_begin)
return (result, self._type) return (result, self._type)

View file

@ -19,6 +19,7 @@
import base64 import base64
import itertools import itertools
import os import os
import re
import socket import socket
from contextlib import closing from contextlib import closing
@ -32,6 +33,8 @@ class Auth(auth.BaseAuth):
self.timeout = 5 self.timeout = 5
self.request_id_gen = itertools.count(1) self.request_id_gen = itertools.count(1)
self.use_x_remote_addr = configuration.get("auth", "dovecot_rip_x_remote_addr")
config_family = configuration.get("auth", "dovecot_connection_type") config_family = configuration.get("auth", "dovecot_connection_type")
if config_family == "AF_UNIX": if config_family == "AF_UNIX":
self.family = socket.AF_UNIX self.family = socket.AF_UNIX
@ -46,7 +49,7 @@ class Auth(auth.BaseAuth):
else: else:
self.family = socket.AF_INET6 self.family = socket.AF_INET6
def _login(self, login, password): def _login_ext(self, login, password, context):
"""Validate credentials. """Validate credentials.
Check if the ``login``/``password`` pair is valid according to Dovecot. Check if the ``login``/``password`` pair is valid according to Dovecot.
@ -148,10 +151,19 @@ class Auth(auth.BaseAuth):
"Authenticating with request id: '{}'" "Authenticating with request id: '{}'"
.format(request_id) .format(request_id)
) )
rip = b''
if self.use_x_remote_addr and context.x_remote_addr:
rip = context.x_remote_addr.encode('ascii')
elif context.remote_addr:
rip = context.remote_addr.encode('ascii')
# squash all whitespace - shouldn't be there and auth protocol
# is sensitive to whitespace (in particular \t and \n)
if rip:
rip = b'\trip=' + re.sub(br'\s', b'', rip)
sock.send( sock.send(
b'AUTH\t%u\tPLAIN\tservice=radicale\tresp=%b\n' % b'AUTH\t%u\tPLAIN\tservice=radicale%s\tresp=%b\n' %
( (
request_id, base64.b64encode( request_id, rip, base64.b64encode(
b'\0%b\0%b' % b'\0%b\0%b' %
(login.encode(), password.encode()) (login.encode(), password.encode())
) )

View file

@ -253,6 +253,10 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([
"value": "12345", "value": "12345",
"help": "dovecot auth port", "help": "dovecot auth port",
"type": int}), "type": int}),
("dovecot_rip_x_remote_addr", {
"value": "False",
"help": "use X-Remote-Addr for dovecot auth remote IP (rip) parameter",
"type": bool}),
("realm", { ("realm", {
"value": "Radicale - Password Required", "value": "Radicale - Password Required",
"help": "message displayed when a password is needed", "help": "message displayed when a password is needed",

View file

@ -282,13 +282,23 @@ class TestBaseAuthRequests(BaseTest):
@pytest.mark.skipif(sys.platform == 'win32', reason="Not supported on Windows") @pytest.mark.skipif(sys.platform == 'win32', reason="Not supported on Windows")
def _test_dovecot( def _test_dovecot(
self, user, password, expected_status, self, user, password, expected_status, expected_rip=None,
response=b'FAIL\n1\n', mech=[b'PLAIN'], broken=None): response=b'FAIL\t1', mech=[b'PLAIN'], broken=None,
extra_config=None, extra_env=None):
import socket import socket
from unittest.mock import DEFAULT, patch from unittest.mock import DEFAULT, patch
self.configure({"auth": {"type": "dovecot", if extra_env is None:
"dovecot_socket": "./dovecot.sock"}}) extra_env = {}
if extra_config is None:
extra_config = {}
config = {"auth": {"type": "dovecot",
"dovecot_socket": "./dovecot.sock"}}
for toplvl, entries in extra_config.items():
for key, val in entries.items():
config[toplvl][key] = val
self.configure(config)
if broken is None: if broken is None:
broken = [] broken = []
@ -311,10 +321,18 @@ class TestBaseAuthRequests(BaseTest):
if "done" not in broken: if "done" not in broken:
handshake += b'DONE\n' handshake += b'DONE\n'
sent_rip = None
def record_sent_data(s, data, flags=None):
nonlocal sent_rip
if b'\trip=' in data:
sent_rip = data.split(b'\trip=')[1].split(b'\t')[0]
return len(data)
with patch.multiple( with patch.multiple(
'socket.socket', 'socket.socket',
connect=DEFAULT, connect=DEFAULT,
send=DEFAULT, send=record_sent_data,
recv=DEFAULT recv=DEFAULT
) as mock_socket: ) as mock_socket:
if "socket" in broken: if "socket" in broken:
@ -325,7 +343,9 @@ class TestBaseAuthRequests(BaseTest):
status, _, answer = self.request( status, _, answer = self.request(
"PROPFIND", "/", "PROPFIND", "/",
HTTP_AUTHORIZATION="Basic %s" % base64.b64encode( HTTP_AUTHORIZATION="Basic %s" % base64.b64encode(
("%s:%s" % (user, password)).encode()).decode()) ("%s:%s" % (user, password)).encode()).decode(),
**extra_env)
assert sent_rip == expected_rip
assert status == expected_status assert status == expected_status
@pytest.mark.skipif(sys.platform == 'win32', reason="Not supported on Windows") @pytest.mark.skipif(sys.platform == 'win32', reason="Not supported on Windows")
@ -392,6 +412,36 @@ class TestBaseAuthRequests(BaseTest):
def test_dovecot_auth_id_mismatch(self): def test_dovecot_auth_id_mismatch(self):
self._test_dovecot("user", "password", 401, response=b'OK\t2') self._test_dovecot("user", "password", 401, response=b'OK\t2')
@pytest.mark.skipif(sys.platform == 'win32', reason="Not supported on Windows")
def test_dovecot_remote_addr(self):
self._test_dovecot("user", "password", 401, expected_rip=b'172.17.16.15',
extra_env={
'REMOTE_ADDR': '172.17.16.15',
'HTTP_X_REMOTE_ADDR': '127.0.0.1',
})
@pytest.mark.skipif(sys.platform == 'win32', reason="Not supported on Windows")
def test_dovecot_x_remote_addr(self):
self._test_dovecot("user", "password", 401, expected_rip=b'172.17.16.15',
extra_env={
'REMOTE_ADDR': '127.0.0.1',
'HTTP_X_REMOTE_ADDR': '172.17.16.15',
},
extra_config={
'auth': {"dovecot_rip_x_remote_addr": "True"},
})
@pytest.mark.skipif(sys.platform == 'win32', reason="Not supported on Windows")
def test_dovecot_x_remote_addr_whitespace(self):
self._test_dovecot("user", "password", 401, expected_rip=b'172.17.16.15rip=127.0.0.1',
extra_env={
'REMOTE_ADDR': '127.0.0.1',
'HTTP_X_REMOTE_ADDR': '172.17.16.15\trip=127.0.0.1',
},
extra_config={
'auth': {"dovecot_rip_x_remote_addr": "True"},
})
def test_custom(self) -> None: def test_custom(self) -> None:
"""Custom authentication.""" """Custom authentication."""
self.configure({"auth": {"type": "radicale.tests.custom.auth"}}) self.configure({"auth": {"type": "radicale.tests.custom.auth"}})