Security review fixes: one fence, honest API calls, a bounded and throttled port, CI that runs #22

Merged
tiagoagueda merged 2 commits from sec2-fixes into main 2026-10-19 10:01:10 +00:00
Owner

What and why, per issue

  • #17 (SEC2-050) mcp locked at 2.3.0 (mcp>=2.2.0, fixed release of CVE-2026-59951); pyjwt 2.15.1 via uv lock --upgrade; pip-audit over the exported lock reports nothing. Streamable HTTP runs stateless_http=True, request bodies capped at 1 MiB. The app is built in serving.py and run with uvicorn so the throttle can sit outside it.
  • #9, #12, #19 (SEC2-067) one fence in fence.py for answers AND errors: per-fence random code in the opening and closing lines (only the matching closer closes), NFKC/format-character/_/-/guillemet look-alikes replaced before the text goes in, what restricted so it cannot write a line. Short third-party fields (title, name, location, role, subject, company_name) stay readable when short and single-line and are flattened and fenced otherwise. INSTRUCTIONS updated.
  • #7 3xx is an error naming the Location; a 2xx that is not JSON is an error; a plain http:// address to another host is warned about on stderr (not refused).
  • #15 problem documents relayed: scope of a 403, errors of a 422 (capped at 5), the wait of a 429 (body or Retry-After, repeated only as a number), detail of a 404. Found on the way: PostuloError was a plain Exception, which the mcp SDK treats as a crash (model sees only "Error executing tool"), so no refusal ever reached the agent. It is now a ToolError (resources get a ResourceError).
  • #18 (SEC2-066) answers read streamed under 8 MiB and a 60 s deadline for the whole exchange (declared Content-Length checked too).
  • #10 get_listing tool and postulo://listings/{id} resource over GET /listings/{pk}, advert fenced; prompt and README name it. Snapshot retaken from core 42be52048; fixtures built from the response schemas (listing, no description on ListingOut); the fake validates fixtures against the success schema and request bodies against the request schema (unknown or missing members fail).
  • #13 --allowed-host name allows name and name:*; name:443/name:80 also allow the bare name; origins follow; --host 0.0.0.0/:: is refused without an --allowed-host. README example corrected.
  • #14 secret compared as bytes (non-ASCII bearer is a 401, not a 500); plan refuses a non-printable-ASCII secret.
  • #21 (SEC2-069) secret must be at least 22 characters with more than 10 distinct; an ASGI throttle answers 429 (with Retry-After) to an address after 10 wrong secrets in 5 minutes (checked end to end against a real uvicorn).
  • #8, #20 (SEC2-068) CI in node:22-bookworm with persist-credentials: false, uv sync --locked, matrix 3.12/3.13/3.14, uv installer pinned by version and sha256 as in the core (#790), new audit job (pip-audit --strict over the exported lock).

Tests

73 pass (was 34) on 3.12, 3.13, 3.14 locally; ruff check and ruff format --check clean. Without the fix the new serving tests fail (13 failed with the old serving.py). Hostile-text tests cover the look-alike closers, forged codes, fenced errors (HTML body, JSON body, connection error), titles and locations with line breaks.

Left open

Nothing among the bug/security issues. #16 (enhancement), #11, #6, #4, #2, #1 (documentation/enhancement) are not part of this pull request.

Notes for review: the branch has two commits (CI; everything else) because the tests share one file. The throttle keys on the TCP peer address, so behind a reverse proxy it bars the proxy; the README says to limit at the proxy as well. Secrets of 16 to 21 characters that worked before are now refused at start-up (the issue asks for 128 bits).

Closes #7, #8, #9, #10, #12, #13, #14, #15, #17, #18, #19, #20, #21

🤖 Generated with Claude Code

## What and why, per issue - **#17 (SEC2-050)** mcp locked at 2.3.0 (`mcp>=2.2.0`, fixed release of CVE-2026-59951); pyjwt 2.15.1 via `uv lock --upgrade`; pip-audit over the exported lock reports nothing. Streamable HTTP runs `stateless_http=True`, request bodies capped at 1 MiB. The app is built in `serving.py` and run with uvicorn so the throttle can sit outside it. - **#9, #12, #19 (SEC2-067)** one fence in `fence.py` for answers AND errors: per-fence random code in the opening and closing lines (only the matching closer closes), NFKC/format-character/`_`/`-`/guillemet look-alikes replaced before the text goes in, `what` restricted so it cannot write a line. Short third-party fields (`title`, `name`, `location`, `role`, `subject`, `company_name`) stay readable when short and single-line and are flattened and fenced otherwise. INSTRUCTIONS updated. - **#7** 3xx is an error naming the Location; a 2xx that is not JSON is an error; a plain `http://` address to another host is warned about on stderr (not refused). - **#15** problem documents relayed: scope of a 403, `errors` of a 422 (capped at 5), the wait of a 429 (body or `Retry-After`, repeated only as a number), `detail` of a 404. Found on the way: `PostuloError` was a plain `Exception`, which the mcp SDK treats as a crash (model sees only "Error executing tool"), so no refusal ever reached the agent. It is now a `ToolError` (resources get a `ResourceError`). - **#18 (SEC2-066)** answers read streamed under 8 MiB and a 60 s deadline for the whole exchange (declared Content-Length checked too). - **#10** `get_listing` tool and `postulo://listings/{id}` resource over `GET /listings/{pk}`, advert fenced; prompt and README name it. Snapshot retaken from core `42be52048`; fixtures built from the response schemas (`listing`, no `description` on `ListingOut`); the fake validates fixtures against the success schema and request bodies against the request schema (unknown or missing members fail). - **#13** `--allowed-host name` allows `name` and `name:*`; `name:443`/`name:80` also allow the bare name; origins follow; `--host 0.0.0.0`/`::` is refused without an `--allowed-host`. README example corrected. - **#14** secret compared as bytes (non-ASCII bearer is a 401, not a 500); `plan` refuses a non-printable-ASCII secret. - **#21 (SEC2-069)** secret must be at least 22 characters with more than 10 distinct; an ASGI throttle answers 429 (with `Retry-After`) to an address after 10 wrong secrets in 5 minutes (checked end to end against a real uvicorn). - **#8, #20 (SEC2-068)** CI in `node:22-bookworm` with `persist-credentials: false`, `uv sync --locked`, matrix 3.12/3.13/3.14, uv installer pinned by version and sha256 as in the core (#790), new `audit` job (`pip-audit --strict` over the exported lock). ## Tests 73 pass (was 34) on 3.12, 3.13, 3.14 locally; `ruff check` and `ruff format --check` clean. Without the fix the new serving tests fail (13 failed with the old `serving.py`). Hostile-text tests cover the look-alike closers, forged codes, fenced errors (HTML body, JSON body, connection error), titles and locations with line breaks. ## Left open Nothing among the bug/security issues. #16 (enhancement), #11, #6, #4, #2, #1 (documentation/enhancement) are not part of this pull request. Notes for review: the branch has two commits (CI; everything else) because the tests share one file. The throttle keys on the TCP peer address, so behind a reverse proxy it bars the proxy; the README says to limit at the proxy as well. Secrets of 16 to 21 characters that worked before are now refused at start-up (the issue asks for 128 bits). Closes #7, #8, #9, #10, #12, #13, #14, #15, #17, #18, #19, #20, #21 🤖 Generated with [Claude Code](https://claude.com/claude-code)
The job ran in python:3.14-slim-bookworm, which has no node, and Forgejo runs
actions/checkout as JavaScript inside the job container, so the run stopped at its first step
and no lint or test was ever run by CI. It now runs in node:22-bookworm like every other
repository, with persist-credentials: false on the checkout.

