feat: add proxy template origin field and implement socket utilities for log streaming

This commit is contained in:
gabdevele
2026-05-07 10:54:57 +02:00 Unverified
parent 53dcdafdb1
commit 354ec150bc
32 changed files with 1681 additions and 1214 deletions
+19 -128
View File
@@ -5,15 +5,20 @@ from fastapi import APIRouter, Depends, HTTPException, WebSocket, status
from pymongo.errors import DuplicateKeyError from pymongo.errors import DuplicateKeyError
from starlette.websockets import WebSocketDisconnect from starlette.websockets import WebSocketDisconnect
from app.core.config import settings from app.api.proxy.socket_utils import (
from app.core.security import ( authenticate_proxy_socket,
InvalidTokenError, proxy_log_path,
authenticate_token, read_log_snapshot,
require_authenticated_user, serialize_health_snapshot,
snapshot_log_state,
stream_proxy_logs,
watch_socket_disconnect,
) )
from app.core.config import settings
from app.core.security import InvalidTokenError, require_authenticated_user
from app.integrations.docker import get_all_services_in_dir from app.integrations.docker import get_all_services_in_dir
from app.integrations.docker.discovery import service_scan_root from app.integrations.docker.discovery import service_scan_root
from app.models.health import HealthHistoryResponse, HealthSnapshot from app.models.health import HealthHistoryResponse
from app.models.proxy import ProxyCreateInput, ProxyUpdateInput from app.models.proxy import ProxyCreateInput, ProxyUpdateInput
from app.models.template import ProxyTemplateCreateInput from app.models.template import ProxyTemplateCreateInput
from app.repositories.health import ( from app.repositories.health import (
@@ -51,7 +56,7 @@ from app.services.mitmproxy.runtime_validation import (
backend_port, backend_port,
ensure_proxy_port_not_reserved, ensure_proxy_port_not_reserved,
) )
from app.services.mitmproxy.templates import BUILTIN_TEMPLATES from app.services.mitmproxy.templates import file_template_ids, list_file_templates
from app.services.mitmproxy.validator import ( from app.services.mitmproxy.validator import (
ProxyCodeValidationError, ProxyCodeValidationError,
validate_proxy_code, validate_proxy_code,
@@ -152,125 +157,6 @@ async def get_proxy_or_404(proxy_port: int):
return proxy return proxy
async def authenticate_proxy_socket(websocket: WebSocket) -> None:
token = websocket.query_params.get("token")
if token is None:
authorization = websocket.headers.get("authorization", "")
scheme, _, value = authorization.partition(" ")
if scheme.lower() == "bearer" and value:
token = value
if not token:
raise InvalidTokenError
await authenticate_token(token)
def proxy_log_path(proxy) -> Path:
if proxy.log_path:
return Path(proxy.log_path)
return manager.log_path_for(proxy)
def serialize_health_snapshot(
snapshot: HealthSnapshot | None,
) -> dict[str, object] | None:
if snapshot is None:
return None
return snapshot.model_dump(mode="json")
async def read_log_snapshot(path: Path, limit: int = 8000) -> str:
snapshot, _offset, _marker = await snapshot_log_state(path, limit=limit)
return snapshot
async def snapshot_log_state(
path: Path,
limit: int = 8000,
marker_width: int = 64,
) -> tuple[str, int, bytes]:
if not await asyncio.to_thread(path.is_file):
return "", 0, b""
data = await asyncio.to_thread(path.read_bytes)
size = len(data)
marker = data[max(0, size - marker_width) : size]
snapshot = data[-limit:].decode("utf-8", errors="replace")
return snapshot, size, marker
def _read_log_marker_bytes(path: Path, offset: int, width: int) -> bytes:
if offset <= 0 or not path.is_file():
return b""
start = max(0, offset - width)
with path.open("rb") as handle:
handle.seek(start)
return handle.read(offset - start)
async def read_log_marker(path: Path, offset: int, width: int = 64) -> bytes:
return await asyncio.to_thread(_read_log_marker_bytes, path, offset, width)
async def watch_socket_disconnect(websocket: WebSocket) -> None:
while True:
await websocket.receive_text()
async def stream_proxy_logs(
websocket: WebSocket,
path: Path,
*,
offset: int,
marker: bytes,
) -> None:
while True:
if not await asyncio.to_thread(path.exists):
await asyncio.sleep(0.4)
continue
try:
size = await asyncio.to_thread(lambda: path.stat().st_size)
except OSError:
await asyncio.sleep(0.4)
continue
if marker and size >= offset and await read_log_marker(path, offset) != marker:
snapshot, offset, marker = await snapshot_log_state(path)
await websocket.send_json({"type": "snapshot", "log": snapshot})
await asyncio.sleep(0.4)
continue
if size < offset:
snapshot, offset, marker = await snapshot_log_state(path)
await websocket.send_json({"type": "snapshot", "log": snapshot})
await asyncio.sleep(0.4)
continue
if size > offset:
def read_log_chunk(
chunk_offset: int = offset,
chunk_size: int = size,
) -> str:
with path.open("rb") as handle:
handle.seek(chunk_offset)
return handle.read(chunk_size - chunk_offset).decode(
"utf-8",
errors="replace",
)
chunk = await asyncio.to_thread(read_log_chunk)
offset = size
marker = await read_log_marker(path, offset)
if chunk:
await websocket.send_json({"type": "append", "log": chunk})
await asyncio.sleep(0.4)
@router.get("", dependencies=auth_dependencies) @router.get("", dependencies=auth_dependencies)
async def list_proxies_handler(): async def list_proxies_handler():
return await list_proxy_documents() return await list_proxy_documents()
@@ -295,7 +181,7 @@ async def list_mitmproxy_options():
@router.get("/mitmproxy/templates", dependencies=auth_dependencies) @router.get("/mitmproxy/templates", dependencies=auth_dependencies)
async def list_mitmproxy_templates(): async def list_mitmproxy_templates():
return [*BUILTIN_TEMPLATES, *await list_custom_templates()] return [*list_file_templates(), *await list_custom_templates()]
@router.post( @router.post(
@@ -305,6 +191,11 @@ async def list_mitmproxy_templates():
) )
async def create_mitmproxy_template(payload: ProxyTemplateCreateInput): async def create_mitmproxy_template(payload: ProxyTemplateCreateInput):
await _validate_code_or_raise(payload.code) await _validate_code_or_raise(payload.code)
if payload.id in file_template_ids():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Template already exists: {payload.id}",
)
try: try:
return await create_custom_template(payload) return await create_custom_template(payload)
except DuplicateKeyError as error: except DuplicateKeyError as error:
@@ -320,7 +211,7 @@ async def create_mitmproxy_template(payload: ProxyTemplateCreateInput):
dependencies=auth_dependencies, dependencies=auth_dependencies,
) )
async def delete_mitmproxy_template(template_id: str): async def delete_mitmproxy_template(template_id: str):
if any(template.id == template_id for template in BUILTIN_TEMPLATES): if template_id in file_template_ids():
raise HTTPException( raise HTTPException(
status_code=status.HTTP_409_CONFLICT, status_code=status.HTTP_409_CONFLICT,
detail="Built-in templates cannot be deleted", detail="Built-in templates cannot be deleted",
+127
View File
@@ -0,0 +1,127 @@
import asyncio
from pathlib import Path
from fastapi import WebSocket
from app.core.security import InvalidTokenError, authenticate_token
from app.models.health import HealthSnapshot
from app.services.mitmproxy.manager import manager
async def authenticate_proxy_socket(websocket: WebSocket) -> None:
token = websocket.query_params.get("token")
if token is None:
authorization = websocket.headers.get("authorization", "")
scheme, _, value = authorization.partition(" ")
if scheme.lower() == "bearer" and value:
token = value
if not token:
raise InvalidTokenError
await authenticate_token(token)
def proxy_log_path(proxy) -> Path:
if proxy.log_path:
return Path(proxy.log_path)
return manager.log_path_for(proxy)
def serialize_health_snapshot(
snapshot: HealthSnapshot | None,
) -> dict[str, object] | None:
if snapshot is None:
return None
return snapshot.model_dump(mode="json")
async def read_log_snapshot(path: Path, limit: int = 8000) -> str:
snapshot, _offset, _marker = await snapshot_log_state(path, limit=limit)
return snapshot
async def snapshot_log_state(
path: Path,
limit: int = 8000,
marker_width: int = 64,
) -> tuple[str, int, bytes]:
if not await asyncio.to_thread(path.is_file):
return "", 0, b""
data = await asyncio.to_thread(path.read_bytes)
size = len(data)
marker = data[max(0, size - marker_width) : size]
snapshot = data[-limit:].decode("utf-8", errors="replace")
return snapshot, size, marker
def _read_log_marker_bytes(path: Path, offset: int, width: int) -> bytes:
if offset <= 0 or not path.is_file():
return b""
start = max(0, offset - width)
with path.open("rb") as handle:
handle.seek(start)
return handle.read(offset - start)
async def read_log_marker(path: Path, offset: int, width: int = 64) -> bytes:
return await asyncio.to_thread(_read_log_marker_bytes, path, offset, width)
async def watch_socket_disconnect(websocket: WebSocket) -> None:
while True:
await websocket.receive_text()
async def stream_proxy_logs(
websocket: WebSocket,
path: Path,
*,
offset: int,
marker: bytes,
) -> None:
while True:
if not await asyncio.to_thread(path.exists):
await asyncio.sleep(0.4)
continue
try:
size = await asyncio.to_thread(lambda: path.stat().st_size)
except OSError:
await asyncio.sleep(0.4)
continue
if marker and size >= offset and await read_log_marker(path, offset) != marker:
snapshot, offset, marker = await snapshot_log_state(path)
await websocket.send_json({"type": "snapshot", "log": snapshot})
await asyncio.sleep(0.4)
continue
if size < offset:
snapshot, offset, marker = await snapshot_log_state(path)
await websocket.send_json({"type": "snapshot", "log": snapshot})
await asyncio.sleep(0.4)
continue
if size > offset:
def read_log_chunk(
chunk_offset: int = offset,
chunk_size: int = size,
) -> str:
with path.open("rb") as handle:
handle.seek(chunk_offset)
return handle.read(chunk_size - chunk_offset).decode(
"utf-8",
errors="replace",
)
chunk = await asyncio.to_thread(read_log_chunk)
offset = size
marker = await read_log_marker(path, offset)
if chunk:
await websocket.send_json({"type": "append", "log": chunk})
await asyncio.sleep(0.4)
+6
View File
@@ -19,8 +19,14 @@ class ProxyTemplateDocument(BaseModel):
code: str = Field(..., description="Addon source code") code: str = Field(..., description="Addon source code")
source_url: str | None = Field(default=None, description="Reference URL") source_url: str | None = Field(default=None, description="Reference URL")
built_in: bool = Field(default=False, description="Provided by thePlug") built_in: bool = Field(default=False, description="Provided by thePlug")
origin: str = Field(default="custom", description="Template source")
created_at: datetime | None = None created_at: datetime | None = None
updated_at: datetime | None = None updated_at: datetime | None = None
source_path: str | None = Field(
default=None,
description="Filesystem source path",
exclude=True,
)
class ProxyTemplateCreateInput(BaseModel): class ProxyTemplateCreateInput(BaseModel):
+1
View File
@@ -62,6 +62,7 @@ async def create_custom_template(
code=payload.code, code=payload.code,
source_url=payload.source_url, source_url=payload.source_url,
built_in=False, built_in=False,
origin="custom",
created_at=now, created_at=now,
updated_at=now, updated_at=now,
) )
+24 -44
View File
@@ -4,12 +4,23 @@ import shutil
import signal import signal
from pathlib import Path from pathlib import Path
import yaml
from app.integrations.docker import change_port from app.integrations.docker import change_port
from app.integrations.docker.ports import get_random_port from app.integrations.docker.ports import get_random_port
from app.models.proxy import ProxyDocument from app.models.proxy import ProxyDocument
from app.services.mitmproxy.options import MitmOptionError, validate_options from app.services.mitmproxy.options import MitmOptionError, validate_options
from app.services.mitmproxy.runtime_files import (
conf_dir_for,
config_path_for,
ensure_log_path,
log_path_for,
read_cmdline,
read_log_tail,
remove_dir,
remove_file,
script_path_for,
write_config,
write_script,
)
from app.services.mitmproxy.runtime_validation import ( from app.services.mitmproxy.runtime_validation import (
ReservedProxyPortError, ReservedProxyPortError,
ensure_proxy_port_not_reserved, ensure_proxy_port_not_reserved,
@@ -300,23 +311,13 @@ class MitmdumpManager:
return candidate return candidate
async def write_script(self, proxy: ProxyDocument) -> Path: async def write_script(self, proxy: ProxyDocument) -> Path:
path = self.script_path_for(proxy) return await write_script(self.work_dir, proxy)
await asyncio.to_thread(path.parent.mkdir, parents=True, exist_ok=True)
await asyncio.to_thread(path.write_text, proxy.code, encoding="utf-8")
return path
async def write_config(self, proxy: ProxyDocument) -> Path: async def write_config(self, proxy: ProxyDocument) -> Path:
path = self.config_path_for(proxy) return await write_config(self.work_dir, proxy)
await asyncio.to_thread(path.parent.mkdir, parents=True, exist_ok=True)
options = {"block_global": False, **proxy.mitm_options}
config_text = yaml.safe_dump(options, sort_keys=True)
await asyncio.to_thread(path.write_text, config_text, encoding="utf-8")
return path
async def log_path(self, proxy: ProxyDocument) -> Path: async def log_path(self, proxy: ProxyDocument) -> Path:
path = self.log_path_for(proxy) return await ensure_log_path(self.work_dir, proxy)
await asyncio.to_thread(path.parent.mkdir, parents=True, exist_ok=True)
return path
async def spawn(self, command: list[str], log_path: Path): async def spawn(self, command: list[str], log_path: Path):
with log_path.open("ab") as log_file: with log_path.open("ab") as log_file:
@@ -408,49 +409,28 @@ class MitmdumpManager:
return expected_script in cmdline and expected_confdir in cmdline return expected_script in cmdline and expected_confdir in cmdline
def read_cmdline(self, pid: int) -> list[str]: def read_cmdline(self, pid: int) -> list[str]:
path = Path(f"/proc/{pid}/cmdline") return read_cmdline(pid)
try:
raw = path.read_bytes()
except OSError:
return []
decoded = raw.decode("utf-8", errors="replace")
return [part for part in decoded.split("\x00") if part]
def script_path_for(self, proxy: ProxyDocument) -> Path: def script_path_for(self, proxy: ProxyDocument) -> Path:
return self.work_dir / "addons" / f"{proxy.proxy_port}.py" return script_path_for(self.work_dir, proxy)
def conf_dir_for(self, proxy: ProxyDocument) -> Path: def conf_dir_for(self, proxy: ProxyDocument) -> Path:
return self.work_dir / "conf" / str(proxy.proxy_port) return conf_dir_for(self.work_dir, proxy)
def config_path_for(self, proxy: ProxyDocument) -> Path: def config_path_for(self, proxy: ProxyDocument) -> Path:
return self.conf_dir_for(proxy) / "config.yaml" return config_path_for(self.work_dir, proxy)
def log_path_for(self, proxy: ProxyDocument) -> Path: def log_path_for(self, proxy: ProxyDocument) -> Path:
return self.work_dir / "logs" / f"{proxy.proxy_port}.log" return log_path_for(self.work_dir, proxy)
async def read_log_tail(self, path: Path, limit: int = 4000) -> str: async def read_log_tail(self, path: Path, limit: int = 4000) -> str:
if not await asyncio.to_thread(path.exists): return await read_log_tail(path, limit=limit)
return ""
content = await asyncio.to_thread(
path.read_text,
encoding="utf-8",
errors="replace",
)
return content[-limit:].strip()
def remove_file(self, path: Path) -> None: def remove_file(self, path: Path) -> None:
try: remove_file(path)
path.unlink(missing_ok=True)
except OSError:
return
def remove_dir(self, path: Path) -> None: def remove_dir(self, path: Path) -> None:
try: remove_dir(path)
shutil.rmtree(path)
except FileNotFoundError:
return
except OSError:
return
def lock_for(self, proxy_port: int) -> asyncio.Lock: def lock_for(self, proxy_port: int) -> asyncio.Lock:
lock = self.locks.get(proxy_port) lock = self.locks.get(proxy_port)
@@ -0,0 +1,82 @@
import asyncio
import shutil
from pathlib import Path
import yaml
from app.models.proxy import ProxyDocument
def script_path_for(work_dir: Path, proxy: ProxyDocument) -> Path:
return work_dir / "addons" / f"{proxy.proxy_port}.py"
def conf_dir_for(work_dir: Path, proxy: ProxyDocument) -> Path:
return work_dir / "conf" / str(proxy.proxy_port)
def config_path_for(work_dir: Path, proxy: ProxyDocument) -> Path:
return conf_dir_for(work_dir, proxy) / "config.yaml"
def log_path_for(work_dir: Path, proxy: ProxyDocument) -> Path:
return work_dir / "logs" / f"{proxy.proxy_port}.log"
async def write_script(work_dir: Path, proxy: ProxyDocument) -> Path:
path = script_path_for(work_dir, proxy)
await asyncio.to_thread(path.parent.mkdir, parents=True, exist_ok=True)
await asyncio.to_thread(path.write_text, proxy.code, encoding="utf-8")
return path
async def write_config(work_dir: Path, proxy: ProxyDocument) -> Path:
path = config_path_for(work_dir, proxy)
await asyncio.to_thread(path.parent.mkdir, parents=True, exist_ok=True)
options = {"block_global": False, **proxy.mitm_options}
config_text = yaml.safe_dump(options, sort_keys=True)
await asyncio.to_thread(path.write_text, config_text, encoding="utf-8")
return path
async def ensure_log_path(work_dir: Path, proxy: ProxyDocument) -> Path:
path = log_path_for(work_dir, proxy)
await asyncio.to_thread(path.parent.mkdir, parents=True, exist_ok=True)
return path
async def read_log_tail(path: Path, limit: int = 4000) -> str:
if not await asyncio.to_thread(path.exists):
return ""
content = await asyncio.to_thread(
path.read_text,
encoding="utf-8",
errors="replace",
)
return content[-limit:].strip()
def remove_file(path: Path) -> None:
try:
path.unlink(missing_ok=True)
except OSError:
return
def remove_dir(path: Path) -> None:
try:
shutil.rmtree(path)
except FileNotFoundError:
return
except OSError:
return
def read_cmdline(pid: int) -> list[str]:
path = Path(f"/proc/{pid}/cmdline")
try:
raw = path.read_bytes()
except OSError:
return []
decoded = raw.decode("utf-8", errors="replace")
return [part for part in decoded.split("\x00") if part]
+120 -505
View File
@@ -1,524 +1,139 @@
from __future__ import annotations
import logging
from collections import defaultdict
from collections.abc import Mapping
from pathlib import Path
import yaml
from pydantic import ValidationError
from app.models.template import ProxyTemplateDocument from app.models.template import ProxyTemplateDocument
DOCS_TEMPLATES_URL = "https://docs.mitmproxy.org/stable/addons/examples/" logger = logging.getLogger(__name__)
SUPPORTED_FILE_TEMPLATE_PROTOCOLS = frozenset({"http", "tcp"})
TEMPLATES_DIR = Path(__file__).resolve().parents[4] / "templates"
def builtin_template( def template_directory() -> Path:
*, return TEMPLATES_DIR
id: str,
name: str,
protocol: str, def template_files(directory: Path | None = None) -> list[Path]:
family: str, root = directory or template_directory()
tags: list[str], if not root.exists():
action: str, return []
summary: str, return sorted(
behavior: str, path
code: str, for path in root.iterdir()
) -> ProxyTemplateDocument: if path.is_file() and path.suffix.lower() in {".yaml", ".yml"}
category = protocol if protocol in {"http", "tcp"} else family
return ProxyTemplateDocument(
id=id,
name=name,
description=summary,
category=category,
protocol=protocol,
family=family,
tags=tags,
action=action,
summary=summary,
behavior=behavior,
source_url=DOCS_TEMPLATES_URL,
built_in=True,
code=code,
) )
BUILTIN_TEMPLATES = [ def _default_category(protocol: str, family: str) -> str:
builtin_template( return protocol if protocol in SUPPORTED_FILE_TEMPLATE_PROTOCOLS else family
id="http-block-path-traversal",
name="Block path traversal",
protocol="http",
family="web-hardening",
tags=["path-traversal", "web", "block"],
action="block",
summary="Stops common path traversal probes in path and query.",
behavior=(
"Logs the matched traversal token and immediately drops the client "
"connection before the request reaches the upstream service."
),
code="""import logging
from urllib.parse import unquote
from mitmproxy import http
TRAVERSAL_MARKERS = (
"../",
"..\\\\",
"%2e%2e",
"%252e%252e",
"..%2f",
"..%5c",
)
def request(flow: http.HTTPFlow) -> None: def _string_value(value: object, default: str = "") -> str:
raw_target = ( if isinstance(value, str):
f"{flow.request.path}?{flow.request.query}" return value
if flow.request.query if value is None:
else flow.request.path return default
return str(value)
def _normalize_template_payload(
raw: Mapping[str, object],
source: Path,
) -> tuple[dict[str, object], str]:
protocol = _string_value(raw.get("protocol")).strip().lower()
if protocol not in SUPPORTED_FILE_TEMPLATE_PROTOCOLS:
expected = sorted(SUPPORTED_FILE_TEMPLATE_PROTOCOLS)
raise ValueError(
f"unsupported protocol {protocol!r}; expected one of {expected}"
)
if "id" not in raw or "name" not in raw or "code" not in raw:
raise ValueError("missing one of required fields: id, name, code")
description = _string_value(
raw.get("description") or raw.get("summary") or raw.get("behavior")
) )
normalized = unquote(unquote(raw_target)).lower() family = _string_value(raw.get("family") or protocol)
for marker in TRAVERSAL_MARKERS: source_url = raw.get("source_url")
if marker in normalized: tags = raw.get("tags")
logging.warning( normalized_tags = [str(tag) for tag in tags] if isinstance(tags, list) else []
"[http-block-path-traversal] blocked %s %s from %s",
flow.request.method, payload: dict[str, object] = {
flow.request.pretty_url, "id": _string_value(raw["id"]).strip(),
flow.client_conn.peername, "name": _string_value(raw["name"]).strip(),
) "description": description,
flow.kill() "category": _string_value(
return raw.get("category") or _default_category(protocol, family)
""",
),
builtin_template(
id="http-block-blacklisted-substrings",
name="Block blacklisted substrings",
protocol="http",
family="web-hardening",
tags=["blacklist-string", "web", "block"],
action="block",
summary="Blocks requests containing forbidden strings in path, query, or body.",
behavior=(
"Scans URL and request body for blacklisted markers, then serves a "
"blank HTML page with 200 OK and logs the first hit."
), ),
code="""import logging "protocol": protocol,
"family": family,
from mitmproxy import http "tags": normalized_tags,
"action": _string_value(raw.get("action") or "custom"),
BLOCKED_STRINGS = ( "summary": _string_value(raw.get("summary") or description),
"flag{", "behavior": _string_value(raw.get("behavior") or description),
"give_me_shell", "code": _string_value(raw["code"]),
"debug=1", "source_url": _string_value(source_url) if source_url is not None else None,
"/etc/passwd", "built_in": True,
) "origin": "file",
}
return payload, str(source)
def request(flow: http.HTTPFlow) -> None: def _load_template_from_file(path: Path) -> ProxyTemplateDocument | None:
body = flow.request.get_text(strict=False) try:
haystack = "\\n".join( raw = yaml.safe_load(path.read_text(encoding="utf-8"))
[flow.request.path, str(flow.request.query), body] except (OSError, yaml.YAMLError) as error:
).lower() logger.warning("Skipping template file %s: %s", path, error)
for marker in BLOCKED_STRINGS: return None
if marker.lower() in haystack:
logging.warning(
"[http-block-blacklisted-substrings] blocked marker=%r url=%s",
marker,
flow.request.pretty_url,
)
flow.response = http.Response.make(
200,
b"",
{"content-type": "text/html; charset=utf-8"},
)
return
""",
),
builtin_template(
id="http-block-large-body",
name="Block large request bodies",
protocol="http",
family="web-hardening",
tags=["length-limit", "body-size", "web", "block"],
action="block",
summary="Rejects oversized HTTP request bodies before they hit the service.",
behavior=(
"Checks the buffered request body length, returns 413 Payload Too "
"Large, and logs the observed size."
),
code="""import logging
from mitmproxy import http if not isinstance(raw, dict):
logger.warning("Skipping template file %s: expected mapping root", path)
return None
normalized_raw = {str(key): value for key, value in raw.items()}
MAX_BODY_BYTES = 8192 try:
payload, source_path = _normalize_template_payload(normalized_raw, path)
document = ProxyTemplateDocument.model_validate(payload)
except (ValidationError, ValueError) as error:
logger.warning("Skipping template file %s: %s", path, error)
return None
document.source_path = source_path
return document
def request(flow: http.HTTPFlow) -> None: def list_file_templates(directory: Path | None = None) -> list[ProxyTemplateDocument]:
size = len(flow.request.raw_content or b"") by_id: dict[str, list[ProxyTemplateDocument]] = defaultdict(list)
if size > MAX_BODY_BYTES: for path in template_files(directory):
logging.warning( template = _load_template_from_file(path)
"[http-block-large-body] blocked %s bytes on %s", if template is None:
size, continue
flow.request.pretty_url, by_id[template.id].append(template)
)
flow.response = http.Response.make(
413,
b"",
{"content-type": "text/plain"},
)
""",
),
builtin_template(
id="http-block-suspicious-methods",
name="Block suspicious HTTP methods",
protocol="http",
family="web-hardening",
tags=["method-filter", "web", "block"],
action="block",
summary="Denies uncommon or dangerous HTTP methods.",
behavior=(
"Returns 405 Method Not Allowed for methods outside the allowlist "
"and logs the blocked method and target URL."
),
code="""import logging
from mitmproxy import http deduplicated: list[ProxyTemplateDocument] = []
duplicate_template_message = (
ALLOWED_METHODS = {"GET", "POST", "HEAD"} "Skipping template id %s from %s: duplicate file-backed template id"
def request(flow: http.HTTPFlow) -> None:
if flow.request.method not in ALLOWED_METHODS:
logging.warning(
"[http-block-suspicious-methods] blocked method=%s url=%s",
flow.request.method,
flow.request.pretty_url,
)
flow.response = http.Response.make(
405,
b"",
{"allow": "GET, POST, HEAD"},
)
""",
),
builtin_template(
id="http-block-admin-paths",
name="Block admin and debug paths",
protocol="http",
family="web-hardening",
tags=["admin-path", "debug-path", "web", "block"],
action="block",
summary="Protects common admin, console, and debug endpoints.",
behavior=(
"Matches the request path against a small denylist and returns a "
"blank 404 page before the request is forwarded."
),
code="""import logging
from mitmproxy import http
BLOCKED_PREFIXES = (
"/admin",
"/debug",
"/console",
"/actuator",
)
def request(flow: http.HTTPFlow) -> None:
if flow.request.path.startswith(BLOCKED_PREFIXES):
logging.warning(
"[http-block-admin-paths] blocked path=%s",
flow.request.path,
)
flow.response = http.Response.make(
404,
b"",
{"content-type": "text/html; charset=utf-8"},
)
""",
),
builtin_template(
id="http-sanitize-headers-basic",
name="Sanitize risky forwarding headers",
protocol="http",
family="web-hardening",
tags=["rewrite", "headers", "web"],
action="rewrite",
summary=(
"Removes a few risky forwarding headers before the request is "
"sent upstream."
),
behavior=(
"Deletes selected override or forwarding headers and logs what was "
"rewritten without blocking the request."
),
code="""import logging
from mitmproxy import http
STRIP_HEADERS = (
"x-forwarded-host",
"x-http-method-override",
"x-original-url",
"x-rewrite-url",
)
def request(flow: http.HTTPFlow) -> None:
removed = []
for header in STRIP_HEADERS:
if header in flow.request.headers:
removed.append(header)
del flow.request.headers[header]
if removed:
logging.info(
"[http-sanitize-headers-basic] stripped headers=%s url=%s",
",".join(removed),
flow.request.pretty_url,
)
""",
),
builtin_template(
id="http-block-command-injection-markers",
name="Block command injection markers",
protocol="http",
family="web-hardening",
tags=["command-injection", "web", "block"],
action="block",
summary=(
"Rejects obvious shell metacharacters and command-substitution "
"markers."
),
behavior=(
"Scans URL and body for shell-style injection markers, logs the "
"first hit, and drops the client connection."
),
code="""import logging
from mitmproxy import http
BLOCKED_MARKERS = (";", "&&", "||", "$(", "`", "|sh", "|bash")
def request(flow: http.HTTPFlow) -> None:
body = flow.request.get_text(strict=False)
haystack = "\\n".join(
[flow.request.pretty_url, body]
).lower()
for marker in BLOCKED_MARKERS:
if marker in haystack:
logging.warning(
"[http-block-command-injection-markers] blocked marker=%r url=%s",
marker,
flow.request.pretty_url,
)
flow.kill()
return
""",
),
builtin_template(
id="http-block-sqli-markers",
name="Block obvious SQLi markers",
protocol="http",
family="web-hardening",
tags=["sqli", "web", "block"],
action="block",
summary="Stops common SQL injection probes before they reach the app.",
behavior=(
"Matches a short list of high-signal SQLi markers in path, query, "
"and body, then serves a blank HTML page and logs the hit."
),
code="""import logging
from mitmproxy import http
BLOCKED_MARKERS = (
"' or 1=1",
'" or 1=1',
"union select",
"sleep(",
"information_schema",
"--",
)
def request(flow: http.HTTPFlow) -> None:
body = flow.request.get_text(strict=False)
haystack = "\\n".join(
[flow.request.path, str(flow.request.query), body]
).lower()
for marker in BLOCKED_MARKERS:
if marker in haystack:
logging.warning(
"[http-block-sqli-markers] blocked marker=%r url=%s",
marker,
flow.request.pretty_url,
)
flow.response = http.Response.make(
200,
b"",
{"content-type": "text/html; charset=utf-8"},
)
return
""",
),
builtin_template(
id="tcp-block-repeated-byte-threshold",
name="Block repeated-byte flood",
protocol="tcp",
family="tcp-hardening",
tags=["bof", "repeated-byte", "tcp", "block"],
action="block",
summary="Closes connections that send suspiciously long runs of the same byte.",
behavior=(
"Measures the longest repeated-byte run in each client message, logs "
"the byte and run length, and closes the TCP flow on threshold hit."
),
code="""import logging
from mitmproxy import tcp
MAX_REPEAT = 128
def longest_run(data: bytes) -> tuple[int, int]:
best_byte = -1
best_run = 0
current_byte = -1
current_run = 0
for byte in data:
if byte == current_byte:
current_run += 1
else:
current_byte = byte
current_run = 1
if current_run > best_run:
best_byte = byte
best_run = current_run
return best_byte, best_run
def tcp_message(flow: tcp.TCPFlow) -> None:
message = flow.messages[-1]
if not message.from_client:
return
repeated_byte, run_length = longest_run(message.content)
if run_length > MAX_REPEAT:
logging.warning(
"[tcp-block-repeated-byte-threshold] blocked byte=0x%02x run=%d",
repeated_byte,
run_length,
)
message.drop()
flow.kill()
""",
),
builtin_template(
id="tcp-block-message-length-threshold",
name="Block oversized TCP messages",
protocol="tcp",
family="tcp-hardening",
tags=["length-limit", "tcp", "block"],
action="block",
summary="Cuts off client messages that exceed a fixed size threshold.",
behavior=(
"Checks every client TCP message size, logs the offending length, "
"drops the message, and kills the flow when the threshold is hit."
),
code="""import logging
from mitmproxy import tcp
MAX_MESSAGE_BYTES = 4096
def tcp_message(flow: tcp.TCPFlow) -> None:
message = flow.messages[-1]
if not message.from_client:
return
size = len(message.content)
if size > MAX_MESSAGE_BYTES:
logging.warning(
"[tcp-block-message-length-threshold] blocked message size=%d",
size,
)
message.drop()
flow.kill()
""",
),
builtin_template(
id="tcp-block-blacklisted-byte-sequences",
name="Block blacklisted TCP byte sequences",
protocol="tcp",
family="tcp-hardening",
tags=["blacklist-bytes", "tcp", "block"],
action="block",
summary=(
"Blocks payloads containing forbidden byte sequences or marker "
"strings."
),
behavior=(
"Looks for dangerous byte patterns in client messages, then drops "
"the message and closes the flow."
),
code="""import logging
from mitmproxy import tcp
BLOCKED_SEQUENCES = (
b"/bin/sh",
b"cat flag",
b"AAAA%p%p",
b"\\x90\\x90\\x90\\x90",
)
def tcp_message(flow: tcp.TCPFlow) -> None:
message = flow.messages[-1]
if not message.from_client:
return
for sequence in BLOCKED_SEQUENCES:
if sequence in message.content:
logging.warning(
"[tcp-block-blacklisted-byte-sequences] blocked sequence=%r",
sequence,
)
message.drop()
flow.kill()
return
""",
),
builtin_template(
id="tcp-block-non-printable-threshold",
name="Block binary-noise threshold",
protocol="tcp",
family="tcp-hardening",
tags=["non-printable", "binary-noise", "tcp", "block"],
action="block",
summary=(
"Blocks client messages with an excessive amount of non-printable "
"bytes."
),
behavior=(
"Treats unusually binary-heavy client payloads as suspicious, logs "
"the ratio, and closes the flow when the threshold is exceeded."
),
code="""import logging
from mitmproxy import tcp
NON_PRINTABLE_RATIO = 0.35
def tcp_message(flow: tcp.TCPFlow) -> None:
message = flow.messages[-1]
if not message.from_client or not message.content:
return
non_printable = sum(
1
for byte in message.content
if byte < 0x20 and byte not in {0x09, 0x0a, 0x0d}
) )
ratio = non_printable / len(message.content) for template_id, templates in by_id.items():
if ratio > NON_PRINTABLE_RATIO: if len(templates) > 1:
logging.warning( for template in templates:
"[tcp-block-non-printable-threshold] blocked ratio=%.2f size=%d", logger.warning(
ratio, duplicate_template_message,
len(message.content), template_id,
) template.source_path or "<unknown>",
message.drop() )
flow.kill() continue
""", deduplicated.append(templates[0])
),
] return sorted(deduplicated, key=lambda template: template.name.lower())
def file_template_ids(directory: Path | None = None) -> set[str]:
return {template.id for template in list_file_templates(directory)}
+40 -2
View File
@@ -9,7 +9,7 @@ from app.core.security import get_auth_context, require_authenticated_user
from app.models.health import HealthSnapshot from app.models.health import HealthSnapshot
from app.models.proxy import ProxyCreateInput, ProxyDocument from app.models.proxy import ProxyCreateInput, ProxyDocument
from app.models.service import ServiceEndpointDocument from app.models.service import ServiceEndpointDocument
from app.services.mitmproxy.templates import BUILTIN_TEMPLATES from app.services.mitmproxy.templates import list_file_templates
from app.services.mitmproxy.validator import ProxyCodeValidationError from app.services.mitmproxy.validator import ProxyCodeValidationError
proxy_router_module = importlib.import_module("app.api.proxy.router") proxy_router_module = importlib.import_module("app.api.proxy.router")
@@ -864,6 +864,8 @@ def test_stop_proxy_updates_runtime_state(client, monkeypatch: object):
def test_list_mitmproxy_templates_returns_builtins_and_custom(client, monkeypatch): def test_list_mitmproxy_templates_returns_builtins_and_custom(client, monkeypatch):
file_templates = list_file_templates()
async def fake_list_custom_templates(): async def fake_list_custom_templates():
return [] return []
@@ -872,13 +874,19 @@ def test_list_mitmproxy_templates_returns_builtins_and_custom(client, monkeypatc
"list_custom_templates", "list_custom_templates",
fake_list_custom_templates, fake_list_custom_templates,
) )
monkeypatch.setattr(
proxy_router_module,
"list_file_templates",
lambda: file_templates,
)
response = client.get("/proxy/mitmproxy/templates") response = client.get("/proxy/mitmproxy/templates")
assert response.status_code == 200 assert response.status_code == 200
body = response.json() body = response.json()
assert len(body) == len(BUILTIN_TEMPLATES) assert len(body) == len(file_templates)
assert body[0]["built_in"] is True assert body[0]["built_in"] is True
assert body[0]["origin"] == "file"
assert body[0]["protocol"] in {"http", "tcp"} assert body[0]["protocol"] in {"http", "tcp"}
assert isinstance(body[0]["tags"], list) assert isinstance(body[0]["tags"], list)
assert body[0]["action"] in {"block", "rewrite"} assert body[0]["action"] in {"block", "rewrite"}
@@ -906,6 +914,7 @@ def test_create_mitmproxy_template_validates_and_persists(client, monkeypatch):
"code": payload.code, "code": payload.code,
"source_url": payload.source_url, "source_url": payload.source_url,
"built_in": False, "built_in": False,
"origin": "custom",
} }
monkeypatch.setattr( monkeypatch.setattr(
@@ -939,4 +948,33 @@ def test_create_mitmproxy_template_validates_and_persists(client, monkeypatch):
assert response.status_code == 201 assert response.status_code == 201
assert response.json()["id"] == "custom-header" assert response.json()["id"] == "custom-header"
assert response.json()["protocol"] == "http" assert response.json()["protocol"] == "http"
assert response.json()["origin"] == "custom"
assert captured["validated"] == "def response(flow):\n pass\n" assert captured["validated"] == "def response(flow):\n pass\n"
def test_create_mitmproxy_template_rejects_file_backed_id(client, monkeypatch):
monkeypatch.setattr(
proxy_router_module,
"file_template_ids",
lambda: {"http-block-path-traversal"},
)
response = client.post(
"/proxy/mitmproxy/templates",
json={
"id": "http-block-path-traversal",
"name": "Custom header",
"description": "Adds a custom response header",
"category": "http",
"protocol": "http",
"family": "custom",
"tags": [],
"action": "custom",
"summary": "Saved custom template.",
"behavior": "Applies the current script as-is.",
"code": "def response(flow):\n pass\n",
},
)
assert response.status_code == 409
assert "Template already exists" in response.json()["detail"]
+46 -3
View File
@@ -1,8 +1,12 @@
from app.services.mitmproxy.templates import BUILTIN_TEMPLATES import logging
import yaml
from app.services.mitmproxy.templates import list_file_templates
def test_builtin_templates_use_four_space_indentation(): def test_file_templates_use_four_space_indentation():
for template in BUILTIN_TEMPLATES: for template in list_file_templates():
for line in template.code.splitlines(): for line in template.code.splitlines():
assert "\t" not in line, f"{template.id} contains a tab: {line!r}" assert "\t" not in line, f"{template.id} contains a tab: {line!r}"
if not line.strip(): if not line.strip():
@@ -11,3 +15,42 @@ def test_builtin_templates_use_four_space_indentation():
assert indent % 4 == 0, ( assert indent % 4 == 0, (
f"{template.id} has non-4-space indentation: {line!r}" f"{template.id} has non-4-space indentation: {line!r}"
) )
def test_list_file_templates_loads_yaml_backed_templates():
templates = list_file_templates()
assert templates
assert all(template.origin == "file" for template in templates)
assert all(template.built_in is True for template in templates)
assert all(template.protocol in {"http", "tcp"} for template in templates)
def test_list_file_templates_skips_invalid_files(tmp_path, caplog):
path = tmp_path / "invalid.yaml"
path.write_text("[]\n", encoding="utf-8")
with caplog.at_level(logging.WARNING):
templates = list_file_templates(tmp_path)
assert templates == []
assert "Skipping template file" in caplog.text
def test_list_file_templates_skips_duplicate_ids(tmp_path, caplog):
payload = {
"id": "duplicate",
"name": "Duplicate",
"protocol": "http",
"description": "Duplicate template",
"code": "from mitmproxy import http\n",
}
document = yaml.safe_dump(payload, sort_keys=False)
(tmp_path / "one.yaml").write_text(document, encoding="utf-8")
(tmp_path / "two.yaml").write_text(document, encoding="utf-8")
with caplog.at_level(logging.WARNING):
templates = list_file_templates(tmp_path)
assert templates == []
assert "duplicate file-backed template id" in caplog.text
-17
View File
@@ -1,21 +1,4 @@
services: services:
backend:
build:
context: ./backend
container_name: theplug-backend
restart: unless-stopped
env_file:
- path: ./backend/.env
required: false
environment:
MONGODB_URI: mongodb://${MONGO_USER:-theplug}:${MONGO_INITDB_ROOT_PASSWORD:-theplug_dev_password}@mongodb:27017/${MONGO_DB:-theplug}?authSource=admin
MONGODB_DB_NAME: ${MONGO_DB:-theplug}
ports:
- "${API_PORT:-3671}:3671"
depends_on:
mongodb:
condition: service_healthy
frontend: frontend:
build: build:
context: ./frontend context: ./frontend
+48 -8
View File
@@ -5,8 +5,13 @@ import { useControlPlaneContext } from "../composables/useControlPlane";
import UiIcon from "./UiIcon.vue"; import UiIcon from "./UiIcon.vue";
const plane = useControlPlaneContext(); const plane = useControlPlaneContext();
const monitoringActive = computed(() => plane.runtimeStatus === "running");
const overallLabel = computed(() =>
monitoringActive.value ? plane.health?.overall ?? "pending" : "stopped",
);
const latestTimestamp = computed(() => { const latestTimestamp = computed(() => {
if (!monitoringActive.value) return "Monitoring inactive";
if (!plane.health?.timestamp) return "No checks yet"; if (!plane.health?.timestamp) return "No checks yet";
return new Date(plane.health.timestamp).toLocaleTimeString([], { return new Date(plane.health.timestamp).toLocaleTimeString([], {
hour: "2-digit", hour: "2-digit",
@@ -16,6 +21,9 @@ const latestTimestamp = computed(() => {
}); });
const targetRows = computed(() => { const targetRows = computed(() => {
if (!monitoringActive.value) {
return [null, null];
}
const checks = plane.health?.checks ?? []; const checks = plane.health?.checks ?? [];
return [ return [
checks.find((check) => check.target === "proxy") ?? null, checks.find((check) => check.target === "proxy") ?? null,
@@ -135,7 +143,11 @@ function formatDuration(value: number | null) {
</p> </p>
</div> </div>
<div class="modal-head-actions"> <div class="modal-head-actions">
<button class="ghost button-icon" :disabled="plane.loading" @click="plane.checkHealth"> <button
class="ghost button-icon"
:disabled="plane.loading || !monitoringActive"
@click="plane.checkHealth"
>
<UiIcon name="pulse" /> <UiIcon name="pulse" />
<span>Run now</span> <span>Run now</span>
</button> </button>
@@ -149,9 +161,9 @@ function formatDuration(value: number | null) {
<div class="modal-body health-modal-body"> <div class="modal-body health-modal-body">
<div <div
class="health-runtime-overview" class="health-runtime-overview"
:class="plane.health?.overall ?? 'disabled'" :class="overallLabel"
> >
<strong>{{ plane.health?.overall ?? "pending" }}</strong> <strong>{{ overallLabel }}</strong>
<span>Last check {{ latestTimestamp }}</span> <span>Last check {{ latestTimestamp }}</span>
</div> </div>
@@ -160,14 +172,38 @@ function formatDuration(value: number | null) {
v-for="(row, index) in targetRows" v-for="(row, index) in targetRows"
:key="row?.target ?? `pending-${index}`" :key="row?.target ?? `pending-${index}`"
class="health-runtime-card" class="health-runtime-card"
:class="row ? (row.healthy ? 'healthy' : 'unhealthy') : 'pending'" :class="
row
? row.healthy
? 'healthy'
: 'unhealthy'
: monitoringActive
? 'pending'
: 'inactive'
"
> >
<div class="health-runtime-card-head"> <div class="health-runtime-card-head">
<strong>{{ row?.target ?? "pending" }}</strong> <strong>{{
<span>{{ row ? (row.healthy ? "ok" : "issue") : "waiting" }}</span> row?.target ?? (index === 0 ? "proxy" : "upstream")
}}</strong>
<span>{{
row
? row.healthy
? "ok"
: "issue"
: monitoringActive
? "pending"
: "inactive"
}}</span>
</div> </div>
<p class="muted small compact-copy"> <p class="muted small compact-copy">
{{ row ? `${row.host}:${row.port} · ${row.protocol}` : "No data yet." }} {{
row
? `${row.host}:${row.port} · ${row.protocol}`
: monitoringActive
? "No data yet."
: "Start the proxy to enable active checks."
}}
</p> </p>
<p <p
v-if="benchmarkEnabled && typeof row?.duration_ms === 'number'" v-if="benchmarkEnabled && typeof row?.duration_ms === 'number'"
@@ -210,7 +246,11 @@ function formatDuration(value: number | null) {
</div> </div>
</div> </div>
<p v-else class="muted small"> <p v-else class="muted small">
Health history will appear after the first scheduled runs. {{
monitoringActive
? "Health history will appear after the first scheduled runs."
: "Health history starts once the proxy is running."
}}
</p> </p>
</div> </div>
+22 -7
View File
@@ -5,8 +5,13 @@ import { useControlPlaneContext } from "../composables/useControlPlane";
import UiIcon from "./UiIcon.vue"; import UiIcon from "./UiIcon.vue";
const plane = useControlPlaneContext(); const plane = useControlPlaneContext();
const monitoringActive = computed(() => plane.runtimeStatus === "running");
const overallLabel = computed(() =>
monitoringActive.value ? plane.health?.overall ?? "pending" : "stopped",
);
const latestTimestamp = computed(() => { const latestTimestamp = computed(() => {
if (!monitoringActive.value) return "Monitoring inactive";
if (!plane.health?.timestamp) return "No checks yet"; if (!plane.health?.timestamp) return "No checks yet";
return new Date(plane.health.timestamp).toLocaleTimeString([], { return new Date(plane.health.timestamp).toLocaleTimeString([], {
hour: "2-digit", hour: "2-digit",
@@ -21,25 +26,35 @@ const proxyCheck = computed(() =>
const upstreamCheck = computed(() => const upstreamCheck = computed(() =>
plane.health?.checks.find((check) => check.target === "upstream") ?? null, plane.health?.checks.find((check) => check.target === "upstream") ?? null,
); );
const proxyLabel = computed(() => {
if (!monitoringActive.value) return "inactive";
return proxyCheck.value ? (proxyCheck.value.healthy ? "ok" : "issue") : "pending";
});
const upstreamLabel = computed(() => {
if (!monitoringActive.value) return "inactive";
return upstreamCheck.value
? (upstreamCheck.value.healthy ? "ok" : "issue")
: "pending";
});
</script> </script>
<template> <template>
<section <section
v-if="plane.selectedProxy" v-if="plane.selectedProxy"
class="health-summary-card" class="health-summary-card"
:class="plane.health?.overall ?? 'pending'" :class="overallLabel"
> >
<div class="health-summary-line"> <div class="health-summary-line">
<strong class="health-summary-overall">{{ plane.health?.overall ?? "pending" }}</strong> <strong class="health-summary-overall">{{ overallLabel }}</strong>
<span class="health-summary-item"> <span class="health-summary-item">
<small>Proxy</small> <small>Proxy</small>
<strong>{{ proxyCheck ? (proxyCheck.healthy ? "ok" : "issue") : "waiting" }}</strong> <strong>{{ proxyLabel }}</strong>
</span> </span>
<span class="health-summary-item"> <span class="health-summary-item">
<small>Upstream</small> <small>Upstream</small>
<strong> <strong>{{ upstreamLabel }}</strong>
{{ upstreamCheck ? (upstreamCheck.healthy ? "ok" : "issue") : "waiting" }}
</strong>
</span> </span>
<span class="health-summary-item health-summary-time"> <span class="health-summary-item health-summary-time">
<small>Last check</small> <small>Last check</small>
@@ -48,7 +63,7 @@ const upstreamCheck = computed(() =>
<div class="health-summary-actions"> <div class="health-summary-actions">
<button <button
class="ghost button-icon" class="ghost button-icon"
:disabled="plane.loading" :disabled="plane.loading || !monitoringActive"
@click="plane.checkHealth" @click="plane.checkHealth"
> >
<UiIcon name="pulse" /> <UiIcon name="pulse" />
+1 -1
View File
@@ -79,7 +79,7 @@ const plane = useControlPlaneContext();
<div class="doc-actions"> <div class="doc-actions">
<button @click="plane.applyTemplate(plane.selectedTemplate)">Use template</button> <button @click="plane.applyTemplate(plane.selectedTemplate)">Use template</button>
<button <button
v-if="!plane.selectedTemplate.built_in" v-if="plane.selectedTemplate.origin === 'custom'"
class="ghost" class="ghost"
@click="plane.removeTemplate(plane.selectedTemplate.id)" @click="plane.removeTemplate(plane.selectedTemplate.id)"
> >
@@ -0,0 +1,138 @@
import { computed, type Ref } from "vue";
import type { MitmOption, ProxyDraft, ProxyTemplate } from "../types";
export const focusedMitmOptions = new Set([
"anticache",
"block_global",
"connection_strategy",
"flow_detail",
"http2",
"http3",
"keep_host_header",
"rawtcp",
"showhost",
"ssl_insecure",
"termlog_verbosity",
"upstream_cert",
"validate_inbound_headers",
"websocket",
]);
export type OptionScope = "focused" | "all";
export type TemplateProtocolFilter = "http" | "tcp";
export function defaultFor(option: MitmOption) {
if (option.default !== null && option.default !== undefined) return option.default;
if (option.type === "bool") return false;
if (option.type === "int" || option.type === "optional int") return 0;
if (option.type === "sequence of str") return [];
return "";
}
export function templateProtocolForDraft(draft: ProxyDraft): "http" | "tcp" {
return ["http", "https", "http3"].includes(draft.protocol) ? "http" : "tcp";
}
type CatalogStateOptions = {
options: Ref<MitmOption[]>;
optionsLoading: Ref<boolean>;
optionSearch: Ref<string>;
optionScope: Ref<OptionScope>;
selectedOptionName: Ref<string>;
templates: Ref<ProxyTemplate[]>;
templateSearch: Ref<string>;
templateProtocolFilter: Ref<TemplateProtocolFilter>;
selectedTemplateId: Ref<string>;
draft: ProxyDraft;
};
export function useControlPlaneCatalog({
options,
optionsLoading,
optionSearch,
optionScope,
selectedOptionName,
templates,
templateSearch,
templateProtocolFilter,
selectedTemplateId,
draft,
}: CatalogStateOptions) {
const filteredOptions = computed(() => {
if (optionsLoading.value) return [];
const query = optionSearch.value.toLowerCase().trim();
const available = options.value.filter((option) => !option.reserved);
const scoped = query
? available
: optionScope.value === "all"
? available
: available.filter((option) => focusedMitmOptions.has(option.name));
const filtered = scoped
.filter((option) => {
if (!query) return true;
return `${option.name} ${option.description}`.toLowerCase().includes(query);
})
.sort((a, b) => a.name.localeCompare(b.name));
return filtered.slice(0, query ? 80 : optionScope.value === "all" ? 80 : 18);
});
const optionCount = computed(
() => options.value.filter((option) => !option.reserved).length,
);
const activeOptionCount = computed(() => Object.keys(draft.mitm_options).length);
const selectedOption = computed(
() =>
filteredOptions.value.find((option) => option.name === selectedOptionName.value) ??
filteredOptions.value[0] ??
null,
);
const filteredTemplates = computed(() => {
const query = templateSearch.value.toLowerCase().trim();
const protocol = templateProtocolFilter.value;
const filtered = templates.value.filter((template) => {
if (template.protocol !== protocol) return false;
if (!query) return true;
return [
template.name,
template.description,
template.summary,
template.behavior,
]
.join(" ")
.toLowerCase()
.includes(query);
});
const sorted = filtered.sort((a, b) => a.name.localeCompare(b.name));
return query ? sorted : sorted.slice(0, 40);
});
const selectedTemplate = computed(
() =>
filteredTemplates.value.find((template) => template.id === selectedTemplateId.value) ??
filteredTemplates.value[0] ??
null,
);
const activeOptions = computed(() =>
Object.entries(draft.mitm_options)
.map(([name, value]) => ({
name,
value: Array.isArray(value) ? value.join(", ") : String(value ?? ""),
option: options.value.find((item) => item.name === name),
}))
.sort((a, b) => a.name.localeCompare(b.name)),
);
return {
activeOptionCount,
activeOptions,
filteredOptions,
filteredTemplates,
optionCount,
selectedOption,
selectedTemplate,
};
}
@@ -0,0 +1,80 @@
import { computed, type Ref } from "vue";
import { bindingsFor, folderBasename } from "./controlPlaneDraft";
import type { DockerService, ProxyDocument } from "../types";
export function useControlPlaneDiscovery(
services: Ref<DockerService[]>,
proxies: Ref<ProxyDocument[]>,
) {
const serviceEntries = computed(() =>
services.value.flatMap((service) =>
bindingsFor(service).map((binding) => {
const proxy =
proxies.value.find(
(item) =>
item.service.name === service.name &&
item.service.folder === service.folder &&
item.service.port === binding.published,
) ?? null;
return {
id: `${service.folder}:${service.name}:${binding.host ?? "*"}:${binding.published}`,
service,
binding,
proxy,
listenHost: binding.host ?? "0.0.0.0",
};
}),
),
);
const missingServiceCount = computed(
() => serviceEntries.value.filter((entry) => !entry.proxy).length,
);
const serviceGroups = computed(() => {
const groups = new Map<
string,
{
id: string;
title: string;
folder: string;
entries: (typeof serviceEntries.value)[number][];
}
>();
for (const entry of serviceEntries.value) {
const folder = entry.service.folder ?? "";
const existing = groups.get(folder);
if (existing) {
existing.entries.push(entry);
continue;
}
groups.set(folder, {
id: folder || entry.service.name,
title: folderBasename(folder) || "Compose folder",
folder,
entries: [entry],
});
}
return [...groups.values()]
.map((group) => ({
...group,
entries: [...group.entries].sort((left, right) => {
const byName = left.service.name.localeCompare(right.service.name);
if (byName !== 0) return byName;
return left.binding.published - right.binding.published;
}),
}))
.sort((left, right) => left.title.localeCompare(right.title));
});
return {
missingServiceCount,
serviceEntries,
serviceGroups,
};
}
@@ -40,6 +40,10 @@ export function createDefaultDraft(port = 8080, code?: string): ProxyDraft {
def request(flow: http.HTTPFlow) -> None: def request(flow: http.HTTPFlow) -> None:
pass pass
def response(flow: http.HTTPFlow) -> None:
pass
`, `,
mode: "reverse", mode: "reverse",
mode_spec: null, mode_spec: null,
@@ -0,0 +1,165 @@
import { ref, watch, type Ref } from "vue";
import { api, buildSocketUrl } from "../api";
import type { HealthResult } from "../types";
type HealthPlaneOptions = {
token: Ref<string | null>;
selectedPort: Ref<number | null>;
onUnauthorized: () => void;
onError: (error: unknown) => void;
};
export function useControlPlaneHealth({
token,
selectedPort,
onUnauthorized,
onError,
}: HealthPlaneOptions) {
const health = ref<HealthResult | null>(null);
const healthHistory = ref<HealthResult[]>([]);
const healthModalOpen = ref(false);
let healthSocket: WebSocket | null = null;
let healthReconnectTimer: number | null = null;
function parseHealthFrame(raw: string) {
try {
const payload = JSON.parse(raw) as HealthResult;
if (
typeof payload?.proxy_port === "number" &&
typeof payload?.timestamp === "string" &&
Array.isArray(payload?.checks)
) {
return payload;
}
} catch {
return null;
}
return null;
}
function pushHealthSample(snapshot: HealthResult) {
const next = [...healthHistory.value];
const index = next.findIndex((item) => item.timestamp === snapshot.timestamp);
if (index >= 0) {
next[index] = snapshot;
} else {
next.push(snapshot);
}
next.sort((left, right) => left.timestamp.localeCompare(right.timestamp));
healthHistory.value = next.slice(-40);
}
async function syncHealthState(silent = false) {
const port = selectedPort.value;
if (port === null) return;
try {
const response = await api.healthHistory(port);
health.value = response.latest;
healthHistory.value = response.history;
} catch (error) {
if (!silent) onError(error);
}
}
function scheduleHealthReconnect(proxyPort: number) {
if (typeof window === "undefined" || healthReconnectTimer !== null) return;
healthReconnectTimer = window.setTimeout(() => {
healthReconnectTimer = null;
if (!token.value || selectedPort.value !== proxyPort) return;
void openHealthStream(proxyPort);
}, 1200);
}
async function openHealthStream(proxyPort: number) {
await syncHealthState(true);
if (
typeof window === "undefined" ||
!token.value ||
selectedPort.value !== proxyPort
) {
return;
}
const socket = new WebSocket(buildSocketUrl(`/proxy/${proxyPort}/health/ws`));
healthSocket = socket;
socket.onmessage = (event) => {
if (socket !== healthSocket) return;
const snapshot = parseHealthFrame(event.data);
if (snapshot === null) return;
health.value = snapshot;
pushHealthSample(snapshot);
};
socket.onclose = (event) => {
if (socket !== healthSocket) return;
healthSocket = null;
if (event.code === 4401) {
onUnauthorized();
return;
}
if (!token.value || selectedPort.value !== proxyPort || event.code === 1000) {
return;
}
scheduleHealthReconnect(proxyPort);
};
}
function stopHealthStream() {
if (typeof window !== "undefined" && healthReconnectTimer !== null) {
window.clearTimeout(healthReconnectTimer);
healthReconnectTimer = null;
}
if (healthSocket === null) return;
healthSocket.onclose = null;
healthSocket.close();
healthSocket = null;
}
function openHealthModal() {
healthModalOpen.value = true;
}
function closeHealthModal() {
healthModalOpen.value = false;
}
function resetHealth() {
stopHealthStream();
health.value = null;
healthHistory.value = [];
healthModalOpen.value = false;
}
watch(
[selectedPort, token],
([port, authToken], [previousPort]) => {
if (port !== previousPort) {
health.value = null;
healthHistory.value = [];
}
stopHealthStream();
if (!authToken || port === null) return;
void openHealthStream(port);
},
{ immediate: true },
);
return {
closeHealthModal,
health,
healthHistory,
healthModalOpen,
openHealthModal,
pushHealthSample,
resetHealth,
stopHealthStream,
syncHealthState,
};
}
@@ -0,0 +1,185 @@
import { computed, ref, watch, type Ref } from "vue";
import { api, buildSocketUrl } from "../api";
type LogFrame = { type: "snapshot" | "append"; log: string };
type LogPlaneOptions = {
token: Ref<string | null>;
selectedPort: Ref<number | null>;
onUnauthorized: () => void;
onError: (error: unknown) => void;
};
export function useControlPlaneLogs({
token,
selectedPort,
onUnauthorized,
onError,
}: LogPlaneOptions) {
const logs = ref("");
const logFilter = ref("");
const logsPaused = ref(false);
const logsCleared = ref(false);
let logSocket: WebSocket | null = null;
let logReconnectTimer: number | null = null;
const filteredLogs = computed(() => {
const source = logs.value || "";
const query = logFilter.value.trim().toLowerCase();
if (!query) return source;
return source
.split("\n")
.filter((line) => line.toLowerCase().includes(query))
.join("\n");
});
function parseLogFrame(raw: string): LogFrame | null {
try {
const payload = JSON.parse(raw);
if (
(payload.type === "snapshot" || payload.type === "append") &&
typeof payload.log === "string"
) {
return payload as LogFrame;
}
} catch {
return { type: "append", log: raw };
}
return null;
}
async function syncLogs(silent = false) {
const port = selectedPort.value;
if (port === null) return;
try {
if (!logsCleared.value) {
logs.value = (await api.logs(port)).log;
}
} catch (error) {
if (!silent) onError(error);
}
}
async function loadLogs() {
await syncLogs(false);
}
function scheduleLogReconnect(proxyPort: number) {
if (typeof window === "undefined" || logReconnectTimer !== null) return;
logReconnectTimer = window.setTimeout(() => {
logReconnectTimer = null;
if (!token.value || logsPaused.value || selectedPort.value !== proxyPort) return;
void openLogStream(proxyPort);
}, 1200);
}
async function openLogStream(proxyPort: number) {
if (!logsCleared.value) {
await syncLogs(true);
}
if (
typeof window === "undefined" ||
!token.value ||
logsPaused.value ||
selectedPort.value !== proxyPort
) {
return;
}
const socket = new WebSocket(buildSocketUrl(`/proxy/${proxyPort}/logs/ws`));
logSocket = socket;
socket.onmessage = (event) => {
if (socket !== logSocket) return;
const frame = parseLogFrame(event.data);
if (frame === null) return;
if (frame.type === "snapshot") {
if (logsCleared.value) return;
logs.value = frame.log;
return;
}
logs.value += frame.log;
};
socket.onclose = (event) => {
if (socket !== logSocket) return;
logSocket = null;
if (event.code === 4401) {
onUnauthorized();
return;
}
if (
!token.value ||
logsPaused.value ||
selectedPort.value !== proxyPort ||
event.code === 1000
) {
return;
}
scheduleLogReconnect(proxyPort);
};
}
function stopLogStream() {
if (typeof window !== "undefined" && logReconnectTimer !== null) {
window.clearTimeout(logReconnectTimer);
logReconnectTimer = null;
}
if (logSocket === null) return;
logSocket.onclose = null;
logSocket.close();
logSocket = null;
}
function toggleLogsPaused() {
logsPaused.value = !logsPaused.value;
}
function clearLogs() {
logs.value = "";
logsCleared.value = true;
}
function resetLogs() {
stopLogStream();
logs.value = "";
logFilter.value = "";
logsPaused.value = false;
logsCleared.value = false;
}
watch(
[selectedPort, token, logsPaused],
([port, authToken, paused], [previousPort]) => {
if (port !== previousPort) {
logs.value = "";
logFilter.value = "";
logsCleared.value = false;
}
stopLogStream();
if (!authToken || port === null || paused) return;
void openLogStream(port);
},
{ immediate: true },
);
return {
clearLogs,
filteredLogs,
loadLogs,
logFilter,
logs,
logsPaused,
logsCleared,
resetLogs,
stopLogStream,
syncLogs,
toggleLogsPaused,
};
}
+69 -499
View File
@@ -12,25 +12,31 @@ import { useRoute, useRouter } from "vue-router";
import { import {
ApiError, ApiError,
api, api,
buildSocketUrl,
getToken, getToken,
login, login,
logout, logout,
setToken, setToken,
} from "../api"; } from "../api";
import { import {
bindingsFor,
createDefaultDraft, createDefaultDraft,
draftForService, draftForService,
draftPayload, draftPayload,
folderBasename,
serializeDraft, serializeDraft,
} from "./controlPlaneDraft"; } from "./controlPlaneDraft";
import {
defaultFor,
templateProtocolForDraft,
type OptionScope,
type TemplateProtocolFilter,
useControlPlaneCatalog,
} from "./controlPlaneCatalog";
import { useControlPlaneDiscovery } from "./controlPlaneDiscovery";
import { useControlPlaneHealth } from "./controlPlaneHealth";
import { useControlPlaneLogs } from "./controlPlaneLogs";
export { createDefaultDraft, modes, protocols } from "./controlPlaneDraft"; export { createDefaultDraft, modes, protocols } from "./controlPlaneDraft";
import type { import type {
DockerService, DockerService,
DockerServiceBinding, DockerServiceBinding,
HealthResult,
MitmOption, MitmOption,
ProxyDocument, ProxyDocument,
ProxyDraft, ProxyDraft,
@@ -40,26 +46,7 @@ import type {
const optionScopeStorageKey = "theplug.option.scope"; const optionScopeStorageKey = "theplug.option.scope";
const scanRootStorageKey = "theplug.scan.root"; const scanRootStorageKey = "theplug.scan.root";
const focusedMitmOptions = new Set([
"anticache",
"block_global",
"connection_strategy",
"flow_detail",
"http2",
"http3",
"keep_host_header",
"rawtcp",
"showhost",
"ssl_insecure",
"termlog_verbosity",
"upstream_cert",
"validate_inbound_headers",
"websocket",
]);
export type RuntimeAction = "start" | "stop" | "restart"; export type RuntimeAction = "start" | "stop" | "restart";
export type OptionScope = "focused" | "all";
export type TemplateProtocolFilter = "http" | "tcp";
type ErrorSurface = "auth" | "wizard" | "discovery" | "workspace"; type ErrorSurface = "auth" | "wizard" | "discovery" | "workspace";
function readSession(key: string, fallback: string) { function readSession(key: string, fallback: string) {
@@ -128,11 +115,6 @@ export function useControlPlane() {
const templates = ref<ProxyTemplate[]>([]); const templates = ref<ProxyTemplate[]>([]);
const backendPort = ref<number | null>(null); const backendPort = ref<number | null>(null);
const selectedPort = ref<number | null>(null); const selectedPort = ref<number | null>(null);
const health = ref<HealthResult | null>(null);
const healthHistory = ref<HealthResult[]>([]);
const logs = ref("");
const logFilter = ref("");
const logsPaused = ref(false);
const optionSearch = ref(""); const optionSearch = ref("");
const templateSearch = ref(""); const templateSearch = ref("");
const customTemplateName = ref(""); const customTemplateName = ref("");
@@ -150,22 +132,41 @@ export function useControlPlane() {
const wizardOpen = ref(false); const wizardOpen = ref(false);
const wizardMode = ref<"create" | "edit">("create"); const wizardMode = ref<"create" | "edit">("create");
const wizardStep = ref(0); const wizardStep = ref(0);
const healthModalOpen = ref(false);
const wizardSeed = ref<{ const wizardSeed = ref<{
service: DockerService; service: DockerService;
binding: DockerServiceBinding; binding: DockerServiceBinding;
} | null>(null); } | null>(null);
const logsCleared = ref(false);
let noticeTimer: number | null = null; let noticeTimer: number | null = null;
let logSocket: WebSocket | null = null;
let logReconnectTimer: number | null = null;
let healthSocket: WebSocket | null = null;
let healthReconnectTimer: number | null = null;
let syncingRoute = false; let syncingRoute = false;
const selectedProxy = computed(() => const selectedProxy = computed(() =>
proxies.value.find((proxy) => proxy.proxy_port === selectedPort.value), proxies.value.find((proxy) => proxy.proxy_port === selectedPort.value),
); );
const logPlane = useControlPlaneLogs({
token,
selectedPort,
onUnauthorized: () => clearSession("Session expired. Sign in again."),
onError: (error) => setError(error, "workspace"),
});
const discoveryPlane = useControlPlaneDiscovery(services, proxies);
const healthPlane = useControlPlaneHealth({
token,
selectedPort,
onUnauthorized: () => clearSession("Session expired. Sign in again."),
onError: (error) => setError(error, "workspace"),
});
const catalogPlane = useControlPlaneCatalog({
options,
optionsLoading,
optionSearch,
optionScope,
selectedOptionName,
templates,
templateSearch,
templateProtocolFilter,
selectedTemplateId,
draft,
});
const runtimeStatus = computed(() => selectedProxy.value?.status ?? "draft"); const runtimeStatus = computed(() => selectedProxy.value?.status ?? "draft");
const isRunning = computed(() => const isRunning = computed(() =>
@@ -211,15 +212,6 @@ export function useControlPlane() {
const wizardError = computed(() => errors.wizard); const wizardError = computed(() => errors.wizard);
const discoveryError = computed(() => errors.discovery); const discoveryError = computed(() => errors.discovery);
const workspaceError = computed(() => errors.workspace); const workspaceError = computed(() => errors.workspace);
const filteredLogs = computed(() => {
const source = logs.value || "";
const query = logFilter.value.trim().toLowerCase();
if (!query) return source;
return source
.split("\n")
.filter((line) => line.toLowerCase().includes(query))
.join("\n");
});
function buildRouteQuery() { function buildRouteQuery() {
const query: Record<string, string | undefined> = { const query: Record<string, string | undefined> = {
@@ -284,71 +276,6 @@ export function useControlPlane() {
} }
} }
const serviceEntries = computed(() =>
services.value.flatMap((service) =>
bindingsFor(service).map((binding) => {
const proxy =
proxies.value.find(
(item) =>
item.service.name === service.name &&
item.service.folder === service.folder &&
item.service.port === binding.published,
) ?? null;
return {
id: `${service.folder}:${service.name}:${binding.host ?? "*"}:${binding.published}`,
service,
binding,
proxy,
listenHost: binding.host ?? "0.0.0.0",
};
}),
),
);
const missingServiceCount = computed(
() => serviceEntries.value.filter((entry) => !entry.proxy).length,
);
const serviceGroups = computed(() => {
const groups = new Map<
string,
{
id: string;
title: string;
folder: string;
entries: (typeof serviceEntries.value)[number][];
}
>();
for (const entry of serviceEntries.value) {
const folder = entry.service.folder ?? "";
const existing = groups.get(folder);
if (existing) {
existing.entries.push(entry);
continue;
}
groups.set(folder, {
id: folder || entry.service.name,
title: folderBasename(folder) || "Compose folder",
folder,
entries: [entry],
});
}
return [...groups.values()]
.map((group) => ({
...group,
entries: [...group.entries].sort((left, right) => {
const byName = left.service.name.localeCompare(right.service.name);
if (byName !== 0) return byName;
return left.binding.published - right.binding.published;
}),
}))
.sort((left, right) => left.title.localeCompare(right.title));
});
const routeState = computed(() => { const routeState = computed(() => {
const livePort = const livePort =
selectedProxy.value?.service.upstream_port ?? draft.service.upstream_port ?? null; selectedProxy.value?.service.upstream_port ?? draft.service.upstream_port ?? null;
@@ -388,73 +315,6 @@ export function useControlPlane() {
}; };
}); });
const filteredOptions = computed(() => {
if (optionsLoading.value) return [];
const query = optionSearch.value.toLowerCase().trim();
const available = options.value.filter((option) => !option.reserved);
const scoped = query
? available
: optionScope.value === "all"
? available
: available.filter((option) => focusedMitmOptions.has(option.name));
const filtered = scoped
.filter((option) => {
if (!query) return true;
return `${option.name} ${option.description}`.toLowerCase().includes(query);
})
.sort((a, b) => a.name.localeCompare(b.name));
return filtered.slice(0, query ? 80 : optionScope.value === "all" ? 80 : 18);
});
const optionCount = computed(
() => options.value.filter((option) => !option.reserved).length,
);
const activeOptionCount = computed(() => Object.keys(draft.mitm_options).length);
const selectedOption = computed(
() =>
filteredOptions.value.find((option) => option.name === selectedOptionName.value) ??
filteredOptions.value[0] ??
null,
);
const filteredTemplates = computed(() => {
const query = templateSearch.value.toLowerCase().trim();
const protocol = templateProtocolFilter.value;
const filtered = templates.value.filter((template) => {
if (template.protocol !== protocol) return false;
if (!query) return true;
return [
template.name,
template.description,
template.summary,
template.behavior,
]
.join(" ")
.toLowerCase()
.includes(query);
});
const sorted = filtered.sort((a, b) => a.name.localeCompare(b.name));
return query ? sorted : sorted.slice(0, 40);
});
const selectedTemplate = computed(
() =>
filteredTemplates.value.find((template) => template.id === selectedTemplateId.value) ??
filteredTemplates.value[0] ??
null,
);
const activeOptions = computed(() =>
Object.entries(draft.mitm_options)
.map(([name, value]) => ({
name,
value: Array.isArray(value) ? value.join(", ") : String(value ?? ""),
option: options.value.find((item) => item.name === name),
}))
.sort((a, b) => a.name.localeCompare(b.name)),
);
const codeLineCount = computed(() => draft.code.split("\n").length); const codeLineCount = computed(() => draft.code.split("\n").length);
const scriptDirty = computed(() => draft.code !== loadedCode.value); const scriptDirty = computed(() => draft.code !== loadedCode.value);
const draftDirty = computed( const draftDirty = computed(
@@ -514,47 +374,18 @@ export function useControlPlane() {
writeSession(scanRootStorageKey, value); writeSession(scanRootStorageKey, value);
}); });
watch(filteredOptions, (items) => { watch(catalogPlane.filteredOptions, (items) => {
if (!items.some((item) => item.name === selectedOptionName.value)) { if (!items.some((item) => item.name === selectedOptionName.value)) {
selectedOptionName.value = items[0]?.name ?? ""; selectedOptionName.value = items[0]?.name ?? "";
} }
}, { immediate: true }); }, { immediate: true });
watch(filteredTemplates, (items) => { watch(catalogPlane.filteredTemplates, (items) => {
if (!items.some((item) => item.id === selectedTemplateId.value)) { if (!items.some((item) => item.id === selectedTemplateId.value)) {
selectedTemplateId.value = items[0]?.id ?? ""; selectedTemplateId.value = items[0]?.id ?? "";
} }
}, { immediate: true }); }, { immediate: true });
watch(
[selectedPort, token],
([port, authToken], [previousPort]) => {
if (port !== previousPort) {
health.value = null;
healthHistory.value = [];
}
stopHealthStream();
if (!authToken || port === null) return;
void openHealthStream(port);
},
{ immediate: true },
);
watch(
[selectedPort, token, logsPaused],
([port, authToken, paused], [previousPort]) => {
if (port !== previousPort) {
logs.value = "";
logFilter.value = "";
logsCleared.value = false;
}
stopLogStream();
if (!authToken || port === null || paused) return;
void openLogStream(port);
},
{ immediate: true },
);
async function signIn() { async function signIn() {
await run(async () => { await run(async () => {
await login(username.value, password.value); await login(username.value, password.value);
@@ -671,23 +502,15 @@ export function useControlPlane() {
typeof seedOrAnnounce === "boolean" ? seedOrAnnounce : announce; typeof seedOrAnnounce === "boolean" ? seedOrAnnounce : announce;
selectedPort.value = null; selectedPort.value = null;
loadedPort.value = null; loadedPort.value = null;
health.value = null; healthPlane.health.value = null;
logs.value = ""; logPlane.logs.value = "";
logFilter.value = ""; logPlane.logFilter.value = "";
logsCleared.value = false; logPlane.logsCleared.value = false;
const nextDraft = createDefaultDraft( const nextDraft = createDefaultDraft(nextPort());
nextPort(),
templates.value.find((template) => template.protocol === "http")?.code ??
draft.code,
);
if (seed) { if (seed) {
Object.assign( Object.assign(
nextDraft, nextDraft,
draftForService( draftForService(seed.service, seed.binding),
seed.service,
seed.binding,
templates.value.find((template) => template.protocol === "http")?.code,
),
); );
} }
Object.assign(draft, nextDraft); Object.assign(draft, nextDraft);
@@ -759,15 +582,9 @@ export function useControlPlane() {
async function createMissingServiceProxies() { async function createMissingServiceProxies() {
await run(async () => { await run(async () => {
for (const entry of serviceEntries.value) { for (const entry of discoveryPlane.serviceEntries.value) {
if (entry.proxy) continue; if (entry.proxy) continue;
await api.createProxy( await api.createProxy(draftForService(entry.service, entry.binding));
draftForService(
entry.service,
entry.binding,
templates.value.find((template) => template.protocol === "http")?.code,
),
);
} }
await Promise.all([refreshProxies(false), refreshServicesRequest(false)]); await Promise.all([refreshProxies(false), refreshServicesRequest(false)]);
const first = proxies.value[0]; const first = proxies.value[0];
@@ -867,212 +684,17 @@ export function useControlPlane() {
if (!proxy) return; if (!proxy) return;
await run(async () => { await run(async () => {
const snapshot = await api.health(proxy.proxy_port); const snapshot = await api.health(proxy.proxy_port);
health.value = snapshot; healthPlane.health.value = snapshot;
pushHealthSample(snapshot); healthPlane.pushHealthSample(snapshot);
}, undefined, "workspace"); }, undefined, "workspace");
} }
function openHealthModal() {
healthModalOpen.value = true;
}
function closeHealthModal() {
healthModalOpen.value = false;
}
function openDiscovery() { function openDiscovery() {
clearError(); clearError();
closeHealthModal(); healthPlane.closeHealthModal();
selectedPort.value = null; selectedPort.value = null;
} }
async function syncHealthState(silent = false) {
const proxy = selectedProxy.value;
if (!proxy) return;
try {
const response = await api.healthHistory(proxy.proxy_port);
health.value = response.latest;
healthHistory.value = response.history;
} catch (err) {
if (!silent) {
setError(err, "workspace");
}
}
}
async function loadLogs() {
await syncLogs(false);
}
async function syncLogs(silent = false) {
const proxy = selectedProxy.value;
if (!proxy) return;
try {
if (!logsCleared.value) {
logs.value = (await api.logs(proxy.proxy_port)).log;
}
} catch (err) {
if (!silent) {
setError(err, "workspace");
}
}
}
async function openLogStream(proxyPort: number) {
if (!logsCleared.value) {
await syncLogs(true);
}
if (
typeof window === "undefined" ||
!token.value ||
logsPaused.value ||
selectedPort.value !== proxyPort
) {
return;
}
const socket = new WebSocket(buildSocketUrl(`/proxy/${proxyPort}/logs/ws`));
logSocket = socket;
socket.onmessage = (event) => {
if (socket !== logSocket) return;
const frame = parseLogFrame(event.data);
if (frame === null) return;
if (frame.type === "snapshot") {
if (logsCleared.value) return;
logs.value = frame.log;
return;
}
logs.value += frame.log;
};
socket.onclose = (event) => {
if (socket !== logSocket) return;
logSocket = null;
if (event.code === 4401) {
clearSession("Session expired. Sign in again.");
return;
}
if (
!token.value ||
logsPaused.value ||
selectedPort.value !== proxyPort ||
event.code === 1000
) {
return;
}
scheduleLogReconnect(proxyPort);
};
}
async function openHealthStream(proxyPort: number) {
await syncHealthState(true);
if (
typeof window === "undefined" ||
!token.value ||
selectedPort.value !== proxyPort
) {
return;
}
const socket = new WebSocket(buildSocketUrl(`/proxy/${proxyPort}/health/ws`));
healthSocket = socket;
socket.onmessage = (event) => {
if (socket !== healthSocket) return;
const snapshot = parseHealthFrame(event.data);
if (snapshot === null) return;
health.value = snapshot;
pushHealthSample(snapshot);
};
socket.onclose = (event) => {
if (socket !== healthSocket) return;
healthSocket = null;
if (event.code === 4401) {
clearSession("Session expired. Sign in again.");
return;
}
if (!token.value || selectedPort.value !== proxyPort || event.code === 1000) {
return;
}
scheduleHealthReconnect(proxyPort);
};
}
function parseLogFrame(raw: string) {
try {
const payload = JSON.parse(raw);
if (
(payload.type === "snapshot" || payload.type === "append") &&
typeof payload.log === "string"
) {
return payload as { type: "snapshot" | "append"; log: string };
}
} catch {
return { type: "append", log: raw } as const;
}
return null;
}
function scheduleLogReconnect(proxyPort: number) {
if (typeof window === "undefined" || logReconnectTimer !== null) return;
logReconnectTimer = window.setTimeout(() => {
logReconnectTimer = null;
if (!token.value || logsPaused.value || selectedPort.value !== proxyPort) return;
void openLogStream(proxyPort);
}, 1200);
}
function parseHealthFrame(raw: string) {
try {
const payload = JSON.parse(raw) as HealthResult;
if (
typeof payload?.proxy_port === "number" &&
typeof payload?.timestamp === "string" &&
Array.isArray(payload?.checks)
) {
return payload;
}
} catch {
return null;
}
return null;
}
function scheduleHealthReconnect(proxyPort: number) {
if (typeof window === "undefined" || healthReconnectTimer !== null) return;
healthReconnectTimer = window.setTimeout(() => {
healthReconnectTimer = null;
if (!token.value || selectedPort.value !== proxyPort) return;
void openHealthStream(proxyPort);
}, 1200);
}
function pushHealthSample(snapshot: HealthResult) {
const next = [...healthHistory.value];
const index = next.findIndex((item) => item.timestamp === snapshot.timestamp);
if (index >= 0) {
next[index] = snapshot;
} else {
next.push(snapshot);
}
next.sort((left, right) => left.timestamp.localeCompare(right.timestamp));
healthHistory.value = next.slice(-40);
}
function templateProtocolForDraft(): "http" | "tcp" {
return ["http", "https", "http3"].includes(draft.protocol) ? "http" : "tcp";
}
async function saveCurrentAsTemplate() { async function saveCurrentAsTemplate() {
const name = customTemplateName.value.trim(); const name = customTemplateName.value.trim();
if (!name) { if (!name) {
@@ -1087,8 +709,8 @@ export function useControlPlane() {
id, id,
name, name,
description: `Saved from ${draft.name} (${draft.mode} / ${draft.protocol}).`, description: `Saved from ${draft.name} (${draft.mode} / ${draft.protocol}).`,
category: templateProtocolForDraft(), category: templateProtocolForDraft(draft),
protocol: templateProtocolForDraft(), protocol: templateProtocolForDraft(draft),
family: "custom", family: "custom",
tags: [], tags: [],
action: "custom", action: "custom",
@@ -1099,7 +721,7 @@ export function useControlPlane() {
}); });
await refreshTemplates(false); await refreshTemplates(false);
customTemplateName.value = ""; customTemplateName.value = "";
templateProtocolFilter.value = templateProtocolForDraft(); templateProtocolFilter.value = templateProtocolForDraft(draft);
selectedTemplateId.value = id; selectedTemplateId.value = id;
}, "Custom template saved", "workspace"); }, "Custom template saved", "workspace");
} }
@@ -1191,15 +813,6 @@ export function useControlPlane() {
optionScope.value = scope; optionScope.value = scope;
} }
function toggleLogsPaused() {
logsPaused.value = !logsPaused.value;
}
function clearLogs() {
logs.value = "";
logsCleared.value = true;
}
function cleanDraft(): ProxyDraft { function cleanDraft(): ProxyDraft {
return draftPayload(draft); return draftPayload(draft);
} }
@@ -1210,8 +823,8 @@ export function useControlPlane() {
loadedPort.value = proxy.proxy_port; loadedPort.value = proxy.proxy_port;
loadedDraftJson.value = serializeDraft(nextDraft); loadedDraftJson.value = serializeDraft(nextDraft);
loadedCode.value = nextDraft.code; loadedCode.value = nextDraft.code;
health.value = null; healthPlane.health.value = null;
logs.value = ""; logPlane.logs.value = "";
} }
function syncLoadedCode(code: string) { function syncLoadedCode(code: string) {
@@ -1231,14 +844,6 @@ export function useControlPlane() {
return port; return port;
} }
function defaultFor(option: MitmOption) {
if (option.default !== null && option.default !== undefined) return option.default;
if (option.type === "bool") return false;
if (option.type === "int" || option.type === "optional int") return 0;
if (option.type === "sequence of str") return [];
return "";
}
function activeSurface(): ErrorSurface { function activeSurface(): ErrorSurface {
if (!token.value) return "auth"; if (!token.value) return "auth";
if (wizardOpen.value) return "wizard"; if (wizardOpen.value) return "wizard";
@@ -1300,8 +905,8 @@ export function useControlPlane() {
} }
function clearSession(message = "") { function clearSession(message = "") {
stopHealthStream(); healthPlane.stopHealthStream();
stopLogStream(); logPlane.stopLogStream();
setToken(null); setToken(null);
token.value = null; token.value = null;
proxies.value = []; proxies.value = [];
@@ -1311,15 +916,12 @@ export function useControlPlane() {
backendPort.value = null; backendPort.value = null;
selectedPort.value = null; selectedPort.value = null;
loadedPort.value = null; loadedPort.value = null;
health.value = null; healthPlane.resetHealth();
logs.value = ""; logPlane.resetLogs();
logFilter.value = "";
logsCleared.value = false;
wizardOpen.value = false; wizardOpen.value = false;
wizardStep.value = 0; wizardStep.value = 0;
wizardMode.value = "create"; wizardMode.value = "create";
wizardSeed.value = null; wizardSeed.value = null;
healthModalOpen.value = false;
templateProtocolFilter.value = "http"; templateProtocolFilter.value = "http";
Object.assign(draft, createDefaultDraft()); Object.assign(draft, createDefaultDraft());
loadedDraftJson.value = serializeDraft(draft); loadedDraftJson.value = serializeDraft(draft);
@@ -1328,44 +930,18 @@ export function useControlPlane() {
void router.replace({ path: "/proxies", query: {} }); void router.replace({ path: "/proxies", query: {} });
} }
function stopLogStream() {
if (typeof window !== "undefined" && logReconnectTimer !== null) {
window.clearTimeout(logReconnectTimer);
logReconnectTimer = null;
}
if (logSocket === null) return;
logSocket.onclose = null;
logSocket.close();
logSocket = null;
}
function stopHealthStream() {
if (typeof window !== "undefined" && healthReconnectTimer !== null) {
window.clearTimeout(healthReconnectTimer);
healthReconnectTimer = null;
}
if (healthSocket === null) return;
healthSocket.onclose = null;
healthSocket.close();
healthSocket = null;
}
return proxyRefs({ return proxyRefs({
activeOptionCount, activeOptionCount: catalogPlane.activeOptionCount,
activeOptions, activeOptions: catalogPlane.activeOptions,
applyTemplate, applyTemplate,
applyService, applyService,
actionState, actionState,
authMessage, authMessage,
checkHealth, checkHealth,
clearLogs,
closeWizard, closeWizard,
codeLineCount, codeLineCount,
canSaveScript, canSaveScript,
canSaveProxy, canSaveProxy,
filteredLogs,
logFilter,
logsPaused,
createMissingServiceProxies, createMissingServiceProxies,
createProxyFromService, createProxyFromService,
customTemplateName, customTemplateName,
@@ -1373,23 +949,17 @@ export function useControlPlane() {
error, error,
templateSearch, templateSearch,
templateProtocolFilter, templateProtocolFilter,
filteredTemplates, filteredTemplates: catalogPlane.filteredTemplates,
filteredOptions, filteredOptions: catalogPlane.filteredOptions,
health,
healthHistory,
healthModalOpen,
discoveryError, discoveryError,
loginError, loginError,
loadLogs,
loading, loading,
logs,
newProxy, newProxy,
notice, notice,
openCreateWizard, openCreateWizard,
openDiscovery, openDiscovery,
openEditWizard, openEditWizard,
openHealthModal, optionCount: catalogPlane.optionCount,
optionCount,
optionScope, optionScope,
optionSearch, optionSearch,
password, password,
@@ -1407,13 +977,13 @@ export function useControlPlane() {
saveProxy, saveProxy,
saveScript, saveScript,
selectTemplate, selectTemplate,
selectedTemplate, selectedTemplate: catalogPlane.selectedTemplate,
selectedOption, selectedOption: catalogPlane.selectedOption,
selectedPort, selectedPort,
selectedProxy, selectedProxy,
serviceGroups, serviceGroups: discoveryPlane.serviceGroups,
selectOption, selectOption,
serviceEntries, serviceEntries: discoveryPlane.serviceEntries,
services, services,
templatesLoading, templatesLoading,
proxiesLoading, proxiesLoading,
@@ -1421,12 +991,10 @@ export function useControlPlane() {
setOption, setOption,
signIn, signIn,
signOut, signOut,
closeHealthModal,
matchProxyPortToService, matchProxyPortToService,
missingServiceCount, missingServiceCount: discoveryPlane.missingServiceCount,
nextWizardStep, nextWizardStep,
toggleOptionScope, toggleOptionScope,
toggleLogsPaused,
token, token,
updateOption, updateOption,
username, username,
@@ -1442,6 +1010,8 @@ export function useControlPlane() {
canGoNextWizard, canGoNextWizard,
scanRoot, scanRoot,
servicesLoading, servicesLoading,
...logPlane,
...healthPlane,
}); });
} }
+1
View File
@@ -83,6 +83,7 @@ export type ProxyTemplate = {
code: string; code: string;
source_url?: string | null; source_url?: string | null;
built_in: boolean; built_in: boolean;
origin: "file" | "custom";
}; };
export type ProxyTemplateCreateInput = { export type ProxyTemplateCreateInput = {
+40
View File
@@ -0,0 +1,40 @@
id: http-block-admin-paths
name: Block admin and debug paths
protocol: http
description: Protects common admin, console, and debug endpoints.
category: http
family: web-hardening
tags:
- admin-path
- debug-path
- web
- block
action: block
summary: Protects common admin, console, and debug endpoints.
behavior: Matches the request path against a small denylist and returns a blank 404
page before the request is forwarded.
source_url: https://docs.mitmproxy.org/stable/addons/examples/
code: |
import logging
from mitmproxy import http
BLOCKED_PREFIXES = (
"/admin",
"/debug",
"/console",
"/actuator",
)
def request(flow: http.HTTPFlow) -> None:
if flow.request.path.startswith(BLOCKED_PREFIXES):
logging.warning(
"[http-block-admin-paths] blocked path=%s",
flow.request.path,
)
flow.response = http.Response.make(
404,
b"",
{"content-type": "text/html; charset=utf-8"},
)
@@ -0,0 +1,46 @@
id: http-block-blacklisted-substrings
name: Block blacklisted substrings
protocol: http
description: Blocks requests containing forbidden strings in path, query, or body.
category: http
family: web-hardening
tags:
- blacklist-string
- web
- block
action: block
summary: Blocks requests containing forbidden strings in path, query, or body.
behavior: Scans URL and request body for blacklisted markers, then serves a blank
HTML page with 200 OK and logs the first hit.
source_url: https://docs.mitmproxy.org/stable/addons/examples/
code: |
import logging
from mitmproxy import http
BLOCKED_STRINGS = (
"flag{",
"give_me_shell",
"debug=1",
"/etc/passwd",
)
def request(flow: http.HTTPFlow) -> None:
body = flow.request.get_text(strict=False)
haystack = "\n".join(
[flow.request.path, str(flow.request.query), body]
).lower()
for marker in BLOCKED_STRINGS:
if marker.lower() in haystack:
logging.warning(
"[http-block-blacklisted-substrings] blocked marker=%r url=%s",
marker,
flow.request.pretty_url,
)
flow.response = http.Response.make(
200,
b"",
{"content-type": "text/html; charset=utf-8"},
)
return
@@ -0,0 +1,37 @@
id: http-block-command-injection-markers
name: Block command injection markers
protocol: http
description: Rejects obvious shell metacharacters and command-substitution markers.
category: http
family: web-hardening
tags:
- command-injection
- web
- block
action: block
summary: Rejects obvious shell metacharacters and command-substitution markers.
behavior: Scans URL and body for shell-style injection markers, logs the first hit,
and drops the client connection.
source_url: https://docs.mitmproxy.org/stable/addons/examples/
code: |
import logging
from mitmproxy import http
BLOCKED_MARKERS = (";", "&&", "||", "$(", "`", "|sh", "|bash")
def request(flow: http.HTTPFlow) -> None:
body = flow.request.get_text(strict=False)
haystack = "\n".join(
[flow.request.pretty_url, body]
).lower()
for marker in BLOCKED_MARKERS:
if marker in haystack:
logging.warning(
"[http-block-command-injection-markers] blocked marker=%r url=%s",
marker,
flow.request.pretty_url,
)
flow.kill()
return
+37
View File
@@ -0,0 +1,37 @@
id: http-block-large-body
name: Block large request bodies
protocol: http
description: Rejects oversized HTTP request bodies before they hit the service.
category: http
family: web-hardening
tags:
- length-limit
- body-size
- web
- block
action: block
summary: Rejects oversized HTTP request bodies before they hit the service.
behavior: Checks the buffered request body length, returns 413 Payload Too Large,
and logs the observed size.
source_url: https://docs.mitmproxy.org/stable/addons/examples/
code: |
import logging
from mitmproxy import http
MAX_BODY_BYTES = 8192
def request(flow: http.HTTPFlow) -> None:
size = len(flow.request.raw_content or b"")
if size > MAX_BODY_BYTES:
logging.warning(
"[http-block-large-body] blocked %s bytes on %s",
size,
flow.request.pretty_url,
)
flow.response = http.Response.make(
413,
b"",
{"content-type": "text/plain"},
)
+48
View File
@@ -0,0 +1,48 @@
id: http-block-path-traversal
name: Block path traversal
protocol: http
description: Stops common path traversal probes in path and query.
category: http
family: web-hardening
tags:
- path-traversal
- web
- block
action: block
summary: Stops common path traversal probes in path and query.
behavior: Logs the matched traversal token and immediately drops the client connection
before the request reaches the upstream service.
source_url: https://docs.mitmproxy.org/stable/addons/examples/
code: |
import logging
from urllib.parse import unquote
from mitmproxy import http
TRAVERSAL_MARKERS = (
"../",
"..\\",
"%2e%2e",
"%252e%252e",
"..%2f",
"..%5c",
)
def request(flow: http.HTTPFlow) -> None:
raw_target = (
f"{flow.request.path}?{flow.request.query}"
if flow.request.query
else flow.request.path
)
normalized = unquote(unquote(raw_target)).lower()
for marker in TRAVERSAL_MARKERS:
if marker in normalized:
logging.warning(
"[http-block-path-traversal] blocked %s %s from %s",
flow.request.method,
flow.request.pretty_url,
flow.client_conn.peername,
)
flow.kill()
return
+48
View File
@@ -0,0 +1,48 @@
id: http-block-sqli-markers
name: Block obvious SQLi markers
protocol: http
description: Stops common SQL injection probes before they reach the app.
category: http
family: web-hardening
tags:
- sqli
- web
- block
action: block
summary: Stops common SQL injection probes before they reach the app.
behavior: Matches a short list of high-signal SQLi markers in path, query, and body,
then serves a blank HTML page and logs the hit.
source_url: https://docs.mitmproxy.org/stable/addons/examples/
code: |
import logging
from mitmproxy import http
BLOCKED_MARKERS = (
"' or 1=1",
'" or 1=1',
"union select",
"sleep(",
"information_schema",
"--",
)
def request(flow: http.HTTPFlow) -> None:
body = flow.request.get_text(strict=False)
haystack = "\n".join(
[flow.request.path, str(flow.request.query), body]
).lower()
for marker in BLOCKED_MARKERS:
if marker in haystack:
logging.warning(
"[http-block-sqli-markers] blocked marker=%r url=%s",
marker,
flow.request.pretty_url,
)
flow.response = http.Response.make(
200,
b"",
{"content-type": "text/html; charset=utf-8"},
)
return
@@ -0,0 +1,35 @@
id: http-block-suspicious-methods
name: Block suspicious HTTP methods
protocol: http
description: Denies uncommon or dangerous HTTP methods.
category: http
family: web-hardening
tags:
- method-filter
- web
- block
action: block
summary: Denies uncommon or dangerous HTTP methods.
behavior: Returns 405 Method Not Allowed for methods outside the allowlist and logs
the blocked method and target URL.
source_url: https://docs.mitmproxy.org/stable/addons/examples/
code: |
import logging
from mitmproxy import http
ALLOWED_METHODS = {"GET", "POST", "HEAD"}
def request(flow: http.HTTPFlow) -> None:
if flow.request.method not in ALLOWED_METHODS:
logging.warning(
"[http-block-suspicious-methods] blocked method=%s url=%s",
flow.request.method,
flow.request.pretty_url,
)
flow.response = http.Response.make(
405,
b"",
{"allow": "GET, POST, HEAD"},
)
@@ -0,0 +1,40 @@
id: http-sanitize-headers-basic
name: Sanitize risky forwarding headers
protocol: http
description: Removes a few risky forwarding headers before the request is sent upstream.
category: http
family: web-hardening
tags:
- rewrite
- headers
- web
action: rewrite
summary: Removes a few risky forwarding headers before the request is sent upstream.
behavior: Deletes selected override or forwarding headers and logs what was rewritten
without blocking the request.
source_url: https://docs.mitmproxy.org/stable/addons/examples/
code: |
import logging
from mitmproxy import http
STRIP_HEADERS = (
"x-forwarded-host",
"x-http-method-override",
"x-original-url",
"x-rewrite-url",
)
def request(flow: http.HTTPFlow) -> None:
removed = []
for header in STRIP_HEADERS:
if header in flow.request.headers:
removed.append(header)
del flow.request.headers[header]
if removed:
logging.info(
"[http-sanitize-headers-basic] stripped headers=%s url=%s",
",".join(removed),
flow.request.pretty_url,
)
@@ -0,0 +1,41 @@
id: tcp-block-blacklisted-byte-sequences
name: Block blacklisted TCP byte sequences
protocol: tcp
description: Blocks payloads containing forbidden byte sequences or marker strings.
category: tcp
family: tcp-hardening
tags:
- blacklist-bytes
- tcp
- block
action: block
summary: Blocks payloads containing forbidden byte sequences or marker strings.
behavior: Looks for dangerous byte patterns in client messages, then drops the message
and closes the flow.
source_url: https://docs.mitmproxy.org/stable/addons/examples/
code: |
import logging
from mitmproxy import tcp
BLOCKED_SEQUENCES = (
b"/bin/sh",
b"cat flag",
b"AAAA%p%p",
b"\x90\x90\x90\x90",
)
def tcp_message(flow: tcp.TCPFlow) -> None:
message = flow.messages[-1]
if not message.from_client:
return
for sequence in BLOCKED_SEQUENCES:
if sequence in message.content:
logging.warning(
"[tcp-block-blacklisted-byte-sequences] blocked sequence=%r",
sequence,
)
message.drop()
flow.kill()
return
@@ -0,0 +1,35 @@
id: tcp-block-message-length-threshold
name: Block oversized TCP messages
protocol: tcp
description: Cuts off client messages that exceed a fixed size threshold.
category: tcp
family: tcp-hardening
tags:
- length-limit
- tcp
- block
action: block
summary: Cuts off client messages that exceed a fixed size threshold.
behavior: Checks every client TCP message size, logs the offending length, drops the
message, and kills the flow when the threshold is hit.
source_url: https://docs.mitmproxy.org/stable/addons/examples/
code: |
import logging
from mitmproxy import tcp
MAX_MESSAGE_BYTES = 4096
def tcp_message(flow: tcp.TCPFlow) -> None:
message = flow.messages[-1]
if not message.from_client:
return
size = len(message.content)
if size > MAX_MESSAGE_BYTES:
logging.warning(
"[tcp-block-message-length-threshold] blocked message size=%d",
size,
)
message.drop()
flow.kill()
@@ -0,0 +1,42 @@
id: tcp-block-non-printable-threshold
name: Block binary-noise threshold
protocol: tcp
description: Blocks client messages with an excessive amount of non-printable bytes.
category: tcp
family: tcp-hardening
tags:
- non-printable
- binary-noise
- tcp
- block
action: block
summary: Blocks client messages with an excessive amount of non-printable bytes.
behavior: Treats unusually binary-heavy client payloads as suspicious, logs the ratio,
and closes the flow when the threshold is exceeded.
source_url: https://docs.mitmproxy.org/stable/addons/examples/
code: |
import logging
from mitmproxy import tcp
NON_PRINTABLE_RATIO = 0.35
def tcp_message(flow: tcp.TCPFlow) -> None:
message = flow.messages[-1]
if not message.from_client or not message.content:
return
non_printable = sum(
1
for byte in message.content
if byte < 0x20 and byte not in {0x09, 0x0a, 0x0d}
)
ratio = non_printable / len(message.content)
if ratio > NON_PRINTABLE_RATIO:
logging.warning(
"[tcp-block-non-printable-threshold] blocked ratio=%.2f size=%d",
ratio,
len(message.content),
)
message.drop()
flow.kill()
@@ -0,0 +1,54 @@
id: tcp-block-repeated-byte-threshold
name: Block repeated-byte flood
protocol: tcp
description: Closes connections that send suspiciously long runs of the same byte.
category: tcp
family: tcp-hardening
tags:
- bof
- repeated-byte
- tcp
- block
action: block
summary: Closes connections that send suspiciously long runs of the same byte.
behavior: Measures the longest repeated-byte run in each client message, logs the
byte and run length, and closes the TCP flow on threshold hit.
source_url: https://docs.mitmproxy.org/stable/addons/examples/
code: |
import logging
from mitmproxy import tcp
MAX_REPEAT = 128
def longest_run(data: bytes) -> tuple[int, int]:
best_byte = -1
best_run = 0
current_byte = -1
current_run = 0
for byte in data:
if byte == current_byte:
current_run += 1
else:
current_byte = byte
current_run = 1
if current_run > best_run:
best_byte = byte
best_run = current_run
return best_byte, best_run
def tcp_message(flow: tcp.TCPFlow) -> None:
message = flow.messages[-1]
if not message.from_client:
return
repeated_byte, run_length = longest_run(message.content)
if run_length > MAX_REPEAT:
logging.warning(
"[tcp-block-repeated-byte-threshold] blocked byte=0x%02x run=%d",
repeated_byte,
run_length,
)
message.drop()
flow.kill()