fix: address independent review findings
This commit is contained in:
+9
-1
@@ -1,5 +1,14 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
- Bound JSON nesting before parsing and return a JSON-RPC parse error instead
|
||||||
|
of dropping the request connection.
|
||||||
|
- Bound request-body reads and return HTTP 408 for incomplete slow requests.
|
||||||
|
- Enforce the advertised object-item schema for result evidence and review
|
||||||
|
findings.
|
||||||
|
- Correct the documented post-`CHANGES_REQUESTED` remediation path.
|
||||||
|
|
||||||
All notable changes to this project are documented here.
|
All notable changes to this project are documented here.
|
||||||
|
|
||||||
## 0.1.0 — 2026-07-30
|
## 0.1.0 — 2026-07-30
|
||||||
@@ -14,4 +23,3 @@ Initial review candidate:
|
|||||||
- transport authentication abstraction and BASDM separation controls;
|
- transport authentication abstraction and BASDM separation controls;
|
||||||
- tests, container example, contract, threat model, production profile, and
|
- tests, container example, contract, threat model, production profile, and
|
||||||
WP-PA-001 review evidence.
|
WP-PA-001 review evidence.
|
||||||
|
|
||||||
|
|||||||
@@ -49,8 +49,9 @@ Work packages move through:
|
|||||||
|
|
||||||
`HOLD` leaves a reviewed package in `REVIEWED`. A new review can be requested
|
`HOLD` leaves a reviewed package in `REVIEWED`. A new review can be requested
|
||||||
after a completed review. This MVP treats corrections after
|
after a completed review. This MVP treats corrections after
|
||||||
`CHANGES_REQUESTED` as a new work package or a new explicitly governed
|
`CHANGES_REQUESTED` as a new work package. It does not support replacing the
|
||||||
iteration; it does not silently overwrite submitted result evidence.
|
result on the reviewed work package and never silently overwrites submitted
|
||||||
|
result evidence.
|
||||||
|
|
||||||
## Trust boundaries
|
## Trust boundaries
|
||||||
|
|
||||||
@@ -58,4 +59,3 @@ iteration; it does not silently overwrite submitted result evidence.
|
|||||||
- The database is trusted to enforce integrity and append-only triggers.
|
- The database is trusted to enforce integrity and append-only triggers.
|
||||||
- Artifact URIs and commit hashes are references, not trusted content.
|
- Artifact URIs and commit hashes are references, not trusted content.
|
||||||
- Reverse proxy identity headers are not consumed by the built-in adapter.
|
- Reverse proxy identity headers are not consumed by the built-in adapter.
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ hardening work package.
|
|||||||
2. Replace `StaticTokenAuthProvider` with validated OIDC/JWT or mTLS workload
|
2. Replace `StaticTokenAuthProvider` with validated OIDC/JWT or mTLS workload
|
||||||
identity. Bind issuer, audience, subject, expiry, and revocation policy.
|
identity. Bind issuer, audience, subject, expiry, and revocation policy.
|
||||||
3. Put the service behind TLS and authenticated ingress. Do not trust arbitrary
|
3. Put the service behind TLS and authenticated ingress. Do not trust arbitrary
|
||||||
forwarded identity headers.
|
forwarded identity headers. The ingress must enforce connection limits,
|
||||||
|
header/body read deadlines, and request quotas in addition to the
|
||||||
|
application's five-second request-body timeout.
|
||||||
4. Run multiple stateless instances only after PostgreSQL conformance and
|
4. Run multiple stateless instances only after PostgreSQL conformance and
|
||||||
concurrency tests pass.
|
concurrency tests pass.
|
||||||
5. Add structured audit export, metrics, tracing correlation, alerts, backup,
|
5. Add structured audit export, metrics, tracing correlation, alerts, backup,
|
||||||
@@ -33,4 +35,3 @@ production gate acceptance.
|
|||||||
Chat threads are not push subscribers. A client, automation, or PA invocation
|
Chat threads are not push subscribers. A client, automation, or PA invocation
|
||||||
must call `sync_since(last_cursor)`. The cursor itself belongs in durable client
|
must call `sync_since(last_cursor)`. The cursor itself belongs in durable client
|
||||||
state and is advanced only after successful processing.
|
state and is advanced only after successful processing.
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,8 @@
|
|||||||
| Cross-project reads | project filter plus membership resolution |
|
| Cross-project reads | project filter plus membership resolution |
|
||||||
| Browser cross-origin request | exact configurable Origin allowlist |
|
| Browser cross-origin request | exact configurable Origin allowlist |
|
||||||
| Oversized request | one MiB body limit |
|
| Oversized request | one MiB body limit |
|
||||||
|
| Excessive JSON nesting | pre-parse nesting limit plus guarded decoder |
|
||||||
|
| Slow/incomplete body | five-second application read timeout |
|
||||||
| MIME confusion/caching | strict JSON input, nosniff, no-store |
|
| MIME confusion/caching | strict JSON input, nosniff, no-store |
|
||||||
| SQL injection | parameterized SQL; one controlled placeholder expansion |
|
| SQL injection | parameterized SQL; one controlled placeholder expansion |
|
||||||
|
|
||||||
@@ -57,8 +59,10 @@ fetched or executed by the bus.
|
|||||||
4. The event payload is not cryptographically chained or signed. Add hash
|
4. The event payload is not cryptographically chained or signed. Add hash
|
||||||
chaining/signatures only if the agreed threat model requires tamper
|
chaining/signatures only if the agreed threat model requires tamper
|
||||||
evidence against privileged database operators.
|
evidence against privileged database operators.
|
||||||
5. No application rate limiter exists. Apply per-identity limits and request
|
5. No application rate limiter or bounded worker pool exists. The application
|
||||||
quotas at the trusted edge.
|
closes incomplete request bodies after five seconds, but production ingress
|
||||||
|
must additionally enforce connection limits, header/body read deadlines,
|
||||||
|
per-identity limits, and request quotas.
|
||||||
6. The server emits access metadata but no security audit sink or metrics.
|
6. The server emits access metadata but no security audit sink or metrics.
|
||||||
Integrate structured logs, alerts, and privacy-aware retention.
|
Integrate structured logs, alerts, and privacy-aware retention.
|
||||||
7. Membership deactivation and credential revocation are not exposed as MVP
|
7. Membership deactivation and credential revocation are not exposed as MVP
|
||||||
@@ -80,4 +84,3 @@ fetched or executed by the bus.
|
|||||||
- review log contents for sensitive project data;
|
- review log contents for sensitive project data;
|
||||||
- add abuse limits and operational monitoring;
|
- add abuse limits and operational monitoring;
|
||||||
- decide whether privileged-operator tamper evidence is required.
|
- decide whether privileged-operator tamper evidence is required.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# WP-PA-001 Rework Evidence
|
||||||
|
|
||||||
|
Date: 2026-07-30
|
||||||
|
|
||||||
|
## Review input
|
||||||
|
|
||||||
|
- Independent reviewer: Claude Code
|
||||||
|
- Reviewed commit: `bc55924198865502203a992b6ea914a38fd9d173`
|
||||||
|
- Verdict: `CHANGES_REQUESTED`
|
||||||
|
- Findings addressed: M-1, M-2, L-1, L-2
|
||||||
|
- Optional observation O-1: no correction required
|
||||||
|
|
||||||
|
## Corrections
|
||||||
|
|
||||||
|
| Finding | Correction | Regression evidence |
|
||||||
|
|---|---|---|
|
||||||
|
| M-1 | Added a pre-parse JSON nesting limit and retained `RecursionError` handling. Excessively nested JSON returns JSON-RPC `-32700`. Brackets inside strings are ignored by the nesting scanner. | `test_deeply_nested_json_returns_parse_error`; `test_brackets_inside_json_string_do_not_count_as_nesting` |
|
||||||
|
| M-2 | Added a five-second connection/request-body read timeout. Incomplete bodies receive HTTP 408 and the connection is closed. Production ingress requirements now explicitly include connection limits and read deadlines. | `test_incomplete_body_times_out` |
|
||||||
|
| L-1 | Corrected the architecture document: post-`CHANGES_REQUESTED` corrections require a new work package in this MVP. | Documentation inspection |
|
||||||
|
| L-2 | Enforced object items for `evidence` and `findings`, matching the advertised MCP schemas. | `test_evidence_and_findings_items_must_be_objects` |
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Runtime: Python 3.12.13
|
||||||
|
|
||||||
|
```text
|
||||||
|
PYTHONPATH=src python3.12 -m unittest discover -v
|
||||||
|
Ran 20 tests in 5.696s
|
||||||
|
OK
|
||||||
|
|
||||||
|
PYTHONPATH=src python3.12 -m compileall -q src tests
|
||||||
|
exit 0
|
||||||
|
|
||||||
|
pip install --no-cache-dir --no-deps --target <temporary-directory> .
|
||||||
|
PYTHONPATH=<temporary-directory> PROJECT_BUS_MIGRATIONS=<repo>/migrations \
|
||||||
|
python3.12 -m unittest discover -v tests
|
||||||
|
Ran 20 tests in 5.394s
|
||||||
|
OK
|
||||||
|
|
||||||
|
git diff --check
|
||||||
|
exit 0
|
||||||
|
```
|
||||||
|
|
||||||
|
The rework must receive a fresh independent review before PM gate acceptance.
|
||||||
@@ -14,6 +14,8 @@ from .mcp import MCPApplication
|
|||||||
|
|
||||||
LOGGER = logging.getLogger("project_bus.http")
|
LOGGER = logging.getLogger("project_bus.http")
|
||||||
MAX_BODY_BYTES = 1_048_576
|
MAX_BODY_BYTES = 1_048_576
|
||||||
|
MAX_JSON_NESTING_DEPTH = 128
|
||||||
|
DEFAULT_REQUEST_READ_TIMEOUT_SECONDS = 5.0
|
||||||
|
|
||||||
|
|
||||||
class ProjectBusHTTPServer(ThreadingHTTPServer):
|
class ProjectBusHTTPServer(ThreadingHTTPServer):
|
||||||
@@ -25,17 +27,25 @@ class ProjectBusHTTPServer(ThreadingHTTPServer):
|
|||||||
application: MCPApplication,
|
application: MCPApplication,
|
||||||
auth_provider: AuthProvider,
|
auth_provider: AuthProvider,
|
||||||
allowed_origins: set[str],
|
allowed_origins: set[str],
|
||||||
|
request_read_timeout_seconds: float = DEFAULT_REQUEST_READ_TIMEOUT_SECONDS,
|
||||||
):
|
):
|
||||||
|
if request_read_timeout_seconds <= 0:
|
||||||
|
raise ValueError("request_read_timeout_seconds must be positive")
|
||||||
super().__init__(server_address, ProjectBusRequestHandler)
|
super().__init__(server_address, ProjectBusRequestHandler)
|
||||||
self.application = application
|
self.application = application
|
||||||
self.auth_provider = auth_provider
|
self.auth_provider = auth_provider
|
||||||
self.allowed_origins = allowed_origins
|
self.allowed_origins = allowed_origins
|
||||||
|
self.request_read_timeout_seconds = request_read_timeout_seconds
|
||||||
|
|
||||||
|
|
||||||
class ProjectBusRequestHandler(BaseHTTPRequestHandler):
|
class ProjectBusRequestHandler(BaseHTTPRequestHandler):
|
||||||
server: ProjectBusHTTPServer
|
server: ProjectBusHTTPServer
|
||||||
protocol_version = "HTTP/1.1"
|
protocol_version = "HTTP/1.1"
|
||||||
|
|
||||||
|
def setup(self) -> None:
|
||||||
|
super().setup()
|
||||||
|
self.connection.settimeout(self.server.request_read_timeout_seconds)
|
||||||
|
|
||||||
def log_message(self, format_string: str, *args: Any) -> None:
|
def log_message(self, format_string: str, *args: Any) -> None:
|
||||||
LOGGER.info("%s - %s", self.address_string(), format_string % args)
|
LOGGER.info("%s - %s", self.address_string(), format_string % args)
|
||||||
|
|
||||||
@@ -73,13 +83,20 @@ class ProjectBusRequestHandler(BaseHTTPRequestHandler):
|
|||||||
self._json(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, {"error": "invalid_body_size"})
|
self._json(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, {"error": "invalid_body_size"})
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
request = json.loads(self.rfile.read(length))
|
body = self.rfile.read(length)
|
||||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
if _exceeds_json_nesting_limit(body):
|
||||||
|
raise RecursionError("JSON nesting limit exceeded")
|
||||||
|
request = json.loads(body)
|
||||||
|
except (json.JSONDecodeError, UnicodeDecodeError, RecursionError):
|
||||||
self._json(
|
self._json(
|
||||||
HTTPStatus.OK,
|
HTTPStatus.OK,
|
||||||
{"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": "Parse error"}},
|
{"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": "Parse error"}},
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
except TimeoutError:
|
||||||
|
self.close_connection = True
|
||||||
|
self._json(HTTPStatus.REQUEST_TIMEOUT, {"error": "request_body_timeout"})
|
||||||
|
return
|
||||||
if not isinstance(request, dict):
|
if not isinstance(request, dict):
|
||||||
self._json(
|
self._json(
|
||||||
HTTPStatus.OK,
|
HTTPStatus.OK,
|
||||||
@@ -134,3 +151,28 @@ def allowed_origins_from_environment() -> set[str]:
|
|||||||
raw = os.environ.get("PROJECT_BUS_ALLOWED_ORIGINS", "")
|
raw = os.environ.get("PROJECT_BUS_ALLOWED_ORIGINS", "")
|
||||||
return {item.strip() for item in raw.split(",") if item.strip()}
|
return {item.strip() for item in raw.split(",") if item.strip()}
|
||||||
|
|
||||||
|
|
||||||
|
def _exceeds_json_nesting_limit(
|
||||||
|
body: bytes, limit: int = MAX_JSON_NESTING_DEPTH
|
||||||
|
) -> bool:
|
||||||
|
depth = 0
|
||||||
|
in_string = False
|
||||||
|
escaped = False
|
||||||
|
for byte in body:
|
||||||
|
if in_string:
|
||||||
|
if escaped:
|
||||||
|
escaped = False
|
||||||
|
elif byte == 0x5C: # backslash
|
||||||
|
escaped = True
|
||||||
|
elif byte == 0x22: # double quote
|
||||||
|
in_string = False
|
||||||
|
continue
|
||||||
|
if byte == 0x22:
|
||||||
|
in_string = True
|
||||||
|
elif byte in (0x5B, 0x7B): # [ {
|
||||||
|
depth += 1
|
||||||
|
if depth > limit:
|
||||||
|
return True
|
||||||
|
elif byte in (0x5D, 0x7D): # ] }
|
||||||
|
depth -= 1
|
||||||
|
return False
|
||||||
|
|||||||
@@ -451,6 +451,8 @@ class ProjectBusService:
|
|||||||
summary = self._require_text("summary", summary)
|
summary = self._require_text("summary", summary)
|
||||||
if not isinstance(evidence, list):
|
if not isinstance(evidence, list):
|
||||||
raise ValidationError("evidence must be a list")
|
raise ValidationError("evidence must be a list")
|
||||||
|
if any(not isinstance(item, dict) for item in evidence):
|
||||||
|
raise ValidationError("evidence items must be objects")
|
||||||
request = {"work_package_id": work_package_id, "summary": summary, "evidence": evidence}
|
request = {"work_package_id": work_package_id, "summary": summary, "evidence": evidence}
|
||||||
|
|
||||||
def action(connection: sqlite3.Connection, context: ActorContext) -> dict[str, Any]:
|
def action(connection: sqlite3.Connection, context: ActorContext) -> dict[str, Any]:
|
||||||
@@ -600,6 +602,10 @@ class ProjectBusService:
|
|||||||
summary: str,
|
summary: str,
|
||||||
idempotency_key: str,
|
idempotency_key: str,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
if not isinstance(findings, list):
|
||||||
|
raise ValidationError("findings must be a list")
|
||||||
|
if any(not isinstance(item, dict) for item in findings):
|
||||||
|
raise ValidationError("findings items must be objects")
|
||||||
try:
|
try:
|
||||||
review_verdict = ReviewVerdict(verdict)
|
review_verdict = ReviewVerdict(verdict)
|
||||||
except ValueError as error:
|
except ValueError as error:
|
||||||
|
|||||||
+61
-1
@@ -1,6 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import http.client
|
||||||
|
import socket
|
||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
import unittest
|
import unittest
|
||||||
@@ -24,7 +26,11 @@ class MCPHTTPTestCase(unittest.TestCase):
|
|||||||
{"bootstrap-secret-at-least-24": TokenIdentity("system", "system", "System")}
|
{"bootstrap-secret-at-least-24": TokenIdentity("system", "system", "System")}
|
||||||
)
|
)
|
||||||
self.server = ProjectBusHTTPServer(
|
self.server = ProjectBusHTTPServer(
|
||||||
("127.0.0.1", 0), MCPApplication(service), auth, {"https://chat.example"}
|
("127.0.0.1", 0),
|
||||||
|
MCPApplication(service),
|
||||||
|
auth,
|
||||||
|
{"https://chat.example"},
|
||||||
|
request_read_timeout_seconds=0.2,
|
||||||
)
|
)
|
||||||
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
||||||
self.thread.start()
|
self.thread.start()
|
||||||
@@ -115,6 +121,60 @@ class MCPHTTPTestCase(unittest.TestCase):
|
|||||||
self.assertEqual(status, 403)
|
self.assertEqual(status, 403)
|
||||||
self.assertEqual(response["error"], "origin_not_allowed")
|
self.assertEqual(response["error"], "origin_not_allowed")
|
||||||
|
|
||||||
|
def test_deeply_nested_json_returns_parse_error(self) -> None:
|
||||||
|
body = b"[" * 200_000 + b"]" * 200_000
|
||||||
|
connection = http.client.HTTPConnection(
|
||||||
|
"127.0.0.1", self.server.server_port, timeout=2
|
||||||
|
)
|
||||||
|
connection.request(
|
||||||
|
"POST",
|
||||||
|
"/mcp",
|
||||||
|
body=body,
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
response = connection.getresponse()
|
||||||
|
payload = json.loads(response.read())
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
self.assertEqual(response.status, 200)
|
||||||
|
self.assertEqual(payload["error"]["code"], -32700)
|
||||||
|
|
||||||
|
def test_brackets_inside_json_string_do_not_count_as_nesting(self) -> None:
|
||||||
|
status, response = self.request(
|
||||||
|
{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 1,
|
||||||
|
"method": "initialize",
|
||||||
|
"params": {"text": "[{" * 500 + "}]" * 500},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
self.assertIn("result", response)
|
||||||
|
|
||||||
|
def test_incomplete_body_times_out(self) -> None:
|
||||||
|
connection = socket.create_connection(
|
||||||
|
("127.0.0.1", self.server.server_port), timeout=2
|
||||||
|
)
|
||||||
|
connection.settimeout(2)
|
||||||
|
connection.sendall(
|
||||||
|
b"POST /mcp HTTP/1.1\r\n"
|
||||||
|
b"Host: 127.0.0.1\r\n"
|
||||||
|
b"Content-Type: application/json\r\n"
|
||||||
|
b"Content-Length: 100\r\n"
|
||||||
|
b"Connection: close\r\n\r\n"
|
||||||
|
b'{"jsonrpc":'
|
||||||
|
)
|
||||||
|
response = b""
|
||||||
|
while True:
|
||||||
|
chunk = connection.recv(4_096)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
response += chunk
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
self.assertIn(b"HTTP/1.1 408 Request Timeout", response)
|
||||||
|
self.assertIn(b'"error":"request_body_timeout"', response)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -256,6 +256,34 @@ class ServiceTestCase(unittest.TestCase):
|
|||||||
idempotency_key="premature-gate",
|
idempotency_key="premature-gate",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_evidence_and_findings_items_must_be_objects(self) -> None:
|
||||||
|
self.issue()
|
||||||
|
self.service.claim_work_package(
|
||||||
|
self.principals["lead"],
|
||||||
|
project_id="sandbox",
|
||||||
|
work_package_id="WP-001",
|
||||||
|
idempotency_key="claim",
|
||||||
|
)
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
self.service.submit_result(
|
||||||
|
self.principals["lead"],
|
||||||
|
project_id="sandbox",
|
||||||
|
work_package_id="WP-001",
|
||||||
|
summary="Invalid evidence.",
|
||||||
|
evidence=["not-an-object"], # type: ignore[list-item]
|
||||||
|
idempotency_key="invalid-evidence",
|
||||||
|
)
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
self.service.submit_review(
|
||||||
|
self.principals["reviewer"],
|
||||||
|
project_id="sandbox",
|
||||||
|
review_id="rev-does-not-matter",
|
||||||
|
verdict="APPROVE",
|
||||||
|
findings=[42], # type: ignore[list-item]
|
||||||
|
summary="Invalid findings.",
|
||||||
|
idempotency_key="invalid-findings",
|
||||||
|
)
|
||||||
|
|
||||||
def test_idempotent_replay_and_key_mismatch(self) -> None:
|
def test_idempotent_replay_and_key_mismatch(self) -> None:
|
||||||
first = self.service.issue_work_package(
|
first = self.service.issue_work_package(
|
||||||
self.principals["pm"],
|
self.principals["pm"],
|
||||||
|
|||||||
Reference in New Issue
Block a user