Compare commits

...

2 Commits

Author SHA1 Message Date
75e414eb01 fix(toolbox): /social/me R3 transparent peer resolution via X-R3-Peer (ref #507)
iPhone hitting /social/me from the kbin splash on R3 tunnel was 400'ing
because the previous chain was only ?mh=<hash> + ARP _resolve(), and R3
peers are on 10.99.1.0/24 not the captive subnet.

New 3-step resolution chain (matches /report/me/html):
  1. ?mh=<hash>             — explicit hex hash in URL
  2. X-R3-Peer header       — sentinel set by mitm-wg's inject_xff
                              for transparent R3 flows ; we look up
                              the WG pubkey hash from
                              /var/lib/secubox/toolbox/wg-peers.json
                              keyed by peer IP.
  3. ARP _resolve()         — R2 captive subnet fallback

Verified live: GET /social/me with X-R3-Peer: 10.99.1.60 → 303 →
/social/<token-for-mac_hash 9433ceb90895a075>
2026-06-09 12:25:28 +02:00
4d4dacccdf fix(toolbox): Phase 11.B live-deploy hardening — addon import, static mount, kbin paths, splash link (ref #507)
Five fixes caught during the first live deploy on gk2 :

  1. mitmproxy_addons/social_graph.py: `from . import local_store` was
     never resolving because mitmproxy loads addons as top-level
     modules (not as package members).  Inlined the R3 path
     (10.99.1.0/24 → sha256(wg_pubkey)[:16]) directly.  Verified
     against the real iPhone IP (10.99.1.60 → 9433ceb90895a075).

  2. www/toolbox/social.js: the JSON fetch + wipe paths used the
     nginx-prefixed `/api/v1/toolbox/social/...`.  The kbin vhost
     routes via HAProxy straight to uvicorn (bypassing nginx) so
     that prefix never matches.  Rewrote to the FastAPI router
     paths `/social/graph/{token}` + `/social/wipe/{token}`.

  3. secubox_toolbox/app.py: FastAPI StaticFiles mount on /toolbox
     for the kbin HAProxy path (which doesn't go through nginx).
     The mount serves the same files nginx would alias from
     /usr/share/secubox/www/toolbox/.

  4. debian/postinst: chmod 0755 /usr/share/secubox/www so the
     `secubox-toolbox` service user (not in secubox group) can
     traverse to the WebUI directory.  Without this the StaticFiles
     mount crashed the service at startup with PermissionError.

  5. New /social/me endpoint + splash menu icon : self-resolving
     entry point that mints a 1 h HMAC token and 303-redirects to
     /social/{token}.  Mirrors the existing /report/me/html pattern.
     Splash page gets a 🕸️ "Ma carto" quick-nav icon.

End-to-end via gk2 live :
  /social/me?mh=<hash> → 303 → /social/{token} → 200 (4883 bytes FR)
  static assets : 200 / 7529 / 8359 / 279706 bytes
  addon smoke test : mac_hash for 10.99.1.60 → 9433ceb90895a075 ✓
2026-06-09 12:19:50 +02:00
6 changed files with 133 additions and 9 deletions

View File

@ -74,6 +74,9 @@ a:hover{text-decoration:underline}
<a href="/report/me/html" class=qi title="Mon rapport live">
<span class=qi-emoji>📊</span><span class=qi-label>Mon rapport</span>
</a>
<a href="/social/me" class=qi title="Cartographie sociale — qui me piste, où ?">
<span class=qi-emoji>🕸️</span><span class=qi-label>Ma carto</span>
</a>
<a href="/wg/ca.mobileconfig" class=qi title="CA R3 iPhone (.mobileconfig)">
<span class=qi-emoji>📲</span><span class=qi-label>CA iPhone</span>
</a>

View File

@ -46,6 +46,17 @@ case "$1" in
install -d -m 0750 -o secubox-toolbox -g secubox-toolbox /var/lib/secubox/toolbox
install -d -m 0750 -o secubox-toolbox -g secubox-toolbox /var/log/secubox
# 4a. Phase 11.B (#507) — make /usr/share/secubox/www traversable so
# the FastAPI StaticFiles mount can serve /toolbox/social.{css,js} +
# /toolbox/d3.v7.min.js to clients arriving via the kbin HAProxy
# route (which bypasses nginx and so doesn't use the nginx /toolbox/
# alias). Without this chmod the uvicorn process crashes at
# startup with PermissionError on Path("/usr/share/secubox/www/
# toolbox").is_dir(). Idempotent ; safe to repeat.
if [ -d /usr/share/secubox/www ]; then
chmod 0755 /usr/share/secubox/www 2>/dev/null || true
fi
# 4b. GeoLite2 databases (Phase 2a+ : flag emojis + ASN org)
# ASN DB from geoipupdate or Debian package geoip-database
# Country DB from db-ip.com CC-BY (no MaxMind account required)

View File

@ -82,18 +82,53 @@ def _registrable_domain(host: str) -> str:
# ─── peer identity ───
# We reuse the local_store helpers so the social addon sees the SAME
# mac_hash the rest of the toolbox does. Imported lazily to avoid a
# circular import at addon-load time.
def _client_mac_hash(flow) -> Optional[str]:
# Phase 11.A originally tried `from . import local_store` which silently
# failed because mitmproxy loads addons as top-level modules (not as
# package members), so the relative import never resolved. Inlined
# here — only the R3 path (peer IP in 10.99.1.0/24 → WG pubkey hash)
# since Phase B is R3-only. R2 captive lookup remains in local_store
# and joins later when the addon is wired into the captive mitm.
import hashlib as _hashlib
import json as _json
from pathlib import Path as _Path
_WG_PEERS_DB = _Path("/var/lib/secubox/toolbox/wg-peers.json")
_WG_PEERS_CACHE: dict = {}
_WG_PEERS_MTIME: float = 0.0
def _wg_hash_of(ip: str) -> Optional[str]:
global _WG_PEERS_MTIME
try:
from . import local_store as _ls # type: ignore
ip = _ls._peer_ip(flow)
return _ls._client_hash(ip)
if not _WG_PEERS_DB.exists():
return None
mtime = _WG_PEERS_DB.stat().st_mtime
if mtime != _WG_PEERS_MTIME or not _WG_PEERS_CACHE:
data = _json.loads(_WG_PEERS_DB.read_text()).get("peers", {})
_WG_PEERS_CACHE.clear()
for pubkey, meta in data.items():
peer_ip = meta.get("ip")
if peer_ip:
_WG_PEERS_CACHE[peer_ip] = _hashlib.sha256(
pubkey.encode()
).hexdigest()[:16]
_WG_PEERS_MTIME = mtime
return _WG_PEERS_CACHE.get(ip)
except Exception:
return None
def _client_mac_hash(flow) -> Optional[str]:
try:
if flow.client_conn and flow.client_conn.peername:
ip = flow.client_conn.peername[0]
if ip and ip.startswith("10.99.1."):
return _wg_hash_of(ip)
except Exception:
pass
return None
# ─── cookie parsers ───
_SET_COOKIE_NAMEVAL = re.compile(r"^\s*([^=;]+)\s*=\s*([^;]*)")
_COOKIE_PAIR = re.compile(r"\s*([^=;]+)\s*=\s*([^;]*)")

View File

@ -2103,6 +2103,71 @@ def _load_social_i18n(lang: str) -> dict:
return {}
@router.get("/social/me")
async def social_view_me(request: Request) -> RedirectResponse:
"""Self-resolving entry point used by the kbin splash menu.
Resolution chain (matches /report/me/html):
1. ?mh=<hash> explicit R3 hash in URL (e.g. from banner)
2. X-R3-Peer header mitm-wg's inject_xff sentinel for transparent
R3 flows (peer IP in 10.99.1.0/24)
3. _resolve() ARP lookup R2 captive subnet clients only
Mints a short-TTL (1 h) HMAC token bound to the resolved mac_hash and
redirects to /social/{token}. Keeps a single HMAC-token-gated code
path for the graph view + wipe endpoint.
"""
salt = _get_salt()
mac_hash = None
# 1) ?mh= overrides everything (used by inject_banner.py links + the
# report flow + manual curl).
mh_qp = (request.query_params.get("mh") or "").strip().lower()
if mh_qp and all(c in "0123456789abcdef" for c in mh_qp) and 8 <= len(mh_qp) <= 64:
mac_hash = mh_qp
# 2) X-R3-Peer (transparent R3 — the iPhone hits kbin via the R3
# tunnel, mitm-wg adds the sentinel, HAProxy passes it through).
# We derive the same mac_hash the local_store addon uses :
# sha256(wg_pubkey)[:16] looked up from /var/lib/secubox/toolbox/
# wg-peers.json keyed by peer IP.
if not mac_hash:
peer_ip = _client_ip(request)
if peer_ip and peer_ip.startswith("10.99.1."):
try:
import hashlib as _h
import json as _j
from pathlib import Path as _P
_DB = _P("/var/lib/secubox/toolbox/wg-peers.json")
if _DB.exists():
peers = _j.loads(_DB.read_text()).get("peers", {})
for pubkey, meta in peers.items():
if meta.get("ip") == peer_ip:
mac_hash = _h.sha256(pubkey.encode()).hexdigest()[:16]
break
except Exception as e:
log.warning("/social/me wg-peer lookup failed: %s", e)
# 3) R2 captive — ARP _resolve().
if not mac_hash:
_ip, mac = _resolve(request)
if mac:
mac_hash = macmod.hash_mac(mac, salt)
if not mac_hash:
raise HTTPException(
400,
"client identity unresolved (not on R3 tunnel and not in "
"captive subnet) — append ?mh=<hash> from your banner's "
"report link",
)
tok = reports.mint_token(mac_hash, salt, ttl_seconds=3600)
lang = request.query_params.get("lang") or ""
suffix = f"?lang={lang}" if lang else ""
return RedirectResponse(url=f"/social/{tok.token}{suffix}", status_code=303)
@router.get("/social/{token}", response_class=HTMLResponse)
async def social_view(token: str, request: Request) -> HTMLResponse:
"""Per-client social mapping HTML page.

View File

@ -6,8 +6,10 @@ from __future__ import annotations
import asyncio
import logging
from pathlib import Path
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from . import __version__, social, store, threat_intel
from .api import router as toolbox_router
@ -26,6 +28,14 @@ app = FastAPI(
)
app.include_router(toolbox_router)
# Phase 11.B (#507) — serve the WebUI assets on the same origin as
# the FastAPI HTML pages. Required because the kbin vhost routes
# through HAProxy directly to uvicorn (bypassing nginx), so the
# nginx /toolbox/ alias never gets a chance to match.
_TOOLBOX_WWW = Path("/usr/share/secubox/www/toolbox")
if _TOOLBOX_WWW.is_dir():
app.mount("/toolbox", StaticFiles(directory=_TOOLBOX_WWW), name="toolbox-www")
@app.on_event("startup")
async def _startup() -> None:

View File

@ -196,7 +196,7 @@
}
async function confirmWipe() {
try {
const r = await fetch(`/api/v1/toolbox/social/wipe/${encodeURIComponent(token)}`, { method: 'POST' });
const r = await fetch(`/social/wipe/${encodeURIComponent(token)}`, { method: 'POST' });
if (!r.ok) throw new Error('http ' + r.status);
const j = await r.json();
wipeModal.close();
@ -226,7 +226,7 @@
// ─── fetch + bootstrap ───
async function fetchGraph() {
try {
const r = await fetch(`/api/v1/toolbox/social/graph/${encodeURIComponent(token)}?since=86400`);
const r = await fetch(`/social/graph/${encodeURIComponent(token)}?since=86400`);
if (!r.ok) throw new Error('http ' + r.status);
const g = await r.json();
render(g);