Files
wehub-resource-sync e0e362d700
SDK Tests / changes (push) Successful in 2m29s
Real E2E Tests / changes (push) Successful in 2m29s
Deploy Docs Pages / build (push) Has been cancelled
Deploy Docs Pages / deploy (push) Has been cancelled
Real E2E Tests / JavaScript E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Python E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Java E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / C# E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Go E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Real E2E CI (push) Has been cancelled
SDK Tests / SDK CI (push) Has been cancelled
SDK Tests / CLI Tests (push) Has been cancelled
SDK Tests / Python SDK Quality (code-interpreter) (push) Has been cancelled
SDK Tests / Python SDK Quality (sandbox) (push) Has been cancelled
SDK Tests / Python SDK Tests (code-interpreter) (push) Has been cancelled
SDK Tests / JavaScript SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / JavaScript SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Python SDK Tests (sandbox) (push) Has been cancelled
SDK Tests / CLI Quality (push) Has been cancelled
SDK Tests / Kotlin SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Kotlin SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / C# SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / C# SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Go SDK Quality And Tests (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:39:33 +08:00

578 lines
20 KiB
Python

# Copyright 2026 Alibaba Group Holding Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# OpenSandbox egress system addon.
#
# Always loaded by the egress mitmproxy launcher. Stays transparent on the
# wire (does not add or alter headers that would reveal the proxy to peers).
#
# Behavior:
# 1. Forces streaming for SSE / chunked responses so each chunk is forwarded
# immediately, bypassing the stream_large_bodies=1m buffer set in config.yaml
# (which otherwise stalls LLM-style small-chunk streams).
# 2. Acts as Credential Proxy when the egress sidecar has an active
# Credential Vault revision. The active revision is stored in the Go
# sidecar process and read over an egress-container-private Unix socket.
# Credential values are not logged, and response header values containing
# credentials are redacted. Response bodies are not rewritten by default.
# 3. Implements SNI-aware ignore_hosts for transparent mode. mitmproxy's
# built-in ignore_hosts check in transparent mode matches against the
# destination IP first; the SNI hostname is only available inside the TLS
# ClientHello, which arrives after the initial check. This addon re-checks
# the same ignore_hosts patterns against the SNI hostname at the
# tls_clienthello layer and sets ignore_connection=True when a match is
# found, ensuring domain-based TLS pass-through works reliably.
#
# User-defined addons can be loaded alongside this script via
# OPENSANDBOX_EGRESS_MITMPROXY_SCRIPT (comma-separated for multiple scripts).
from __future__ import annotations
import http.client as http_client
import json
import os
import re
import socket
import time
from typing import Any
from urllib.parse import quote, quote_plus, unquote
from mitmproxy import ctx, http
from mitmproxy.tls import ClientHelloData
CREDENTIAL_PROXY_SOCKET_ENV = "OPENSANDBOX_CREDENTIAL_PROXY_SOCKET"
DEFAULT_CREDENTIAL_PROXY_SOCKET = "/run/opensandbox/credential-proxy/active.sock"
ACTIVE_VAULT_PATH = "/credential-vault/_active"
VAULT_CACHE_TTL_SECONDS = 0.5
FLOW_REDACTIONS_KEY = "opensandbox_credential_redactions"
HEADER_SUBSTITUTION_DENYLIST = {
"host",
"content-length",
"transfer-encoding",
"connection",
"upgrade",
"te",
"trailer",
"proxy-authorization",
"proxy-authenticate",
"forwarded",
"x-forwarded-for",
"x-forwarded-host",
"x-forwarded-proto",
}
class ActiveVault:
def __init__(
self,
revision: int,
bindings: list[dict[str, Any]],
redactions: list[str],
) -> None:
self.revision = revision
self.bindings = bindings
self.redactions = redactions
_vault_cache: ActiveVault | None = None
_vault_cache_loaded_at = 0.0
class UnixSocketHTTPConnection(http_client.HTTPConnection):
def __init__(self, socket_path: str, timeout: float) -> None:
super().__init__("credential-proxy", timeout=timeout)
self.socket_path = socket_path
def connect(self) -> None:
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.settimeout(self.timeout)
sock.connect(self.socket_path)
self.sock = sock
def tls_clienthello(data: ClientHelloData) -> None:
"""Re-check ignore_hosts patterns against SNI hostname.
In transparent mode, mitmproxy checks ignore_hosts against the
destination IP:port before the TLS handshake. If the check fails at
that stage (SNI not yet available), we get a second chance here with
the actual hostname from the ClientHello SNI extension.
"""
sni = data.client_hello.sni
if not sni:
return
patterns = ctx.options.ignore_hosts
if not patterns:
return
for pattern in patterns:
try:
if re.search(pattern, sni):
data.ignore_connection = True
return
except re.error:
pass
def _load_active_vault() -> ActiveVault | None:
global _vault_cache, _vault_cache_loaded_at
now = time.monotonic()
if _vault_cache is not None and now - _vault_cache_loaded_at < VAULT_CACHE_TTL_SECONDS:
return _vault_cache
socket_path = (
os.environ.get(CREDENTIAL_PROXY_SOCKET_ENV, "").strip()
or DEFAULT_CREDENTIAL_PROXY_SOCKET
)
connection = UnixSocketHTTPConnection(socket_path, timeout=0.25)
try:
connection.request("GET", ACTIVE_VAULT_PATH)
response = connection.getresponse()
body = response.read()
if response.status == 404:
_vault_cache = None
_vault_cache_loaded_at = now
return None
if response.status != 200:
ctx.log.warn(
f"credential proxy: active vault lookup failed with HTTP {response.status}"
)
_vault_cache = None
_vault_cache_loaded_at = now
return None
payload = json.loads(body.decode("utf-8"))
except Exception as exc: # noqa: BLE001 - mitm addon must not crash traffic handling
ctx.log.warn(f"credential proxy: active vault lookup failed: {exc}")
_vault_cache = None
_vault_cache_loaded_at = now
return None
finally:
connection.close()
bindings = payload.get("bindings") or []
redactions = [v for v in (payload.get("redactions") or []) if isinstance(v, str) and v]
_vault_cache = ActiveVault(
revision=int(payload.get("revision") or 0),
bindings=bindings,
redactions=redactions,
)
_vault_cache_loaded_at = now
return _vault_cache
def _request_host(flow: http.HTTPFlow) -> str:
host = flow.request.pretty_host or flow.request.host or ""
return host.rstrip(".").lower()
def _request_port(flow: http.HTTPFlow) -> int:
if flow.request.port:
return int(flow.request.port)
return 443 if flow.request.scheme == "https" else 80
def _request_path(flow: http.HTTPFlow) -> str:
path = flow.request.path or "/"
return path.split("?", 1)[0] or "/"
_DOT_SEGMENT_RE = re.compile(r"/\.\.(/|$)")
def _path_is_ambiguous(raw_path: str) -> bool:
"""Return True if the raw request path contains ambiguous segments.
Legitimate HTTP clients resolve dot segments before sending. Raw ``..``
or percent-encoded separators on the wire indicate an attempt to confuse
path-based authorization checks because the canonical path seen by the
upstream server may differ from the raw prefix matched here.
"""
path = raw_path.split("?", 1)[0]
# Only match ``..`` as a complete path segment (/../ or trailing /..).
if _DOT_SEGMENT_RE.search(path):
return True
# Iteratively decode to catch nested encodings like %252e%252e or %252f.
decoded = path
for _ in range(10):
lower = decoded.lower()
if "%2f" in lower or "%5c" in lower:
return True
if "\\" in decoded:
return True
next_decoded = unquote(decoded)
if next_decoded == decoded:
break
decoded = next_decoded
if _DOT_SEGMENT_RE.search(decoded):
return True
return False
def _host_matches(host: str, pattern: str) -> tuple[bool, int]:
pattern = pattern.rstrip(".").lower()
if pattern.startswith("*."):
suffix = pattern[1:]
apex = pattern[2:]
return host.endswith(suffix) and host != apex, 1
return host == pattern, 2
def _path_matches(path: str, pattern: str) -> bool:
if pattern.endswith("*"):
return path.startswith(pattern[:-1])
return path == pattern
def _binding_matches(flow: http.HTTPFlow, binding: dict[str, Any]) -> tuple[bool, int]:
match = binding.get("match") or {}
scheme = (flow.request.scheme or "").lower()
host = _request_host(flow)
port = _request_port(flow)
method = (flow.request.method or "").upper()
path = _request_path(flow)
schemes = match.get("schemes") or ["https"]
if scheme not in schemes:
return False, 0
canonical_port = 443 if scheme == "https" else 80
if port != canonical_port:
return False, 0
if method not in [m.upper() for m in (match.get("methods") or ["GET", "POST", "PUT", "PATCH", "DELETE"])]:
return False, 0
if not any(_path_matches(path, p) for p in (match.get("paths") or ["/*"])):
return False, 0
best_precedence = 0
for pattern in match.get("hosts") or []:
ok, precedence = _host_matches(host, pattern)
if ok and precedence > best_precedence:
best_precedence = precedence
return best_precedence > 0, best_precedence
def _select_binding(flow: http.HTTPFlow, vault: ActiveVault) -> dict[str, Any] | None:
matches: list[tuple[int, dict[str, Any]]] = []
for binding in vault.bindings:
ok, precedence = _binding_matches(flow, binding)
if ok:
matches.append((precedence, binding))
if not matches:
return None
highest = max(precedence for precedence, _ in matches)
selected = [binding for precedence, binding in matches if precedence == highest]
if len(selected) != 1:
flow.response = http.Response.make(
403,
b"credential binding ambiguous\n",
{"content-type": "text/plain"},
)
ctx.log.warn(
"credential proxy: ambiguous binding match for "
f"{flow.request.method} {_request_host(flow)}{_request_path(flow)}"
)
return None
return selected[0]
def _split_path_query(raw_path: str) -> tuple[str, str | None]:
if "?" not in raw_path:
return raw_path or "/", None
path, query = raw_path.split("?", 1)
return path or "/", query
def _request_body_bytes(flow: http.HTTPFlow) -> bytes | None:
body = getattr(flow.request, "raw_content", None)
if body is None:
body = getattr(flow.request, "content", None)
if body is None:
return None
if isinstance(body, str):
return body.encode("utf-8")
return body
def _set_request_body_bytes(flow: http.HTTPFlow, body: bytes) -> None:
flow.request.content = body
if "transfer-encoding" in flow.request.headers:
del flow.request.headers["transfer-encoding"]
flow.request.headers["content-length"] = str(len(body))
def _encoded_substitution_value(value: str, surface: str, content_type: str = "") -> str:
if surface in {"path", "query"}:
return quote(value, safe="")
if surface == "body":
normalized_type = content_type.split(";", 1)[0].strip().lower()
if normalized_type == "application/json" or normalized_type.endswith("+json"):
return json.dumps(value)[1:-1]
if normalized_type == "application/x-www-form-urlencoded":
return quote_plus(value, safe="")
return value
def _replace_literals_once(text: str, replacements: list[tuple[str, str]]) -> tuple[str, list[int]]:
# Apply replacements against the original text so inserted credential values
# are never scanned again for later placeholders.
if not replacements:
return text, []
parts: list[str] = []
applied: list[int] = []
applied_set: set[int] = set()
i = 0
while i < len(text):
selected_index = -1
selected_placeholder = ""
selected_replacement = ""
for index, (placeholder, replacement) in enumerate(replacements):
if len(placeholder) <= len(selected_placeholder):
continue
if text.startswith(placeholder, i):
selected_index = index
selected_placeholder = placeholder
selected_replacement = replacement
if selected_index >= 0:
parts.append(selected_replacement)
if selected_index not in applied_set:
applied.append(selected_index)
applied_set.add(selected_index)
i += len(selected_placeholder)
continue
parts.append(text[i])
i += 1
if not applied:
return text, []
return "".join(parts), applied
def _substitution_replacements(
substitutions: list[dict[str, Any]], surface: str, content_type: str = ""
) -> list[tuple[str, str]]:
replacements: list[tuple[str, str]] = []
for substitution in substitutions:
placeholder = substitution.get("placeholder")
value = substitution.get("value")
surfaces = substitution.get("in") or []
if not placeholder or value is None or surface not in surfaces:
continue
replacements.append(
(
str(placeholder),
_encoded_substitution_value(str(value), surface, content_type),
)
)
return replacements
def _apply_path_query_substitutions(
flow: http.HTTPFlow,
substitutions: list[dict[str, Any]],
) -> list[str]:
raw_path = flow.request.path or "/"
path_part, query_part = _split_path_query(raw_path)
path_changed = False
query_changed = False
applied: list[str] = []
path_part, path_applied = _replace_literals_once(
path_part, _substitution_replacements(substitutions, "path")
)
if path_applied:
path_changed = True
applied.extend("path" for _ in path_applied)
if query_part is not None:
query_part, query_applied = _replace_literals_once(
query_part, _substitution_replacements(substitutions, "query")
)
if query_applied:
query_changed = True
applied.extend("query" for _ in query_applied)
if path_changed:
candidate_path = path_part
if query_part is not None:
candidate_path = f"{candidate_path}?{query_part}"
if _path_is_ambiguous(candidate_path):
flow.response = http.Response.make(
403,
b"request path contains ambiguous substituted segments\n",
{"content-type": "text/plain"},
)
ctx.log.warn(
"credential proxy: rejected request after path substitution: "
f"{flow.request.method} {_request_host(flow)} path=[REDACTED]"
)
return applied
if path_changed or query_changed:
flow.request.path = path_part if query_part is None else f"{path_part}?{query_part}"
return applied
def _apply_header_substitutions(
flow: http.HTTPFlow,
substitutions: list[dict[str, Any]],
) -> list[str]:
applied: list[str] = []
replacements = _substitution_replacements(substitutions, "header")
if not replacements:
return applied
for name, header_value in list(flow.request.headers.items()):
if name.lower() in HEADER_SUBSTITUTION_DENYLIST:
continue
updated, header_applied = _replace_literals_once(str(header_value), replacements)
if header_applied:
flow.request.headers[name] = updated
applied.extend("header" for _ in header_applied)
return applied
def _apply_body_substitutions(
flow: http.HTTPFlow,
substitutions: list[dict[str, Any]],
) -> list[str]:
body = _request_body_bytes(flow)
if body is None:
return []
content_encoding = flow.request.headers.get("content-encoding", "").strip().lower()
if content_encoding and content_encoding != "identity":
return []
content_type = flow.request.headers.get("content-type", "")
if content_type.split(";", 1)[0].strip().lower().startswith("multipart/"):
return []
try:
text = body.decode("utf-8")
except UnicodeDecodeError:
return []
applied: list[str] = []
replacements = _substitution_replacements(substitutions, "body", content_type)
text, body_applied = _replace_literals_once(text, replacements)
applied.extend("body" for _ in body_applied)
if applied:
_set_request_body_bytes(flow, text.encode("utf-8"))
return applied
def _apply_substitutions(flow: http.HTTPFlow, binding: dict[str, Any]) -> list[str]:
substitutions = binding.get("substitutions") or []
if not substitutions:
return []
applied = []
applied.extend(_apply_path_query_substitutions(flow, substitutions))
if flow.response is not None and getattr(flow.response, "status_code", None) == 403:
return applied
applied.extend(_apply_header_substitutions(flow, substitutions))
applied.extend(_apply_body_substitutions(flow, substitutions))
return applied
def request(flow: http.HTTPFlow) -> None:
vault = _load_active_vault()
if vault is None:
return
# Reject requests with ambiguous path segments before credential injection.
# Raw dot-segments or encoded variants on the wire are not produced by
# legitimate HTTP clients and can trick prefix-based path matching into
# injecting credentials for a scope the canonical path does not belong to.
raw_path = flow.request.path or "/"
if _path_is_ambiguous(raw_path):
flow.response = http.Response.make(
403,
b"request path contains ambiguous segments\n",
{"content-type": "text/plain"},
)
ctx.log.warn(
"credential proxy: rejected request with ambiguous path: "
f"{flow.request.method} {_request_host(flow)}{_request_path(flow)}"
)
return
binding = _select_binding(flow, vault)
if not binding:
return
substituted_surfaces = _apply_substitutions(flow, binding)
if flow.response is not None and getattr(flow.response, "status_code", None) == 403:
return
injected_names: list[str] = []
for header in binding.get("headers") or []:
name = header.get("name")
value = header.get("value")
if not name or value is None:
continue
# mitmproxy Headers is case-insensitive; delete first to avoid duplicate
# effective header names before setting the credentialed value.
if name in flow.request.headers:
del flow.request.headers[name]
flow.request.headers[name] = value
injected_names.append(name)
if injected_names or substituted_surfaces:
flow.metadata[FLOW_REDACTIONS_KEY] = list(vault.redactions)
ctx.log.info(
"credential proxy: applied binding="
f"{binding.get('name')} revision={vault.revision} "
f"host={_request_host(flow)} method={flow.request.method} "
f"headers={','.join(injected_names)} "
f"substitutions={','.join(sorted(set(substituted_surfaces)))}"
)
elif binding.get("substitutions"):
ctx.log.info(
"credential proxy: substitution miss binding="
f"{binding.get('name')} revision={vault.revision} "
f"host={_request_host(flow)} method={flow.request.method}"
)
def responseheaders(flow: http.HTTPFlow) -> None:
if flow.response is None:
return
_redact_response_headers(flow)
content_type = flow.response.headers.get("content-type", "").lower()
transfer_encoding = flow.response.headers.get("transfer-encoding", "").lower()
if "text/event-stream" in content_type or "chunked" in transfer_encoding:
flow.response.stream = True
def _redact_response_headers(flow: http.HTTPFlow) -> None:
redactions = flow.metadata.get(FLOW_REDACTIONS_KEY, [])
if not redactions or flow.response is None:
return
for name, value in list(flow.response.headers.items()):
redacted = _redact_text(value, redactions)
if redacted != value:
flow.response.headers[name] = redacted
def _redact_text(text: str, values: list[str]) -> str:
out = text
for value in sorted({value for value in values if value}, key=len, reverse=True):
out = out.replace(value, "[REDACTED]")
return out