mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-20 15:33:54 +00:00
fix(security): enforce one redirect credential policy
This commit is contained in:
parent
b8dd1bf3a5
commit
6e75ba7fa0
6 changed files with 448 additions and 139 deletions
|
|
@ -18,6 +18,7 @@ from pathlib import Path
|
|||
from typing import Any, NamedTuple, Optional
|
||||
|
||||
from hermes_cli import __version__ as _HERMES_VERSION
|
||||
from hermes_cli.urllib_security import open_credentialed_url
|
||||
|
||||
# Identify ourselves so endpoints fronted by Cloudflare's Browser Integrity
|
||||
# Check (error 1010) don't reject the default ``Python-urllib/*`` signature.
|
||||
|
|
@ -29,43 +30,9 @@ COPILOT_EDITOR_VERSION = "vscode/1.104.1"
|
|||
COPILOT_REASONING_EFFORTS_GPT5 = ["minimal", "low", "medium", "high"]
|
||||
COPILOT_REASONING_EFFORTS_O_SERIES = ["low", "medium", "high"]
|
||||
|
||||
_CREDENTIAL_REDIRECT_HEADERS = {
|
||||
"authorization",
|
||||
"x-api-key",
|
||||
"api-key",
|
||||
"x-goog-api-key",
|
||||
"cookie",
|
||||
}
|
||||
|
||||
_ORIGINAL_URLOPEN = urllib.request.urlopen
|
||||
|
||||
|
||||
class _StripCredentialRedirectHandler(urllib.request.HTTPRedirectHandler):
|
||||
"""Drop credential headers when urllib follows redirects to another host."""
|
||||
|
||||
def __init__(self, original_host: str):
|
||||
self._original_host = original_host
|
||||
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
new_host = (urllib.parse.urlparse(newurl).hostname or "").lower()
|
||||
if new_host and new_host != self._original_host:
|
||||
clean_headers = {
|
||||
name: value
|
||||
for name, value in req.header_items()
|
||||
if name.lower() not in _CREDENTIAL_REDIRECT_HEADERS
|
||||
}
|
||||
return urllib.request.Request(newurl, headers=clean_headers, method="GET")
|
||||
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
||||
|
||||
|
||||
def _urlopen_model_catalog_request(req: urllib.request.Request, *, timeout: float):
|
||||
if urllib.request.urlopen is not _ORIGINAL_URLOPEN:
|
||||
return urllib.request.urlopen(req, timeout=timeout)
|
||||
if not any(name.lower() in _CREDENTIAL_REDIRECT_HEADERS for name, _ in req.header_items()):
|
||||
return urllib.request.urlopen(req, timeout=timeout)
|
||||
original_host = (urllib.parse.urlparse(req.full_url).hostname or "").lower()
|
||||
opener = urllib.request.build_opener(_StripCredentialRedirectHandler(original_host))
|
||||
return opener.open(req, timeout=timeout)
|
||||
"""Open catalog requests without forwarding headers across origins."""
|
||||
return open_credentialed_url(req, timeout=timeout)
|
||||
|
||||
|
||||
# Fallback OpenRouter snapshot used when the live catalog is unavailable.
|
||||
|
|
@ -3154,15 +3121,13 @@ def ensure_lmstudio_model_loaded(
|
|||
load_headers = dict(headers)
|
||||
load_headers["Content-Type"] = "application/json"
|
||||
try:
|
||||
with urllib.request.urlopen(
|
||||
urllib.request.Request(
|
||||
server_root + "/api/v1/models/load",
|
||||
data=body,
|
||||
headers=load_headers,
|
||||
method="POST",
|
||||
),
|
||||
timeout=timeout,
|
||||
) as resp:
|
||||
load_request = urllib.request.Request(
|
||||
server_root + "/api/v1/models/load",
|
||||
data=body,
|
||||
headers=load_headers,
|
||||
method="POST",
|
||||
)
|
||||
with _urlopen_model_catalog_request(load_request, timeout=timeout) as resp:
|
||||
resp.read()
|
||||
except Exception:
|
||||
return None
|
||||
|
|
|
|||
80
hermes_cli/urllib_security.py
Normal file
80
hermes_cli/urllib_security.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
"""Security policy for credential-bearing stdlib urllib requests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from collections.abc import Callable, Iterable
|
||||
from typing import Any
|
||||
|
||||
# Headers safe to forward to a different origin. Everything else is dropped:
|
||||
# custom provider headers routinely carry credentials under arbitrary names.
|
||||
_CROSS_ORIGIN_SAFE_HEADERS = frozenset({"accept", "user-agent"})
|
||||
_DEFAULT_PORTS = {"http": 80, "https": 443}
|
||||
|
||||
|
||||
def url_origin(url: str) -> tuple[str, str, int | None]:
|
||||
"""Return a normalized (scheme, hostname, effective port) origin."""
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
scheme = (parsed.scheme or "").lower()
|
||||
try:
|
||||
port = parsed.port
|
||||
except ValueError:
|
||||
port = None
|
||||
return (
|
||||
scheme,
|
||||
(parsed.hostname or "").lower().rstrip("."),
|
||||
port or _DEFAULT_PORTS.get(scheme),
|
||||
)
|
||||
|
||||
|
||||
class SafeCredentialRedirectHandler(urllib.request.HTTPRedirectHandler):
|
||||
"""Preserve request headers only while redirects stay on one origin."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
original_url: str,
|
||||
*,
|
||||
cross_origin_safe_headers: Iterable[str] = _CROSS_ORIGIN_SAFE_HEADERS,
|
||||
) -> None:
|
||||
self._original_origin = url_origin(original_url)
|
||||
self._cross_origin_safe_headers = frozenset(
|
||||
str(name).lower() for name in cross_origin_safe_headers
|
||||
)
|
||||
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
# Let urllib enforce status/method semantics first (notably 307/308).
|
||||
redirected = super().redirect_request(req, fp, code, msg, headers, newurl)
|
||||
if redirected is None:
|
||||
return None
|
||||
|
||||
resolved_url = urllib.parse.urljoin(req.full_url, newurl)
|
||||
if url_origin(resolved_url) != self._original_origin:
|
||||
# Use an allowlist rather than guessing credential header names.
|
||||
# normalize_extra_headers permits arbitrary secret-bearing names.
|
||||
for name, _value in list(redirected.header_items()):
|
||||
if name.lower() not in self._cross_origin_safe_headers:
|
||||
redirected.remove_header(name)
|
||||
return redirected
|
||||
|
||||
|
||||
def open_credentialed_url(
|
||||
request: urllib.request.Request,
|
||||
*,
|
||||
timeout: float,
|
||||
opener_factory: Callable[..., Any] = urllib.request.build_opener,
|
||||
):
|
||||
"""Open a request without forwarding credentials across origins.
|
||||
|
||||
``opener_factory`` is an explicit instrumentation/test seam. Security is
|
||||
never disabled based on global ``urlopen`` identity.
|
||||
"""
|
||||
opener = opener_factory(SafeCredentialRedirectHandler(request.full_url))
|
||||
return opener.open(request, timeout=timeout)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SafeCredentialRedirectHandler",
|
||||
"open_credentialed_url",
|
||||
"url_origin",
|
||||
]
|
||||
Loading…
Add table
Add a link
Reference in a new issue