fix: confine PDP checks to the admitted local transport
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a12d13-27f2-7163-b8c0-f682af02772c
This commit is contained in:
tegwick 2026-10-12 08:27:37 +02:00
parent 0db6adef69
commit d379a3dfba
6 changed files with 268 additions and 50 deletions

View file

@ -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)

View file

@ -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")