Bound approval transport and refuse redirects and proxies
Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a126e1-76f8-7643-8cf3-e93336539ade
This commit is contained in:
parent
2eb045bfbf
commit
b428ffecb1
8 changed files with 552 additions and 16 deletions
71
approval_engine/http_transport.py
Normal file
71
approval_engine/http_transport.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
"""Isolated fixed-origin HTTP transport. No credentials in argv or diagnostics.
|
||||
|
||||
Invoked by the SDK through a bounded subprocess, not as an application command.
|
||||
Isolation allows the parent to terminate DNS, reads and exceptional close without
|
||||
signals in an application's main thread or mandatory multiprocessing main guards.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from urllib.error import HTTPError
|
||||
from urllib.request import HTTPRedirectHandler, ProxyHandler, Request, build_opener
|
||||
|
||||
MAX_BODY = 256 * 1024
|
||||
|
||||
|
||||
class NoRedirect(HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
raise HTTPError(req.full_url, code, "redirect refused", headers, fp)
|
||||
|
||||
|
||||
def fetch(data):
|
||||
request = Request(data["url"], data=data["body"].encode("utf-8") if data["body"] is not None else None,
|
||||
method=data["method"], headers={"Authorization": "Bearer " + data["token"],
|
||||
"Content-Type": "application/json", "Accept": "application/json"})
|
||||
opener = build_opener(ProxyHandler({}), NoRedirect())
|
||||
response = None
|
||||
try:
|
||||
response = opener.open(request, timeout=data["timeout"])
|
||||
if response.geturl() != data["url"] or response.getcode() != 200:
|
||||
return {"error": "status", "status": response.getcode()}
|
||||
types = response.headers.get_all("Content-Type", [])
|
||||
if len(types) != 1 or response.headers.get_content_type() != "application/json":
|
||||
return {"error": "content"}
|
||||
charset = response.headers.get_content_charset()
|
||||
if charset not in (None, "utf-8") or response.headers.get("Content-Encoding", "identity").lower() != "identity":
|
||||
return {"error": "content"}
|
||||
lengths = response.headers.get_all("Content-Length", [])
|
||||
if len(lengths) > 1 or (lengths and (not lengths[0].isdigit() or int(lengths[0]) > MAX_BODY)):
|
||||
return {"error": "content"}
|
||||
raw = response.read(MAX_BODY + 1)
|
||||
if len(raw) > MAX_BODY or (lengths and len(raw) != int(lengths[0])):
|
||||
return {"error": "content"}
|
||||
return {"body": raw.decode("utf-8")}
|
||||
except HTTPError as exc:
|
||||
# Never read a refused/redirect/error body; even close is parent-bounded.
|
||||
try:
|
||||
exc.close()
|
||||
except Exception:
|
||||
pass
|
||||
return {"error": "status", "status": int(exc.code)}
|
||||
except Exception:
|
||||
return {"error": "unavailable"}
|
||||
finally:
|
||||
if response is not None:
|
||||
try:
|
||||
response.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
data = json.loads(sys.stdin.buffer.read(MAX_BODY + 1))
|
||||
result = fetch(data)
|
||||
except Exception:
|
||||
result = {"error": "unavailable"}
|
||||
sys.stdout.write(json.dumps(result, allow_nan=False))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -3,12 +3,18 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import quote
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.parse import quote, urlsplit
|
||||
from urllib.request import Request
|
||||
|
||||
from .binding import DIGEST_RE
|
||||
|
||||
|
|
@ -41,28 +47,134 @@ class ApprovalHTTPClient:
|
|||
token_file: str | Path,
|
||||
*,
|
||||
timeout_seconds: float = 3,
|
||||
opener: Callable[..., Any] = urlopen,
|
||||
opener: Callable[..., Any] | None = None,
|
||||
) -> None:
|
||||
if not base_url or not base_url.startswith(("http://", "https://")):
|
||||
try:
|
||||
parsed = urlsplit(base_url)
|
||||
valid = (parsed.scheme in {"http", "https"} and parsed.hostname and
|
||||
parsed.port != 0 and not parsed.username and not parsed.password and
|
||||
not parsed.query and not parsed.fragment and
|
||||
not any(ch.isspace() or ord(ch) < 32 for ch in base_url))
|
||||
except (TypeError, ValueError):
|
||||
valid = False
|
||||
if not valid:
|
||||
raise ApprovalProtocolError("approval-engine URL is missing or invalid")
|
||||
if isinstance(timeout_seconds, bool) or not isinstance(timeout_seconds, (int, float)) or not math.isfinite(timeout_seconds) or not 0 < timeout_seconds <= 60:
|
||||
raise ApprovalProtocolError("approval timeout must be finite and bounded")
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.token_file = Path(token_file)
|
||||
self.timeout_seconds = timeout_seconds
|
||||
self.opener = opener
|
||||
|
||||
def _token(self) -> str:
|
||||
token = self.token_file.read_text(encoding="utf-8").strip()
|
||||
if not token or any(ch.isspace() for ch in token):
|
||||
raise ApprovalProtocolError("approval credential is unavailable")
|
||||
fd = None
|
||||
directory = None
|
||||
try:
|
||||
parts = self.token_file.absolute().parts
|
||||
if ".." in parts:
|
||||
raise ValueError("unsafe credential path")
|
||||
directory = os.open(parts[0], os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
||||
for part in parts[1:-1]:
|
||||
child = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=directory)
|
||||
os.close(directory)
|
||||
directory = child
|
||||
fd = os.open(parts[-1], os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=directory)
|
||||
info = os.fstat(fd)
|
||||
if (not stat.S_ISREG(info.st_mode) or info.st_uid not in {0, os.geteuid()}
|
||||
or info.st_mode & 0o037 or info.st_size > 16384 or info.st_nlink != 1):
|
||||
raise ValueError("unsafe credential file")
|
||||
raw = os.read(fd, 16385)
|
||||
token = raw.decode("utf-8").strip()
|
||||
if len(raw) > 16384 or not token or any(ch.isspace() or ord(ch) < 33 or ord(ch) > 126 for ch in token):
|
||||
raise ValueError("invalid credential")
|
||||
except (OSError, UnicodeError, ValueError):
|
||||
raise ApprovalProtocolError("approval credential is unavailable") from None
|
||||
finally:
|
||||
if fd is not None:
|
||||
os.close(fd)
|
||||
if directory is not None:
|
||||
os.close(directory)
|
||||
return token
|
||||
|
||||
@staticmethod
|
||||
def _decode(raw: str) -> dict[str, Any]:
|
||||
def pairs(items):
|
||||
result = {}
|
||||
for key, value in items:
|
||||
if key in result:
|
||||
raise ValueError("duplicate JSON key")
|
||||
result[key] = value
|
||||
return result
|
||||
def constant(_value):
|
||||
raise ValueError("non-finite JSON value")
|
||||
def number(value):
|
||||
parsed = float(value)
|
||||
if not math.isfinite(parsed):
|
||||
raise ValueError("non-finite JSON number")
|
||||
return parsed
|
||||
try:
|
||||
result = json.loads(raw, object_pairs_hook=pairs, parse_constant=constant, parse_float=number)
|
||||
except (ValueError, RecursionError):
|
||||
raise ApprovalProtocolError("approval endpoint returned invalid JSON") from None
|
||||
if not isinstance(result, dict):
|
||||
raise ApprovalProtocolError("approval endpoint returned invalid payload")
|
||||
return result
|
||||
|
||||
def _isolated_request(self, method, url, body, token, deadline):
|
||||
payload = json.dumps({"method": method, "url": url, "body": body,
|
||||
"token": token, "timeout": self.timeout_seconds}, allow_nan=False).encode()
|
||||
if len(payload) > _MAX_BODY:
|
||||
raise ApprovalProtocolError("approval request is too large")
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise ApprovalProtocolError("approval transport deadline exceeded")
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
[sys.executable, "-I", str(Path(__file__).with_name("http_transport.py"))],
|
||||
input=payload, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
||||
timeout=remaining, check=False, env={},
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise ApprovalProtocolError("approval transport deadline exceeded") from None
|
||||
except OSError:
|
||||
raise ApprovalProtocolError("approval-engine unreachable") from None
|
||||
if completed.returncode != 0 or len(completed.stdout) > 2 * _MAX_BODY:
|
||||
raise ApprovalProtocolError("approval transport failed")
|
||||
try:
|
||||
result = json.loads(completed.stdout)
|
||||
except (ValueError, UnicodeError):
|
||||
raise ApprovalProtocolError("approval transport failed") from None
|
||||
if not isinstance(result, dict):
|
||||
raise ApprovalProtocolError("approval transport failed")
|
||||
if result.get("error") == "status" and type(result.get("status")) is int:
|
||||
raise ApprovalProtocolError(_status_message(result["status"]))
|
||||
if result.get("error") == "content":
|
||||
raise ApprovalProtocolError("approval endpoint returned invalid content")
|
||||
if not isinstance(result.get("body"), str):
|
||||
raise ApprovalProtocolError("approval-engine unreachable")
|
||||
return self._decode(result["body"])
|
||||
|
||||
def _request(
|
||||
self, method: str, path: str, body: dict[str, Any] | None = None
|
||||
) -> dict[str, Any]:
|
||||
deadline = time.monotonic() + self.timeout_seconds
|
||||
if not isinstance(path, str) or not path.startswith("/") or path.startswith("//") or any(ch.isspace() or ord(ch) < 32 for ch in path):
|
||||
raise ApprovalProtocolError("approval request path is invalid")
|
||||
url = self.base_url + path
|
||||
if urlsplit(url)[:2] != urlsplit(self.base_url)[:2] or urlsplit(url).fragment:
|
||||
raise ApprovalProtocolError("approval request origin is invalid")
|
||||
token = self._token()
|
||||
encoded = None if body is None else json.dumps(body).encode("utf-8")
|
||||
try:
|
||||
text_body = None if body is None else json.dumps(body, allow_nan=False)
|
||||
except (ValueError, TypeError):
|
||||
raise ApprovalProtocolError("approval request is invalid") from None
|
||||
if self.opener is None:
|
||||
return self._isolated_request(method, url, text_body, token, deadline)
|
||||
# Explicit owner-controlled injection for test adapters. It does not
|
||||
# establish production transport admission or an isolated deadline.
|
||||
encoded = None if text_body is None else text_body.encode("utf-8")
|
||||
request = Request(
|
||||
self.base_url + path,
|
||||
url,
|
||||
data=encoded,
|
||||
method=method,
|
||||
headers={
|
||||
|
|
@ -85,17 +197,14 @@ class ApprovalHTTPClient:
|
|||
except Exception:
|
||||
pass
|
||||
raise ApprovalProtocolError(_status_message(status)) from None
|
||||
except (URLError, TimeoutError, OSError):
|
||||
except Exception:
|
||||
raise ApprovalProtocolError("approval-engine unreachable") from None
|
||||
if status != 200 or len(raw) > _MAX_BODY:
|
||||
raise ApprovalProtocolError(_status_message(status if status != 200 else 502))
|
||||
try:
|
||||
result = json.loads(raw.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise ApprovalProtocolError("approval endpoint returned invalid JSON") from exc
|
||||
if not isinstance(result, dict):
|
||||
raise ApprovalProtocolError("approval endpoint returned invalid payload")
|
||||
return result
|
||||
return self._decode(raw.decode("utf-8"))
|
||||
except UnicodeDecodeError:
|
||||
raise ApprovalProtocolError("approval endpoint returned invalid JSON") from None
|
||||
|
||||
def claim(self, approval_id: str) -> dict[str, Any]:
|
||||
if not _ID_RE.fullmatch(approval_id):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue