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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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) ---------------------------------
|
||||
|
||||
|
||||
Reference in New Issue
Block a user