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.
This commit is contained in:
commit
be87900794
35 files changed
+5003
No files matched your search
@@ -0,0 +1,113 @@
|
||||
"""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,
|
||||
}
|
||||
Reference in new issue
Block a user