feat: DCC stripping in both directions to prevent IP leaks

Block all non-ACTION CTCP/DCC from client-to-server (outbound) and add
security logging when inbound CTCP/DCC is stripped. Hard boundary with
no config toggle -- DCC exposes the client's real IP which defeats the
stealth proxy architecture.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
user
2026-02-21 19:30:44 +01:00
parent f4f3132b6b
commit 0064e52fee
4 changed files with 77 additions and 0 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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])

View File

@@ -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) ---------------------------------