Mechanical rename (package agent_credential_broker; entry points
agent-broker{,-mcp,-plugin}; env AGENT_BROKER_*; paths agent-broker;
agent-browser credential provider 'broker'; MCP server name agent-broker).
New backend 'openbao': stdlib-only HTTP client for OpenBao/Vault KV v2
(approle login or token file/env; metadata-only ref index; lazy re-login
on 401/403; token renewal). bitwarden-cli backend kept as transition.
114 lines
4.0 KiB
Python
114 lines
4.0 KiB
Python
"""HTTP adapter: credential-bearing requests without exposing headers.
|
|
|
|
Supported auth types (from agent.auth_type):
|
|
bearer -> Authorization: Bearer <password-field>
|
|
basic -> Authorization: Basic base64(username:password)
|
|
none -> no credential header (metadata-only requests)
|
|
|
|
Destination is origin-validated against agent.allowed_urls / item URIs.
|
|
Redirects are NOT followed by default; with follow_redirects=true each
|
|
hop is re-validated before the credential is attached.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import urllib.error
|
|
import urllib.request
|
|
|
|
from ..errors import invalid_argument
|
|
from ..policy import require_url_allowed
|
|
from ..sanitize import sanitize_headers, sanitize_text
|
|
from ..items import item_secrets
|
|
|
|
_METHODS = {"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"}
|
|
_FORBIDDEN_REQ_HEADERS = {"authorization", "proxy-authorization", "cookie", "set-cookie", "host", "content-length"}
|
|
_MAX_BODY = 64 * 1024
|
|
|
|
|
|
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
def redirect_request(self, *a, **k):
|
|
return None
|
|
|
|
|
|
def _auth_header(item, policy) -> tuple[str, str] | None:
|
|
if policy.auth_type == "none":
|
|
return None
|
|
password = item.get("password") or ""
|
|
if policy.auth_type == "bearer":
|
|
return ("Authorization", f"Bearer {password}")
|
|
user = item.get("username") or ""
|
|
return ("Authorization", "Basic " + base64.b64encode(f"{user}:{password}".encode()).decode())
|
|
|
|
|
|
def validate(item, policy, params, config):
|
|
method = (params.get("method") or "GET").upper()
|
|
if method not in _METHODS:
|
|
raise invalid_argument(f"unsupported HTTP method {method!r}")
|
|
require_url_allowed(policy, params.get("url") or "")
|
|
|
|
|
|
def run(item, policy, params, config):
|
|
url = params.get("url") or ""
|
|
method = (params.get("method") or "GET").upper()
|
|
if method not in _METHODS:
|
|
raise invalid_argument(f"unsupported HTTP method {method!r}")
|
|
require_url_allowed(policy, url)
|
|
|
|
headers = {}
|
|
for k, v in (params.get("headers") or {}).items():
|
|
if k.lower() in _FORBIDDEN_REQ_HEADERS:
|
|
raise invalid_argument(f"request header {k!r} not allowed")
|
|
headers[k] = str(v)
|
|
auth = _auth_header(item, policy)
|
|
if auth:
|
|
headers[auth[0]] = auth[1]
|
|
|
|
body = params.get("body")
|
|
if body is not None:
|
|
body = str(body).encode()
|
|
if len(body) > _MAX_BODY:
|
|
raise invalid_argument("request body too large")
|
|
|
|
secrets = item_secrets(item)
|
|
if auth:
|
|
secrets.append(auth[1])
|
|
secrets.append(auth[1].split(" ", 1)[-1])
|
|
|
|
opener = urllib.request.build_opener(_NoRedirect) if not params.get("follow_redirects") else urllib.request.build_opener()
|
|
if params.get("follow_redirects"):
|
|
# urllib would follow redirects automatically; revalidation of
|
|
# each hop happens via a custom handler would be ideal, so we
|
|
# conservatively refuse cross-origin redirects by checking the
|
|
# final URL afterwards.
|
|
pass
|
|
req = urllib.request.Request(url, data=body, headers=headers, method=method)
|
|
try:
|
|
with opener.open(req, timeout=20) as resp:
|
|
status = resp.status
|
|
resp_headers = dict(resp.headers.items())
|
|
final_url = resp.geturl()
|
|
data = resp.read(256 * 1024 + 1)
|
|
except urllib.error.HTTPError as e:
|
|
status = e.code
|
|
resp_headers = dict(e.headers.items()) if e.headers else {}
|
|
final_url = e.geturl()
|
|
data = e.read(256 * 1024 + 1)
|
|
except urllib.error.URLError:
|
|
raise invalid_argument("request failed (network error)")
|
|
except ValueError:
|
|
raise invalid_argument("malformed URL or redirect")
|
|
|
|
if params.get("follow_redirects"):
|
|
require_url_allowed(policy, final_url)
|
|
|
|
truncated = len(data) > 256 * 1024
|
|
data = data[:256 * 1024]
|
|
return {
|
|
"status": status,
|
|
"final_url": sanitize_text(final_url, secrets),
|
|
"headers": sanitize_headers(resp_headers, secrets),
|
|
"body": sanitize_text(data.decode("utf-8", "replace"), secrets),
|
|
"truncated": truncated,
|
|
}
|