Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ in the main repo).

## [Unreleased]

### Security — bounded stable HTTP task path

- Enforce the protocol's 1 MiB encoded request and response boundary for
`POST /v1/task`, including declared and chunked bodies, identity-only content
encoding, bounded provider output, and non-retryable oversize failures.

## [0.7.110] — 2026-08-28

### Changed — explicit qualification boundary
Expand Down
22 changes: 22 additions & 0 deletions parity/http-task-resource-boundary-v1.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"fixture_version": "1.0.0",
"profile": "iicp-supported-http-task-resource-boundary-v1",
"scope": "POST /v1/task HTTP/JSON coordinated stable binding",
"max_encoded_request_bytes": 1048576,
"max_encoded_response_bytes": 1048576,
"supported_content_encodings": ["identity"],
"vectors": [
{"id": "request_exact_limit", "direction": "request", "encoded_bytes": 1048576, "expected": "accept"},
{"id": "request_limit_plus_one", "direction": "request", "encoded_bytes": 1048577, "expected": "reject", "http_status": 413, "error_code": "request_too_large", "retryable": false, "fallback": false},
{"id": "declared_request_over_limit", "direction": "request", "content_length": 1048577, "expected": "reject_before_body_read", "http_status": 413, "error_code": "request_too_large"},
{"id": "conflicting_content_length", "direction": "request", "content_length_values": [12, 13], "expected": "reject", "http_status": 400, "error_code": "invalid_http_body"},
{"id": "chunked_exact_limit", "direction": "request", "transfer_encoding": "chunked", "encoded_bytes": 1048576, "expected": "accept"},
{"id": "chunked_limit_plus_one", "direction": "request", "transfer_encoding": "chunked", "encoded_bytes": 1048577, "expected": "reject_on_overrun", "http_status": 413, "error_code": "request_too_large"},
{"id": "unsupported_request_encoding", "direction": "request", "content_encoding": "gzip", "expected": "reject_before_decode", "http_status": 415, "error_code": "unsupported_content_encoding"},
{"id": "malformed_json", "direction": "request", "expected": "reject", "http_status": 400, "error_code": "invalid_http_body"},
{"id": "disconnect_before_declared_length", "direction": "request", "expected": "reject", "http_status": 400, "error_code": "invalid_http_body"},
{"id": "response_exact_limit", "direction": "response", "encoded_bytes": 1048576, "expected": "accept"},
{"id": "response_limit_plus_one", "direction": "response", "encoded_bytes": 1048577, "expected": "abort_and_reject", "error_code": "response_too_large", "retryable": false, "fallback": false},
{"id": "generated_response_over_limit", "direction": "response", "encoded_bytes": 1048577, "expected": "replace_with_bounded_error", "http_status": 500, "error_code": "response_too_large", "retryable": false}
]
}
94 changes: 86 additions & 8 deletions src/iicp_client/_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@

import httpx

from iicp_client._http_resource import (
HttpTaskBodyError,
append_response_chunk,
bounded_request_json,
validate_response_headers,
)
from iicp_client.endpoint_security import PinnedAsyncHTTPTransport, resolve_endpoint
from iicp_client.errors import IicpError, from_http

