Files
agent-credential-broker/agent_credential_broker/adapters/http.py
T
me be87900794 agent-credential-broker: fork of vaultwarden-agent-broker + OpenBao backend
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.
2026-10-14 13:27:33 -06:00

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,
}