mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-29 18:36:55 +00:00
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:
parent
e3bb8f421c
commit
c13878ecbc
99
packages/secubox-exposure/api/exclusion.py
Normal file
99
packages/secubox-exposure/api/exclusion.py
Normal 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]
|
||||
|
|
@ -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", "?"))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
59
packages/secubox-exposure/tests/test_exclusion.py
Normal file
59
packages/secubox-exposure/tests/test_exclusion.py
Normal 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()
|
||||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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('');
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user