"""HTTP adapter: credential-bearing requests without exposing headers. Supported auth types (from agent.auth_type): bearer -> Authorization: Bearer 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, }