Expand Down Expand Up @@ -84,27 +90,47 @@ async def post_json(
pin_provider_endpoint: bool = False,
) -> tuple[dict[str, Any], int]:
"""Returns (response_body, elapsed_ms)."""
try:
encoded = bounded_request_json(body)
except HttpTaskBodyError as exc:
raise IicpError(
code=exc.code,
message=exc.message,
component=component,
retryable=False,
http_status=exc.status,
) from None
timeout = (timeout_ms / 1000.0) + 2.0
headers: dict[str, str] = {"traceparent": traceparent or _traceparent()}
headers: dict[str, str] = {
"traceparent": traceparent or _traceparent(),
"Content-Type": "application/json",
"Accept": "application/json",
}
if extra_headers:
headers.update(extra_headers)
t0 = time.monotonic()
response_client: httpx.AsyncClient | None = None
try:
if pin_provider_endpoint:
current = url
resp: httpx.Response | None = None
for redirect_count in range(4):
endpoint = await resolve_endpoint(current)
transport = PinnedAsyncHTTPTransport(endpoint, verify=_tls_context(tls_verify))
async with httpx.AsyncClient(
client = httpx.AsyncClient(
timeout=timeout,
transport=transport,
follow_redirects=False,
) as client:
resp = await client.post(current, json=body, headers=headers)
)
response_client = client
request = client.build_request("POST", current, content=encoded, headers=headers)
resp = await client.send(request, stream=True)
if resp.status_code in {307, 308}:
location = resp.headers.get("location")
if redirect_count == 3 or not location:
await resp.aclose()
await client.aclose()
response_client = None
raise IicpError(
code="IICP-ENDPOINT-REFUSED",
message="provider redirect limit exceeded or omitted Location",
Expand All @@ -116,44 +142,96 @@ async def post_json(
parsed_next = httpx.URL(next_url)
next_origin = (parsed_next.scheme, parsed_next.host, parsed_next.port)
if next_origin != current_origin:
await resp.aclose()
await client.aclose()
response_client = None
raise IicpError(
code="IICP-ENDPOINT-REFUSED",
message="cross-origin provider redirect is not allowed",
component=component,
retryable=False,
)
await resp.aclose()
await client.aclose()
response_client = None
current = next_url
continue
if 300 <= resp.status_code < 400:
await resp.aclose()
await client.aclose()
response_client = None
raise IicpError(
code="IICP-ENDPOINT-REFUSED",
message="provider redirect method is not allowed",
component=component,
retryable=False,
)
response_client = client
break
assert resp is not None
else:
async with httpx.AsyncClient(timeout=timeout, verify=_tls_context(tls_verify)) as client:
resp = await client.post(url, json=body, headers=headers)
response_client = httpx.AsyncClient(timeout=timeout, verify=_tls_context(tls_verify))
request = response_client.build_request("POST", url, content=encoded, headers=headers)
resp = await response_client.send(request, stream=True)
except httpx.TimeoutException:
if response_client is not None:
await response_client.aclose()
raise IicpError(
code="IICP-E003",
message=f"Request to {url} timed out after {timeout_ms}ms",
component=component,
retryable=True,
) from None
except httpx.RequestError as exc:
if response_client is not None:
await response_client.aclose()
raise IicpError(
code="IICP-E004",
message=f"Network error reaching {url}: {exc}",
component=component,
retryable=True,
) from exc
try:
validate_response_headers(resp.headers)
content = bytearray()
async for chunk in resp.aiter_raw():
append_response_chunk(content, chunk)
except HttpTaskBodyError as exc:
raise IicpError(
code=exc.code,
message=exc.message,
component=component,
retryable=False,
http_status=exc.status,
) from None
finally:
await resp.aclose()
if response_client is not None:
await response_client.aclose()
elapsed = int((time.monotonic() - t0) * 1000)
try:
decoded = httpx.Response(resp.status_code, content=bytes(content)).json()
except Exception:
decoded = {"error": {"code": "invalid_http_body", "message": "provider returned invalid JSON"}}
if resp.is_success:
raise IicpError(
code="invalid_http_body",
message="provider returned invalid JSON",
component=component,
retryable=False,
http_status=500,
) from None
if not resp.is_success:
raise from_http(resp.status_code, _safe_json(resp), component)
return resp.json(), elapsed
raise from_http(resp.status_code, decoded, component)
if not isinstance(decoded, dict):
raise IicpError(
code="invalid_http_body",
message="provider response must be a JSON object",
component=component,
retryable=False,
http_status=500,
)
return decoded, elapsed


def _safe_json(resp: httpx.Response) -> dict:
Expand Down
182 changes: 182 additions & 0 deletions src/iicp_client/_http_resource.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
"""Finite resource boundary for the supported HTTP ``POST /v1/task`` binding."""

from __future__ import annotations

import json
from dataclasses import dataclass
from typing import Any, Protocol

MAX_HTTP_TASK_BODY_BYTES = 1_048_576


@dataclass(frozen=True)
class HttpTaskBodyError(Exception):
status: int
code: str
message: str
close_connection: bool = False


class _HeadersLike(Protocol):
def get(self, name: str, default: Any = None) -> Any: ...


class _BinaryReader(Protocol):
def read(self, size: int = -1) -> bytes: ...

def readline(self, size: int = -1) -> bytes: ...


def encode_task_json(value: Any) -> bytes:
return json.dumps(value, separators=(",", ":"), ensure_ascii=False).encode("utf-8")


def bounded_request_json(value: Any) -> bytes:
encoded = encode_task_json(value)
if len(encoded) > MAX_HTTP_TASK_BODY_BYTES:
raise HttpTaskBodyError(
413,
"request_too_large",
f"encoded task request exceeds {MAX_HTTP_TASK_BODY_BYTES} bytes",
)
return encoded


def bounded_response_json(value: Any) -> tuple[int, bytes]:
encoded = encode_task_json(value)
if len(encoded) <= MAX_HTTP_TASK_BODY_BYTES:
return 200, encoded
return 500, encode_task_json(
{
"error": {
"code": "response_too_large",
"message": f"encoded task response exceeds {MAX_HTTP_TASK_BODY_BYTES} bytes",
}
}
)


def _header_values(headers: _HeadersLike, name: str) -> list[str]:
getter = getattr(headers, "get_all", None)
raw = getter(name) if callable(getter) else None
if raw is None:
value = headers.get(name)
raw = [] if value is None else [value]
values: list[str] = []
for item in raw:
values.extend(part.strip() for part in str(item).split(","))
return values


def _validate_content_encoding(headers: _HeadersLike) -> None:
encodings = [value.lower() for value in _header_values(headers, "Content-Encoding")]
if encodings and any(value != "identity" for value in encodings):
raise HttpTaskBodyError(
415,
"unsupported_content_encoding",
"supported HTTP task binding accepts identity encoding only",
True,
)


def _content_length(headers: _HeadersLike) -> int | None:
values = _header_values(headers, "Content-Length")
if not values:
return None
if any(not value.isascii() or not value.isdigit() for value in values):
raise HttpTaskBodyError(400, "invalid_http_body", "invalid Content-Length", True)
parsed = [int(value, 10) for value in values]
if len(set(parsed)) != 1:
raise HttpTaskBodyError(400, "invalid_http_body", "conflicting Content-Length", True)
return parsed[0]


def _read_exact(stream: _BinaryReader, count: int) -> bytes:
data = stream.read(count)
if len(data) != count:
raise HttpTaskBodyError(400, "invalid_http_body", "request body ended before declared length", True)
return data


def _read_chunked(stream: _BinaryReader) -> bytes:
body = bytearray()
while True:
line = stream.readline(8194)
if not line.endswith(b"\r\n") or len(line) > 8193:
raise HttpTaskBodyError(400, "invalid_http_body", "malformed chunk header", True)
size_text = line[:-2].split(b";", 1)[0].strip()
try:
size = int(size_text, 16)
except ValueError as exc:
raise HttpTaskBodyError(400, "invalid_http_body", "malformed chunk size", True) from exc
if size < 0:
raise HttpTaskBodyError(400, "invalid_http_body", "malformed chunk size", True)
if size == 0:
trailer_bytes = 0
while True:
trailer = stream.readline(8194)
trailer_bytes += len(trailer)
if trailer == b"\r\n":
return bytes(body)
if not trailer.endswith(b"\r\n") or trailer_bytes > 8192:
raise HttpTaskBodyError(400, "invalid_http_body", "malformed chunk trailer", True)
if len(body) + size > MAX_HTTP_TASK_BODY_BYTES:
raise HttpTaskBodyError(
413,
"request_too_large",
f"encoded task request exceeds {MAX_HTTP_TASK_BODY_BYTES} bytes",
True,
)
body.extend(_read_exact(stream, size))
if _read_exact(stream, 2) != b"\r\n":
raise HttpTaskBodyError(400, "invalid_http_body", "malformed chunk terminator", True)


def read_task_request_body(headers: _HeadersLike, stream: _BinaryReader) -> bytes:
_validate_content_encoding(headers)
content_length = _content_length(headers)
transfer = [value.lower() for value in _header_values(headers, "Transfer-Encoding")]
if transfer and content_length is not None:
raise HttpTaskBodyError(
400,
"invalid_http_body",
"Content-Length and Transfer-Encoding cannot be combined",
True,
)
if transfer:
if transfer != ["chunked"]:
raise HttpTaskBodyError(400, "invalid_http_body", "unsupported Transfer-Encoding", True)
return _read_chunked(stream)
if content_length is None:
return b""
if content_length > MAX_HTTP_TASK_BODY_BYTES:
raise HttpTaskBodyError(
413,
"request_too_large",
f"encoded task request exceeds {MAX_HTTP_TASK_BODY_BYTES} bytes",
True,
)
return _read_exact(stream, content_length)


def validate_response_headers(headers: _HeadersLike) -> None:
_validate_content_encoding(headers)
content_length = _content_length(headers)
if content_length is not None and content_length > MAX_HTTP_TASK_BODY_BYTES:
raise HttpTaskBodyError(
500,
"response_too_large",
f"encoded task response exceeds {MAX_HTTP_TASK_BODY_BYTES} bytes",
True,
)


def append_response_chunk(buffer: bytearray, chunk: bytes) -> None:
if len(buffer) + len(chunk) > MAX_HTTP_TASK_BODY_BYTES:
raise HttpTaskBodyError(
500,
"response_too_large",
f"encoded task response exceeds {MAX_HTTP_TASK_BODY_BYTES} bytes",
True,
)
buffer.extend(chunk)
6 changes: 4 additions & 2 deletions src/iicp_client/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,10 @@ def __repr__(self) -> str:

def from_http(status: int, body: dict, component: str) -> IicpError:
"""Build a typed IicpError from an HTTP response body."""
code = body.get("code", f"IICP-E{status:03d}")
message = body.get("message", body.get("error", "Unexpected error"))
raw_error = body.get("error")
nested: dict = raw_error if isinstance(raw_error, dict) else {}
code = body.get("code") or nested.get("code") or f"IICP-E{status:03d}"
message = body.get("message") or nested.get("message") or "Unexpected error"
return IicpError(
code=code,
message=message,
Expand Down
Loading
Loading