- asyncio.Protocol (TCP): _transport: asyncio.Transport | None = None + cast() in connection_made; assert guards in every method that directly accesses the field. Files: pop3, smtp, mqtt, postgres, mssql, mongodb, imap, ldap, redis, mysql, sip, vnc. - asyncio.DatagramProtocol (UDP): _transport: asyncio.DatagramTransport | None = None. Files: snmp, tftp, SIPUDPProtocol. - RDP: assert new_transport is not None after start_tls() to narrow Transport | None. - FTP (Twisted): assert self.transport is not None + targeted type: ignore for imprecise Twisted stubs (misc/override/arg-type/attr-defined), IReactorTCP cast for listenTCP. - conpot: proc.stdout is None guard before iteration. - Bonus fixes surfaced by annotation: - smtp: get_payload(decode=True) bytes narrowing (arg-type on sha256) - postgres: rename shadowed `msg` param to `err_msg` in _handle_startup - mongodb: base64.binascii.Error → import binascii; binascii.Error - imap: result: list[int] = [] (var-annotated)
215 lines
6.9 KiB
Python
215 lines
6.9 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
LDAPserver.
|
|
Parses BER-encoded BindRequest messages, logs DN and password, returns an
|
|
invalidCredentials error. Logs all interactions as JSON.
|
|
"""
|
|
|
|
import asyncio
|
|
import os
|
|
import re
|
|
from typing import cast
|
|
|
|
import instance_seed as _seed
|
|
from syslog_bridge import (
|
|
encode_secret,
|
|
forward_syslog,
|
|
syslog_line,
|
|
write_syslog_file,
|
|
)
|
|
|
|
NODE_NAME = os.environ.get("NODE_NAME", "ldapserver")
|
|
SERVICE_NAME = "ldap"
|
|
LOG_TARGET = os.environ.get("LOG_TARGET", "")
|
|
|
|
# RFC 4514 distinguished-name grammar: DN is a sequence of comma-separated
|
|
# RDNs like "cn=foo,ou=people,dc=example,dc=com". Each RDN is
|
|
# attribute=value, attribute matches [A-Za-z][A-Za-z0-9-]*. We keep this
|
|
# check loose on value contents (commas can be escaped etc.) but tight on
|
|
# shape, so garbage like `"abc"` or `\x00\x00` gets rejected with
|
|
# invalidDNSyntax (34) instead of invalidCredentials (49) — that's how a
|
|
# real OpenLDAP replies.
|
|
_RDN_RE = re.compile(r"^[A-Za-z][A-Za-z0-9-]*=.+$")
|
|
|
|
|
|
def _is_valid_dn(dn: str) -> bool:
|
|
"""True for empty (anonymous bind) or RFC 4514-shaped DN."""
|
|
if dn == "":
|
|
return True
|
|
if len(dn) > 1024:
|
|
return False
|
|
# Split on unescaped commas. Not perfect, but catches the obvious
|
|
# "not a DN" inputs (missing '=' in some RDN, empty segments, etc.).
|
|
parts: list[str] = []
|
|
buf = ""
|
|
escape = False
|
|
for ch in dn:
|
|
if escape:
|
|
buf += ch
|
|
escape = False
|
|
continue
|
|
if ch == "\\":
|
|
buf += ch
|
|
escape = True
|
|
continue
|
|
if ch == ",":
|
|
parts.append(buf)
|
|
buf = ""
|
|
continue
|
|
buf += ch
|
|
parts.append(buf)
|
|
return all(_RDN_RE.match(p.strip()) for p in parts)
|
|
|
|
|
|
|
|
|
|
def _log(event_type: str, severity: int = 6, **kwargs) -> None:
|
|
line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs)
|
|
write_syslog_file(line)
|
|
forward_syslog(line, LOG_TARGET)
|
|
|
|
|
|
def _ber_length(data: bytes, pos: int):
|
|
"""Return (length, next_pos)."""
|
|
b = data[pos]
|
|
if b < 0x80:
|
|
return b, pos + 1
|
|
n = b & 0x7f
|
|
length = int.from_bytes(data[pos + 1:pos + 1 + n], "big")
|
|
return length, pos + 1 + n
|
|
|
|
|
|
def _ber_string(data: bytes, pos: int):
|
|
"""Skip tag byte, read BER length, return (string, next_pos)."""
|
|
pos += 1 # skip tag
|
|
length, pos = _ber_length(data, pos)
|
|
return data[pos:pos + length].decode(errors="replace"), pos + length
|
|
|
|
|
|
def _parse_bind_request(msg: bytes):
|
|
"""Best-effort extraction of (dn, password) from a raw LDAPMessage."""
|
|
try:
|
|
pos = 0
|
|
# LDAPMessage SEQUENCE
|
|
assert msg[pos] == 0x30 # nosec B101
|
|
pos += 1
|
|
_, pos = _ber_length(msg, pos)
|
|
# messageID INTEGER
|
|
assert msg[pos] == 0x02 # nosec B101
|
|
pos += 1
|
|
id_len, pos = _ber_length(msg, pos)
|
|
pos += id_len
|
|
# BindRequest [APPLICATION 0]
|
|
assert msg[pos] == 0x60 # nosec B101
|
|
pos += 1
|
|
_, pos = _ber_length(msg, pos)
|
|
# version INTEGER
|
|
assert msg[pos] == 0x02 # nosec B101
|
|
pos += 1
|
|
v_len, pos = _ber_length(msg, pos)
|
|
pos += v_len
|
|
# name LDAPDN (OCTET STRING)
|
|
dn, pos = _ber_string(msg, pos)
|
|
# authentication CHOICE — simple [0] OCTET STRING
|
|
if msg[pos] == 0x80:
|
|
pos += 1
|
|
pw_len, pos = _ber_length(msg, pos)
|
|
password = msg[pos:pos + pw_len].decode(errors="replace")
|
|
else:
|
|
password = "<sasl_or_unknown>" # nosec B105
|
|
return dn, password
|
|
except Exception:
|
|
return "<parse_error>", "<parse_error>"
|
|
|
|
|
|
def _bind_error_response(message_id: int, result_code: int = 49, error_text: str = "") -> bytes:
|
|
"""BindResponse with a configurable resultCode + diagnosticMessage.
|
|
49 = invalidCredentials, 34 = invalidDNSyntax, 53 = unwillingToPerform."""
|
|
err_bytes = error_text.encode()
|
|
result_enc = bytes([0x0a, 0x01, result_code & 0xff])
|
|
matched_dn = bytes([0x04, 0x00])
|
|
error_msg = bytes([0x04, len(err_bytes)]) + err_bytes
|
|
bind_resp_body = result_enc + matched_dn + error_msg
|
|
bind_resp = bytes([0x61, len(bind_resp_body)]) + bind_resp_body
|
|
|
|
msg_id_enc = bytes([0x02, 0x01, message_id & 0xff])
|
|
ldap_msg_body = msg_id_enc + bind_resp
|
|
return bytes([0x30, len(ldap_msg_body)]) + ldap_msg_body
|
|
|
|
|
|
class LDAPProtocol(asyncio.Protocol):
|
|
_transport: asyncio.Transport | None = None
|
|
_peer: tuple[str, int] | None = None
|
|
|
|
def __init__(self):
|
|
self._transport = None
|
|
self._peer = None
|
|
self._buf = b""
|
|
|
|
def connection_made(self, transport: asyncio.BaseTransport) -> None:
|
|
self._transport = cast(asyncio.Transport, transport)
|
|
self._peer = cast(tuple[str, int], self._transport.get_extra_info("peername", ("?", 0)))
|
|
_log("connect", src=self._peer[0], src_port=self._peer[1])
|
|
|
|
def data_received(self, data):
|
|
self._buf += data
|
|
self._process()
|
|
|
|
def _process(self):
|
|
while len(self._buf) >= 2:
|
|
if self._buf[0] != 0x30:
|
|
self._buf = b""
|
|
return
|
|
if self._buf[1] < 0x80:
|
|
msg_len = self._buf[1] + 2
|
|
elif self._buf[1] == 0x81:
|
|
if len(self._buf) < 3:
|
|
return
|
|
msg_len = self._buf[2] + 3
|
|
else:
|
|
self._buf = b""
|
|
return
|
|
if len(self._buf) < msg_len:
|
|
return
|
|
msg = self._buf[:msg_len]
|
|
self._buf = self._buf[msg_len:]
|
|
self._handle_message(msg)
|
|
|
|
def _handle_message(self, msg: bytes) -> None:
|
|
assert self._transport is not None
|
|
assert self._peer is not None
|
|
# Extract messageID for the response
|
|
try:
|
|
message_id = msg[4] if len(msg) > 4 else 1
|
|
except Exception:
|
|
message_id = 1
|
|
dn, password = _parse_bind_request(msg)
|
|
_log("bind", src=self._peer[0], dn=dn, principal=dn,
|
|
**encode_secret(password))
|
|
_seed.jitter_sync(10, 60)
|
|
if dn and not _is_valid_dn(dn):
|
|
# OpenLDAP returns invalidDNSyntax (34) for malformed DNs, with
|
|
# a diagnostic like: "invalid DN syntax". Matching that exactly
|
|
# keeps the decoy consistent with what a scanner expects.
|
|
self._transport.write(_bind_error_response(
|
|
message_id, result_code=34,
|
|
error_text="invalid DN"
|
|
))
|
|
else:
|
|
self._transport.write(_bind_error_response(message_id))
|
|
|
|
def connection_lost(self, exc):
|
|
_log("disconnect", src=self._peer[0] if self._peer else "?")
|
|
|
|
|
|
async def main():
|
|
_log("startup", msg=f"LDAP server starting as {NODE_NAME}")
|
|
loop = asyncio.get_running_loop()
|
|
server = await loop.create_server(LDAPProtocol, "0.0.0.0", 389) # nosec B104
|
|
async with server:
|
|
await server.serve_forever()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
asyncio.run(main())
|