feat(exposure): Tor refuses cert-pinned/MITM-bypassed hosts (ref #797)

The per-vhost Tor channel now consults the toolbox's own MITM-exclusion lists
(ignore_hosts bypass regex + TLS-splice suffix, both operator-editable in the
Filtres MITM webui and autolearned) and hard-refuses (400) a hidden service for
any matching host — never anonymously re-expose an endpoint clients pin. reach
(lan/wan) still applies. Toolbox webui shows a Tor-exclu badge.
This commit is contained in:
CyberMind-FR 2026-07-04 08:09:12 +02:00
parent e3bb8f421c
commit c13878ecbc
6 changed files with 221 additions and 50 deletions

View File

@ -0,0 +1,99 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# Source-Disclosed License — All rights reserved except as expressly granted.
# See LICENCE-CMSD-1.0.md for terms.
"""SecuBox-Deb :: exposure.exclusion — cert-pinned / MITM-bypassed hosts.
Single source of truth is the toolbox's own MITM-exclusion lists, so a host the
toolbox refuses to intercept (cert-pinned / E2E apps, pinned APIs) is also
refused a Tor hidden service you must never anonymously re-expose an endpoint
that clients pin. The list is read live from the same files the toolbox uses,
so operator edits (Filtres MITM webui) and autolearn feed the Tor gate too:
* MITM ignore_hosts bypass (REGEX, one per line):
seed /usr/lib/secubox/toolbox/conf/mitm-bypass-seed.conf (package)
static /var/lib/secubox/toolbox/mitm-bypass.conf (webui add/remove)
learned /var/lib/secubox/toolbox/mitm-bypass-dynamic.conf (autolearn)
* TLS SNI splice (host SUFFIX, one per line):
seed /usr/lib/secubox/toolbox/conf/tls-splice-seed.conf (package)
learned /var/lib/secubox/toolbox/splice-learned.txt (autolearn)
Read-only + best-effort: an unreadable/missing list never raises it just
contributes nothing (fail-open on availability, never fail-closed on a missing
toolbox). Matching itself is fail-safe: a host that matches ANY list is excluded.
"""
from __future__ import annotations
import os
import re
from pathlib import Path
from typing import List, Set, Tuple
_BYPASS_FILES = (
os.environ.get("SECUBOX_BYPASS_SEED", "/usr/lib/secubox/toolbox/conf/mitm-bypass-seed.conf"),
os.environ.get("SECUBOX_BYPASS_STATIC", "/var/lib/secubox/toolbox/mitm-bypass.conf"),
os.environ.get("SECUBOX_BYPASS_DYNAMIC", "/var/lib/secubox/toolbox/mitm-bypass-dynamic.conf"),
)
_SPLICE_FILES = (
os.environ.get("SECUBOX_SPLICE_SEED", "/usr/lib/secubox/toolbox/conf/tls-splice-seed.conf"),
os.environ.get("SECUBOX_SPLICE_LEARNED", "/var/lib/secubox/toolbox/splice-learned.txt"),
)
def _lines(path: str) -> List[str]:
"""Non-comment, non-empty stripped lines; missing/unreadable → []."""
try:
out = []
for raw in Path(path).read_text(encoding="utf-8").splitlines():
line = raw.split("#", 1)[0].strip()
if line:
out.append(line)
return out
except OSError:
return []
def _load_bypass_regex() -> List[re.Pattern]:
pats: List[re.Pattern] = []
for f in _BYPASS_FILES:
for line in _lines(f):
try:
pats.append(re.compile(line, re.IGNORECASE))
except re.error:
continue # a malformed operator regex must not break the gate
return pats
def _load_splice_suffixes() -> Set[str]:
out: Set[str] = set()
for f in _SPLICE_FILES:
for line in _lines(f):
out.add(line.lower().strip("."))
return out
def _suffix_match(host: str, suffixes: Set[str]) -> bool:
h = (host or "").lower().strip(".")
if not h or not suffixes:
return False
if h in suffixes:
return True
return any(h.endswith("." + s) for s in suffixes)
def exclusion_reason(host: str) -> Tuple[bool, str]:
"""(excluded, reason). reason names which list matched, for audit + UI."""
h = (host or "").strip()
if not h:
return (False, "")
for pat in _load_bypass_regex():
if pat.fullmatch(h):
return (True, f"MITM-bypass (cert-pinned): {pat.pattern}")
suf = _load_splice_suffixes()
if _suffix_match(h, suf):
return (True, "TLS-splice (cert-pinned API)")
return (False, "")
def is_excluded(host: str) -> bool:
return exclusion_reason(host)[0]

View File

@ -36,6 +36,7 @@ except ImportError:
return {"sub": "dev"}
from api import reach as _reach
from api import exclusion as _excl
app = FastAPI(title="SecuBox Exposure Manager API", version="2.0.0")
@ -174,7 +175,19 @@ async def set_exposure(vhost: str, body: ExposureSet, user: dict = Depends(requi
await asyncio.to_thread(_reload_nginx) # best-effort restore of last-good
raise HTTPException(status_code=500,
detail="nginx validation failed; exposure unchanged")
# nginx reloaded OK — now apply Tor, then audit the confirmed change.
# nginx reloaded OK — reach is applied. Tor is HARD-REFUSED for cert-pinned /
# MITM-bypassed hosts (shared toolbox exclusion list — Filtres MITM + splice +
# autolearn): never anonymously re-expose an endpoint that clients pin.
if body.tor:
excluded, why = _excl.exclusion_reason(vhost)
if excluded:
rec = {"vhost": vhost, "reach": body.reach, "mesh": body.mesh, "tor": False}
_audit_exposure(vhost, rec, user.get("sub", "?"))
raise HTTPException(
status_code=400,
detail=f"Tor refusé pour {vhost} : domaine exclu — {why}. "
f"Reach '{body.reach}' appliqué.")
# not excluded — now apply Tor, then audit the confirmed change.
await _apply_tor(vhost, body.tor, user)
rec = {"vhost": vhost, "reach": body.reach, "mesh": body.mesh, "tor": body.tor}
_audit_exposure(vhost, rec, user.get("sub", "?"))

View File

@ -1,51 +1,9 @@
secubox-exposure (1.3.1-1~bookworm1) bookworm; urgency=medium
secubox-exposure (1.4.0-1~bookworm1) bookworm; urgency=medium
* fix(#793): exposure webui posts to /api/v1/exposure/exposure/{vhost} — the
aggregator mount doubles the segment; the previous single-segment path 404'd.
* feat(#): Tor channel hard-refuses cert-pinned / MITM-bypassed hosts.
Reuses the toolbox MITM-exclusion lists (ignore_hosts bypass regex + TLS
splice suffix, both autolearned) as the single source of truth, so a host
the toolbox won't intercept can't be Tor-exposed. reach still applies.
-- Gerald Kerma <devel@cybermind.fr> Sat, 04 Jul 2026 07:49:04 +0200
-- Gerald Kerma <devel@cybermind.fr> Sat, 04 Jul 2026 08:09:12 +0200
secubox-exposure (1.3.0-1~bookworm1) bookworm; urgency=medium
* feat(#793): per-vhost exposure switch — localhost/LAN/WAN reach snippet + mesh/Tor toggles, fail-safe apply (nginx -t + rollback), thread-offloaded reload/Tor, vhost-name validation, real_ip acceptance test.
-- Gerald Kerma <devel@cybermind.fr> Sat, 04 Jul 2026 07:41:45 +0200
secubox-exposure (1.2.0-1~bookworm1) bookworm; urgency=medium
* feat(#768): Tor + public egress channels federated (phases 3-4).
- Tor: emancipating with tor now also registers the .onion into the annuaire
directory (channel=tor) so the anonymous endpoint appears in every node's
mesh catalog.
- Public: --dns/domain federates a public-channel offer and emits the exact
WAF vhost recipe (DNS + acme cert + haproxyctl vhost via mitmproxy_inspector
— never waf_bypass). Deliberately NOT auto-applied (board-wide WAF blast
radius; real reach needs operator DNS+cert).
- offer_argv generalized to any channel. +2 tests.
-- Gerald Kerma <devel@cybermind.fr> Wed, 01 Jul 2026 12:00:00 +0200
secubox-exposure (1.1.0-1~bookworm1) bookworm; urgency=medium
* feat(#768): real Mesh egress channel — appstore federation phase 1.
api/mesh_egress.py: emancipating a service over the MESH channel now (1)
durably opens its port to the wg-mesh (nft allow before the terminal drop
on vortex nodes; hub 10/8-accept nodes need nothing) and (2) publishes a
signed "emancipated" ServiceOffer into the annuaire directory (annuairectl,
as secubox) so it federates into every node's mesh-wide catalog. Wired into
/emancipate (was a no-op stub for mesh). +4 unit tests.
-- Gerald Kerma <devel@cybermind.fr> Wed, 01 Jul 2026 10:00:00 +0200
secubox-exposure (1.0.0-1~bookworm1) bookworm; urgency=medium
* Initial release
* Service exposure management for SecuBox
* Tor hidden service creation and management
* SSL backend configuration
* DNS and mesh network exposure
* Emancipate/revoke service controls
* Real-time service scanning
* Dashboard with exposure status
-- Gerald KERMA <devel@cybermind.fr> Sun, 23 Mar 2026 14:30:00 +0100

View File

@ -0,0 +1,59 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
"""Tor-exclusion reader — reads the toolbox MITM-bypass (regex) + TLS-splice
(suffix) lists so cert-pinned hosts can never be Tor-exposed."""
import api.exclusion as ex
def _seed(tmp_path, monkeypatch, bypass="", splice=""):
b = tmp_path / "mitm-bypass-seed.conf"
b.write_text(bypass)
s = tmp_path / "tls-splice-seed.conf"
s.write_text(splice)
monkeypatch.setattr(ex, "_BYPASS_FILES", (str(b),))
monkeypatch.setattr(ex, "_SPLICE_FILES", (str(s),))
def test_signal_matched_via_bypass_regex(tmp_path, monkeypatch):
# the real seed ships Signal as a regex; a subdomain must match.
_seed(tmp_path, monkeypatch, bypass=r"(.+\.)?signal\.org")
assert ex.is_excluded("chat.signal.org")
assert ex.is_excluded("signal.org")
def test_anthropic_and_chatgpt_matched_via_splice_suffix(tmp_path, monkeypatch):
_seed(tmp_path, monkeypatch, splice="api.anthropic.com\nchatgpt.com\n")
assert ex.is_excluded("api.anthropic.com")
assert ex.is_excluded("chatgpt.com")
assert ex.is_excluded("web.chatgpt.com") # suffix → subdomain
def test_internal_vhost_not_excluded(tmp_path, monkeypatch):
_seed(tmp_path, monkeypatch, bypass=r"(.+\.)?signal\.org", splice="api.anthropic.com\n")
assert not ex.is_excluded("zigbee.gk2.secubox.in")
assert not ex.is_excluded("lyrion.gk2.secubox.in")
def test_lookalike_not_excluded(tmp_path, monkeypatch):
_seed(tmp_path, monkeypatch, bypass=r"(.+\.)?signal\.org", splice="api.anthropic.com\n")
assert not ex.is_excluded("evilsignal.org") # fullmatch, not substring
assert not ex.is_excluded("api.anthropic.com.evil.io")
def test_missing_lists_fail_open(tmp_path, monkeypatch):
monkeypatch.setattr(ex, "_BYPASS_FILES", (str(tmp_path / "nope.conf"),))
monkeypatch.setattr(ex, "_SPLICE_FILES", (str(tmp_path / "nope2.conf"),))
assert not ex.is_excluded("api.anthropic.com") # no toolbox → nothing excluded
def test_malformed_regex_is_skipped_not_fatal(tmp_path, monkeypatch):
_seed(tmp_path, monkeypatch, bypass="(unclosed\n(.+\\.)?signal\\.org")
# bad line skipped, good line still matches
assert ex.is_excluded("signal.org")
def test_reason_names_the_matching_list(tmp_path, monkeypatch):
_seed(tmp_path, monkeypatch, bypass=r"(.+\.)?signal\.org", splice="api.anthropic.com\n")
ok, why = ex.exclusion_reason("signal.org")
assert ok and "bypass" in why.lower()
ok, why = ex.exclusion_reason("api.anthropic.com")
assert ok and "splice" in why.lower()

View File

@ -103,3 +103,43 @@ def test_set_exposure_happy_path_writes_new_content_and_returns_record(tmp_path,
assert r.status_code == 200
assert r.json() == {"vhost": "z.example", "reach": "wan", "mesh": False, "tor": False}
assert (tmp_path / "snip" / "z.example.conf").read_text() == ""
def test_tor_hard_refused_for_excluded_host_but_reach_applied(tmp_path, monkeypatch):
"""Requesting Tor for a cert-pinned host → 400, but the reach snippet is
still written + reloaded (Tor is the only channel refused). Never calls
_apply_tor for an excluded host."""
import api.main as m
tor_calls = []
async def _spy_tor(vhost, want, user):
tor_calls.append(vhost)
monkeypatch.setattr(m, "_apply_tor", _spy_tor)
monkeypatch.setattr(m._excl, "exclusion_reason",
lambda h: (True, "MITM-bypass (cert-pinned): test"))
c = _client(tmp_path, monkeypatch, reload_ok=True)
monkeypatch.setattr(m, "_apply_tor", _spy_tor) # _client re-noops it; re-install spy
r = c.post("/exposure/api.anthropic.com", json={"reach": "lan", "mesh": False, "tor": True})
assert r.status_code == 400
assert "exclu" in r.json()["detail"].lower()
assert tor_calls == [] # never created a hidden service
assert (tmp_path / "snip" / "api.anthropic.com.conf").read_text() != "" # reach applied
def test_tor_allowed_for_non_excluded_host(tmp_path, monkeypatch):
import api.main as m
tor_calls = []
async def _spy_tor(vhost, want, user):
tor_calls.append((vhost, want))
monkeypatch.setattr(m._excl, "exclusion_reason", lambda h: (False, ""))
c = _client(tmp_path, monkeypatch, reload_ok=True)
monkeypatch.setattr(m, "_apply_tor", _spy_tor)
r = c.post("/exposure/blog.example", json={"reach": "wan", "mesh": False, "tor": True})
assert r.status_code == 200
assert r.json()["tor"] is True
assert tor_calls == [("blog.example", True)]

View File

@ -128,6 +128,7 @@
Hosts exclus de l'inspection TLS (cert-pinning détecté ou whitelist statique).
</p>
<p style="font-size:0.72rem;color:#888">Sources : 🌱 seed (paquet) · ✋ static (opérateur) · 🔍 learned (cert-pin auto)</p>
<p style="font-size:0.72rem;color:var(--p31-gold,#c9a84c)">🧅 Ces hosts (+ la liste TLS-splice) sont aussi <b>refusés à l'exposition Tor</b> par le module exposure — on ne ré-expose jamais anonymement un endpoint que les clients épinglent.</p>
<ul class="filterlist" id="filters"><li class="empty">loading…</li></ul>
</div>
</section>
@ -462,7 +463,8 @@ async function loadFilters() {
const host = typeof h === 'string' ? h : (h.host || h.pattern || JSON.stringify(h));
const src = (typeof h === 'object' && h.source) ? h.source : '';
const badge = BADGE[src] || esc(src);
return `<li><code>${esc(host)}</code>${badge ? ` <span style="color:var(--p31-dim,#888);font-size:0.72rem">${badge}</span>` : ''}</li>`;
const tor = ` <span title="refusé à l'exposition Tor" style="color:var(--p31-gold,#c9a84c);font-size:0.72rem">🧅 Tor-exclu</span>`;
return `<li><code>${esc(host)}</code>${badge ? ` <span style="color:var(--p31-dim,#888);font-size:0.72rem">${badge}</span>` : ''}${tor}</li>`;
}).join('');
}