The install is `uv sync --locked`, so a pyproject.toml and a uv.lock that disagree fail instead
of being re-resolved into a set nobody reviewed. The tests run over 3.12, 3.13 and 3.14, which
is what pyproject.toml claims: this server is installed on its own under whatever Python the
person has.

uv is installed from a versioned address and the script is checked against a sha256 before it
runs, the same pair as the core's workflow (postulo/postulo#790), instead of piping the moving
install.sh into a shell.

A second job exports the lock without the project and runs pip-audit --strict over it. This
server is its own process, so its lock is what runs, and nothing audited it.

Closes #8
Closes #20

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Security review fixes: one fence for answers and errors, honest API calls, a bounded and throttled port
All checks were successful
CI / Lint and test (Python 3.12) (pull_request) Successful in 13s
CI / Lint and test (Python 3.13) (pull_request) Successful in 13s
CI / Lint and test (Python 3.14) (pull_request) Successful in 12s
CI / Audit the dependencies (pull_request) Successful in 24s
2df9798ef1
Dependencies (#17). mcp is locked at 2.3.0 (pyproject asks for >= 2.2.0, the release that fixes
CVE-2026-59951, unbounded Streamable HTTP sessions), pyjwt at 2.15.1, and `uv lock --upgrade`
brought the rest; pip-audit over the exported lock finds nothing. Streamable HTTP now also runs
stateless, which this server can because it keeps nothing per client, and request bodies are
capped at 1 MiB. The app is built here and run with uvicorn instead of through MCPServer.run, so
the throttle below can sit outside it.

One fence (#9 #12 #19). New fence.py, used for answers and for errors. Every fence carries a
fresh random code in its opening and closing lines and only that closer closes it; anything that
reads as a marker once Unicode is flattened (fullwidth brackets, zero-width and soft-hyphen
characters, underscores or hyphens for spaces, guillemets) is replaced before the text goes in.
Names, titles, places, roles and subjects, which a stranger can write through a captured page or
a shared calendar, stay readable while short and on one line, and are flattened and fenced when
long or broken over lines. The instructions say all of this to the model.

Errors (#7 #15 #18 #19). PostuloError is now a ToolError: the SDK treats any other exception out
of a tool as a crash and tells the model only "Error executing tool", so no refusal ever reached
the agent. A resource turns it into a ResourceError the same way. Postulo's problem documents
are relayed: the scope a 403 names, the field errors of a 422, the wait of a 429 (from the body
or Retry-After, only as a number), the detail of a 404. Whatever the answer said is fenced; only
the status, a checked scope name and a number stand outside it. A 3xx is an error naming the
Location, a 2xx that is not JSON is an error, and a plain http:// address to another host is
announced on stderr. Answers are read streamed, under 8 MiB and a 60 s deadline for the whole
exchange.

The job advert (#10). get_listing and postulo://listings/{id} return the advert over
GET /listings/{id}, fenced; the interview prompt names it. The tests' snapshot of the API is
retaken from the core at 42be52048, the fixtures are built from the response schemas (listing,
not posting; no description on ListingOut), and the fake refuses a fixture or a request body
with a field the description does not declare or lacks one it requires.

The port (#13 #14 #21). An --allowed-host without a port allows the name with and without one,
name:443 and name:80 also allow the bare name, and --host 0.0.0.0 or :: is refused unless an
--allowed-host names what clients call it. The shared secret is compared as bytes (a non-ASCII
byte used to raise TypeError and answer 500), must be printable ASCII of at least 22 characters
with more than ten different ones, and an address that sends ten wrong ones in five minutes is
answered 429 until they age out.

Closes #7
Closes #9
Closes #10
Closes #12
Closes #13
Closes #14
Closes #15
Closes #17
Closes #18
Closes #19
Closes #21

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Postulo/postulo-mcp!22
No description provided.