fix: confine PDP checks to the admitted local transport
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a12d13-27f2-7163-b8c0-f682af02772c
This commit is contained in:
parent
0db6adef69
commit
d379a3dfba
6 changed files with 268 additions and 50 deletions
|
|
@ -16,7 +16,7 @@ from dataclasses import dataclass, field
|
|||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.request import Request
|
||||
|
||||
from secrets_engine.approval_auth import approval_auth_configured, approval_token, credential_urlopen
|
||||
from secrets_engine.execution_contract import ExecutionContract, require_execution_deadline
|
||||
|
|
@ -332,7 +332,7 @@ def authorize_action(
|
|||
base_url=pdp_url,
|
||||
token_file=Path(pdp_token),
|
||||
request=expected_request,
|
||||
opener=pdp_opener or urlopen,
|
||||
opener=pdp_opener,
|
||||
)
|
||||
if not getattr(cfg, "clock_trust_file", None):
|
||||
wait_for_decision_start(envelope)
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ from pathlib import Path
|
|||
from urllib.parse import urlsplit
|
||||
from typing import Any, Callable
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.request import HTTPRedirectHandler, ProxyHandler, Request, build_opener
|
||||
|
||||
from secrets_engine.errors import DecisionError
|
||||
from secrets_engine.openbao import read_strict_token_file
|
||||
|
|
@ -28,8 +28,6 @@ _CLUSTER_SUFFIXES = (".svc.cluster.local", ".svc", ".cluster.local")
|
|||
|
||||
|
||||
def _is_loopback(host: str) -> bool:
|
||||
if host in ("localhost", "localhost."):
|
||||
return True
|
||||
try:
|
||||
return ipaddress.ip_address(host.strip("[]")).is_loopback
|
||||
except ValueError:
|
||||
|
|
@ -37,47 +35,51 @@ def _is_loopback(host: str) -> bool:
|
|||
|
||||
|
||||
def require_supported_pdp_address(base_url: str) -> None:
|
||||
"""Refuse address shapes the owner path excludes (FLEX-DEC-2026-010).
|
||||
"""Require a literal loopback origin before reading the caller credential.
|
||||
|
||||
The supported operator path is a loopback ``kubectl port-forward`` over the
|
||||
authenticated Kubernetes API, which is the only shape that authenticates the
|
||||
*responder*: it resolves no DNS name, targets one named pod, and rides the
|
||||
API server's TLS with our cluster credentials.
|
||||
|
||||
A cluster Service name must never be dialled from a workstation. It does not
|
||||
merely fail -- it RESOLVES, to the wrong host. This workstation carries
|
||||
``search ad.binect.de``, a wildcard zone, so every ``*.svc.cluster.local``
|
||||
name (including services that do not exist) answers with one unrelated
|
||||
public address. Sending there would ship the CheckRequest body and our
|
||||
bearer token to a third party, and because ``flex-auth.decision-record.v1``
|
||||
carries no signature, a responder that knows the published package id and
|
||||
version can return a well-formed allow that passes every check we make.
|
||||
|
||||
Fail-closed protects against a PDP that is ABSENT, not one that LIES. Until
|
||||
FLEX-WP-0024 ships detached signatures, the address shape is the only thing
|
||||
standing in for responder authenticity, so it is enforced rather than
|
||||
documented.
|
||||
The operator must independently bind the port-forward to the admitted
|
||||
Kubernetes cluster and named PDP pod. URL validation alone cannot establish
|
||||
the identity of a process listening on a local port. Hostnames, redirects
|
||||
and ambient proxies must not redirect the credential away from that port.
|
||||
"""
|
||||
host = (urlsplit(base_url).hostname or "").rstrip(".").lower()
|
||||
if not host:
|
||||
raise DecisionError("access-engine check URL has no host")
|
||||
if any(host.endswith(suffix) for suffix in _CLUSTER_SUFFIXES):
|
||||
try:
|
||||
url = urlsplit(base_url)
|
||||
host = (url.hostname or "").lower()
|
||||
valid = (
|
||||
bool(host) and url.scheme in {"http", "https"}
|
||||
and url.username is None and url.password is None
|
||||
and not url.query and not url.fragment and url.path in {"", "/"}
|
||||
and url.port != 0
|
||||
and not any(ord(char) <= 32 or ord(char) == 127 for char in base_url)
|
||||
)
|
||||
except ValueError:
|
||||
valid = False
|
||||
if not valid:
|
||||
raise DecisionError("access-engine check URL is missing or invalid")
|
||||
if any(host.rstrip(".").endswith(suffix) for suffix in _CLUSTER_SUFFIXES):
|
||||
raise DecisionError(
|
||||
f"access-engine check URL '{host}' is an in-cluster Service name; "
|
||||
"from a workstation it resolves through the DNS search suffix to an "
|
||||
"unrelated public host. Use the owner-documented loopback "
|
||||
"kubectl port-forward instead (docs/pdp-access-path.md)"
|
||||
"access-engine check URL is an in-cluster Service name; use the "
|
||||
"owner-bound literal loopback port-forward (docs/pdp-access-path.md)"
|
||||
)
|
||||
if not _is_loopback(host):
|
||||
raise DecisionError(
|
||||
f"access-engine check URL host '{host}' is not loopback; the "
|
||||
"supported path is a loopback kubectl port-forward over the "
|
||||
"authenticated Kubernetes API, which is what authenticates the "
|
||||
"responder (FLEX-DEC-2026-010). Set SECRETS_ENGINE_PDP_URL to the "
|
||||
"forwarded 127.0.0.1 port (docs/pdp-access-path.md)"
|
||||
"access-engine check URL host is not loopback in literal IP form; "
|
||||
"use the owner-bound 127.0.0.1 or [::1] port-forward "
|
||||
"(docs/pdp-access-path.md)"
|
||||
)
|
||||
|
||||
|
||||
class _NoRedirect(HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
return None
|
||||
|
||||
|
||||
def _pdp_urlopen(request, *, timeout):
|
||||
# This is a local tunnel: never inherit an environment/system proxy or a
|
||||
# process-global opener. No redirect, including same-origin, is a decision.
|
||||
return build_opener(ProxyHandler({}), _NoRedirect()).open(request, timeout=timeout)
|
||||
|
||||
|
||||
def _status_message(status: int) -> str:
|
||||
if status in (401, 403):
|
||||
return "access-engine refused the caller"
|
||||
|
|
@ -94,7 +96,7 @@ def check_decision(
|
|||
token_file: Path,
|
||||
request: dict[str, Any],
|
||||
timeout_seconds: float = 3,
|
||||
opener: Callable[..., Any] = urlopen,
|
||||
opener: Callable[..., Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""POST /v1/check. Any non-200, non-JSON, or transport failure fails closed.
|
||||
|
||||
|
|
@ -115,15 +117,18 @@ def check_decision(
|
|||
http_request.add_header("Content-Type", "application/json")
|
||||
http_request.add_header("Accept", "application/json")
|
||||
try:
|
||||
with opener(http_request, timeout=timeout_seconds) as response:
|
||||
with (opener or _pdp_urlopen)(http_request, timeout=timeout_seconds) as response:
|
||||
if getattr(response, "status", 200) != 200:
|
||||
raise DecisionError("access-engine check did not return a decision")
|
||||
payload = json.loads(response.read(_MAX_BODY).decode("utf-8"))
|
||||
body = response.read(_MAX_BODY + 1)
|
||||
if len(body) > _MAX_BODY:
|
||||
raise DecisionError("access-engine check body is too large")
|
||||
payload = json.loads(body.decode("utf-8"))
|
||||
except HTTPError as e:
|
||||
raise DecisionError(f"access-engine check refused: {_status_message(e.code)}") from e
|
||||
except URLError as e:
|
||||
raise DecisionError("access-engine is unreachable for check") from e
|
||||
except json.JSONDecodeError as e:
|
||||
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
||||
raise DecisionError("access-engine check returned a non-JSON body") from e
|
||||
if not isinstance(payload, dict):
|
||||
raise DecisionError("access-engine check returned a non-object body")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue