diff --git a/docs/CHEATSHEET.md b/docs/CHEATSHEET.md index 6743ce1..dd3a50a 100644 --- a/docs/CHEATSHEET.md +++ b/docs/CHEATSHEET.md @@ -187,6 +187,12 @@ notify_proxy = false # use SOCKS5 for notifications Only fires when no clients are attached. +## Security + +- DCC/CTCP stripped both directions (prevents IP leaks). ACTION preserved. +- All server connections routed through SOCKS5 proxy. +- Stealth connect: random nick/user/realname on every connection. + ## Hot Reload ```bash diff --git a/docs/USAGE.md b/docs/USAGE.md index 1f99a03..6d27d60 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -651,6 +651,18 @@ Removing a channel also clears its key: /msg *bouncer AUTOJOIN libera -#secret ``` +## DCC Stripping + +DCC requests (`DCC SEND`, `DCC CHAT`) embed the sender's real IP address in the +protocol payload. The bouncer strips all DCC and non-ACTION CTCP messages in +both directions: + +- **Inbound** (server to client): silently dropped, logged as warning +- **Outbound** (client to server): blocked before reaching the network + +ACTION (`/me`) is preserved. This is a hard security boundary -- there is no +config toggle to disable it. + ## Hot Reload The bouncer reloads its config file on `SIGHUP` or via the `REHASH` command. diff --git a/src/bouncer/router.py b/src/bouncer/router.py index 7efc178..96ad8b9 100644 --- a/src/bouncer/router.py +++ b/src/bouncer/router.py @@ -67,12 +67,14 @@ def _suppress(msg: IRCMessage) -> bool: # CTCP replies in NOTICE if msg.command == "NOTICE" and len(msg.params) >= 2: if msg.params[1].startswith(_CTCP_MARKER): + log.warning("stripped inbound CTCP reply: %s %.80s", msg.prefix, msg.params[1]) return True # CTCP/DCC inside PRIVMSG (keep ACTION) if msg.command == "PRIVMSG" and len(msg.params) >= 2: text = msg.params[1] if text.startswith(_CTCP_MARKER) and not text.startswith("\x01ACTION"): + log.warning("stripped inbound CTCP/DCC: %s %.80s", msg.prefix, text) return True # User mode changes (MODE for non-channel targets) @@ -162,6 +164,13 @@ class Router: if not msg.params: return + # Block outbound CTCP/DCC (except ACTION) -- prevents IP leaks + if msg.command in ("PRIVMSG", "NOTICE") and len(msg.params) >= 2: + text = msg.params[1] + if text.startswith(_CTCP_MARKER) and not text.startswith("\x01ACTION"): + log.warning("blocked outbound CTCP/DCC: %.80s", text) + return + if msg.command == "KICK" and len(msg.params) >= 2: # KICK #channel/net nick/net :reason raw_chan, net = decode_target(msg.params[0]) diff --git a/tests/test_router.py b/tests/test_router.py index bcf83b8..1a2bf34 100644 --- a/tests/test_router.py +++ b/tests/test_router.py @@ -154,6 +154,16 @@ class TestSuppress: msg = _msg("TOPIC", ["#test", "new topic"], prefix="user!i@h") assert _suppress(msg) is False + def test_suppresses_dcc_send(self) -> None: + msg = _msg("PRIVMSG", ["nick", "\x01DCC SEND file 3232235777 5000 1024\x01"], + prefix="user!i@h") + assert _suppress(msg) is True + + def test_suppresses_dcc_chat(self) -> None: + msg = _msg("PRIVMSG", ["nick", "\x01DCC CHAT chat 3232235777 5000\x01"], + prefix="user!i@h") + assert _suppress(msg) is True + # -- Router._proxy_for ------------------------------------------------------ @@ -545,6 +555,46 @@ class TestRouteClientMessage: sent = net.send.call_args[0][0] assert sent.prefix == "me!u@h" + @pytest.mark.asyncio + async def test_blocks_outbound_dcc_send(self) -> None: + router = Router(_config(), _backlog()) + net = _mock_network("libera") + router.networks["libera"] = net + + msg = _msg("PRIVMSG", ["user/libera", "\x01DCC SEND file 3232235777 5000 1024\x01"]) + await router.route_client_message(msg) + net.send.assert_not_awaited() + + @pytest.mark.asyncio + async def test_blocks_outbound_dcc_chat(self) -> None: + router = Router(_config(), _backlog()) + net = _mock_network("libera") + router.networks["libera"] = net + + msg = _msg("PRIVMSG", ["user/libera", "\x01DCC CHAT chat 3232235777 5000\x01"]) + await router.route_client_message(msg) + net.send.assert_not_awaited() + + @pytest.mark.asyncio + async def test_passes_outbound_action(self) -> None: + router = Router(_config(), _backlog()) + net = _mock_network("libera") + router.networks["libera"] = net + + msg = _msg("PRIVMSG", ["#test/libera", "\x01ACTION waves\x01"]) + await router.route_client_message(msg) + net.send.assert_awaited_once() + + @pytest.mark.asyncio + async def test_passes_outbound_normal_privmsg(self) -> None: + router = Router(_config(), _backlog()) + net = _mock_network("libera") + router.networks["libera"] = net + + msg = _msg("PRIVMSG", ["#test/libera", "just a normal message"]) + await router.route_client_message(msg) + net.send.assert_awaited_once() + # -- _dispatch (inbound: network -> clients) ---------------------------------