feat(api): define and enforce the operator API's input-validation rules (BACKLOG #1108) - #885
Open
wshallwshall wants to merge 5 commits into
Open
feat(api): define and enforce the operator API's input-validation rules (BACKLOG #1108)#885wshallwshall wants to merge 5 commits into
wshallwshall wants to merge 5 commits into
Conversation
…ce them (BACKLOG #1108) ASVS 2.1.1 asks that the documentation define input validation rules for checking data items against an expected structure. Two things held it short: no per-field reference existed under docs/, and most of the surface had no rule to write down. An AST walk over api/models.py and api/auth_models.py returns ZERO pattern= constraints against 33 and 25 max_length bounds, so every bound that existed said how much may arrive and none said what it is. messagefoundry/api/validation.py is now the single authority for the control-plane rules, and docs/API-INPUT-VALIDATION.md is the prose. Applied across the categories the item names: engine-minted ids, connection names, epoch time bounds, free-text search terms, and the metadata filters beside them. 36 route path parameters and 25 query parameters are retyped, plus the request-body fields in those categories. Two measurements decided the rules. The connection-name grammar admits a hyphen that the IDE wizard's rule rejects, because four shipped connection names carry one and the narrower rule would make them unreachable. And a lower bound of zero does not exclude infinity: ?received_from=inf reached a store query, and the audit routes accepted ?since=nan. The data plane is untouched. EditResendRequest.raw keeps a size bound and no alphabet rule, and field_path keeps its one authority in parsing/peek. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ists (BACKLOG #1108) The schema census over the live app found what the first pass left: seven collections whose ITEMS carried no rule, and three that were uncapped. Six of the seven are id-shaped, which is one of the categories #1108 names. ChannelScope.channels is a list of connection names and now carries the connection-name rule. Role and permission lists carry a shape rule built from the shipped catalogs -- measured against all 6 Role members and all 28 Permission members -- while which words EXIST stays the auth package's to say. The directory group maps and the counter-reset targets are capped. Two console call sites are fixed rather than worked around. The uploaded-log resend built its request model outside its try, so a target the rule refuses escaped as a 500 instead of the console's own refusal; it now records the refusal the same way an engine 404 is recorded, with the file id and a fixed reason and nothing caller-supplied. The preset save had the same shape. Measured with one instrument, the app's own OpenAPI schema, before and after: route parameters carrying a structural rule went 5 -> 65 of 101, and unbounded strings 36 -> 0. Request-body properties went 0 -> 40 of 92, with the last unbounded string and the last uncapped collection gone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… and what remains The item stays OPEN. Its recorded closing act is `scorecard-rescore`, which the ASVS Tracker performs in the vault, and only then does a Lander flip the banner. Neither act is a builder's, so a shipped build and an open item is the expected outcome here, not an unfinished one. The note records what shipped, the before/after measured with one instrument, the two measurements that decided rules a reader would otherwise have drawn wrong, one correction to the item's own proposed work, the finding that the glob category has no subject on the API surface, five named remainders, and the two questions this build deliberately did not settle. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ed (BACKLOG #1108) `test_error_banner_escapes_hostile_input` posted a hostile role id, relied on `_validate_roles` echoing it into the 400 detail, and asserted the /ui banner escaped it. The role-id rule now refuses that value at `RolesUpdateRequest`, so it never reaches `_validate_roles` and the route renders its own fixed "invalid input". The test's status assertion and its "hostile not in text" assertion both still passed; only the escaped-form assertion caught it. Deleting that assertion would have retired a reflected-XSS regression guard for EVERY rerender-with-error path, silently, as a side effect of an unrelated rule. So it is split in two. The route test pins the stronger new property -- the value is refused before anything can echo it -- with a well-formed but unknown role id as the control that the other arm still runs. The escaping is re-armed directly against `pages.user_detail_page`, where no upstream rule can disarm it, with an ordinary message as the control that the banner renders. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two of that XSS guard's three assertions still passed after the role-id rule stopped its hostile value reaching the echo it depended on. Deleting the one that went red is the obvious fix and would have retired the guard for every other rerender-with-error path. The note generalises it, because the same hazard reaches the seven other tests this build updated and whoever does the console, transport-plane or CLI halves next: a test that reaches its subject THROUGH a value an input rule now rejects stops measuring its subject while still passing. The item stays open. Its closing act is the Tracker's scorecard-rescore. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this does
BACKLOG #1108 asks what the operator API's input validation rules would have to be
before any documentation could define them. This decides them, makes them real, and
publishes the reference.
messagefoundry/api/validation.pyis the single authority for the rules.docs/API-INPUT-VALIDATION.mdis the prose a deploying operator reads, linked fromdocs/README.mdunder "Security reviewers".tests/test_api_input_validation.py(24 tests) pins every pattern and ceiling, and its doc-drift check carries a
doctored-text control that must fail.
The item stays OPEN, and that is the expected outcome. Its recorded closing act
is
scorecard-rescore: the ASVS Tracker re-scores the cell in the vault, then mailsthe Lander for the banner flip. Two acts, two seats, neither of them a Builder's.
Nothing here touches the scorecard, and nothing here should be read as re-scoring it.
The categories that got a rule
_and-, to 256 characters0 .. 4102444800(2100-01-01 UTC), no inf, no NaNPlus the values sitting beside them: role ids, permission ids, the status and
event-kind vocabularies, message type, control id, audit actor and action, one email
address, two filesystem paths, and a log level.
Measured, one instrument, before and after
The app's own generated OpenAPI schema, read at the parent commit and at the tip.
An earlier AST census counting
Query(max_length=...)kwargs was retired mid-task:it stopped answering the question the moment the constraints moved into the
annotation.
Two measurements decided rules a reader would otherwise have drawn wrong
The connection-name grammar is deliberately WIDER than the IDE's. #1108's own
proposed work says to adopt
ide/src/connectionWizardModel.ts's^[A-Za-z][A-Za-z0-9_]*$. Four of the 108 distinct connection names insamples/,harness/,tests/andmessagefoundry/carry a hyphen, and that grammar rejectsall four, so adopting it verbatim would have made four shipped connections
unreachable through the API. The rule here admits them and still excludes path
characters, whitespace, control characters and quoting metacharacters.
A lower bound of zero does not exclude infinity.
?received_from=infparsed tofloat('inf')and reached a store bind;/auditdeclared no bound at all, so?since=nanwas accepted too. A NaN bound is the worse of the two, because everycomparison against it is false, so the filter would return nothing rather than fail.
Three things I decided and one I corrected
The glob category has no subject on the operator API, and that is a finding. A
glob is a file-connector setting consumed at
messagefoundry/transports/file.py; theonly console-to-
connections.tomlwrite seam carriesflaggedanddirectionandnothing else. So the glob rule is a connection-configuration rule and belongs with
that surface. Saying so is a rule for the item's named category; inventing an API
rule for a data item the API does not accept would not be.
field_pathkeeps its one authority and gets no copy here. Its grammar isparsing/peek.parse_path, andstore/content_search.make_specapplies it eagerly atall six acceptance points, so a malformed path is already a 4xx.
Two items keep a rule that is not a pattern, and the doc says the shape gate does
not replace it. A reload
config_diris confined by an allow-list because theloader executes Python from it; a log
levelis checked againstLOG_LEVELS.A correction to the item's own text. It lists the email rule at "three acceptance
points". Only one is an operator-supplied address the API takes as an address, and
that one now carries an
EmailAddressrule.UserCreateRequest.emailandUserUpdateRequest.emailwere NOT changed: shipped test fixtures usebob@x,dr.who@x,j@xand a whitespace-only value, which the rule rejects. Whether aprofile address must carry a dotted domain is a real intranet-addressing question,
not a bug.
What remains, so nobody reads this as more than it is
/uiroutes declare at least 17 of their own parameter bounds foritems this module now governs. They are hand-written copies and can drift. Two of
them are a DIFFERENT data item: the console's
received_from/received_toarebrowser
datetime-localstrings, not epoch numbers.than over HTTP, and a direct call runs no request validation. Where it builds a
request model the rules do apply, and two such sites are fixed here.
authored outside the rule would be created and then be unreachable through the
API. Nothing shipped has such a name.
connections.tomlare untouched.display names, message bodies and other free text where an alphabet rule would be
wrong rather than missing.
The data plane is untouched on purpose.
EditResendRequest.rawkeeps a sizebound and no alphabet rule, because an HL7 v2 body is separated by carriage returns.
Two questions I did NOT settle, because they are not mine
written? This build takes the harder reading and does both. That does not decide
the method question, which #1108 raises and
docs/ASVS-ASSESSMENT-METHOD.md§1.3bears on.
plane? That would make this a control-plane-only cell. The data plane is not
folded in here, and
docs/API-INPUT-VALIDATION.mdnames both questions in its owntext so the page cannot be mistaken for an answer to them.
[api].expose_docswas NOT flipped. The generated schema now carries every pattern,which is a side effect and not the deliverable: a schema lists constraints and does
not say why a rule is drawn where it is, what it costs, or what it does not cover.
Callers fixed rather than worked around
messagefoundry_webconsole/routes/uploaded_logs.pybuiltUploadResendRequestoutside its
try, so a target the rule refuses escaped as a 500 instead of theconsole's own refusal banner. It now records the refusal exactly as an engine 404 is
recorded, with the file id and a fixed reason and nothing caller-supplied.
routes/search.pyhad the same shape on the preset save.Eight tests across four files were updated. Each had used a synthetic non-id
(
"missing","nope","abc") and asserted 404. They now assert BOTH: awell-formed-but-absent id is 404, and a value that could not be an id is 422. A
single probe conflated the two and would have gone on passing with the id rule
removed. One test seeded a store row with
user_id="ad9", which is now awell-formed id so the AD branch it is named for is still reached.
The near-miss worth reviewing hardest
Adding an input rule can silently disarm an existing test.
test_error_banner_escapes_hostile_inputwas the reflected-XSS regression guard forevery rerender-with-error path in the console. It posted a hostile role id, relied
on
_validate_rolesechoing it into the 400 detail, and asserted the banner escapedit. The new role-id rule refuses that value at
RolesUpdateRequest, so it neverreaches
_validate_roles.Two of that test's three assertions still passed -- the 400 status and
hostile not in r.text. Only the escaped-form assertion went red, and deleting it isthe obvious fix. That would have retired the guard for every other path as an
unnoticed side effect of an unrelated change.
It is split instead. The route test pins the stronger new property, with a
well-formed-but-unknown role id as the control that the other arm still runs. The
escaping is re-armed directly against
pages.user_detail_page, where no upstreamrule can disarm it, with an ordinary message as the control that the banner renders.
The same hazard applies to the other seven updated tests, and to whoever does the
console, transport-plane or CLI halves next. It is recorded in the BACKLOG note.
Checks I ran
Run in this worktree on Windows, in a
python -m venvwith.[dev,harness,fhir,dicom,x12,xml,webauthn]plus the console, perci.yml.ruff check .All checks passed!ruff format --check .1243 files already formattedmypy messagefoundry messagefoundry_webconsoleSuccess: no issues found in 301 source filespytest tests/test_api_input_validation.py24 passedpytestover the API, auth, upload, attachment, connection, alerts, apiclient and DR suites285 passedon the first pass, then the 8 updated tests greenpytest packaging/messagefoundry-webconsole/tests+ apiclient + doc guards566 passed, 3 skippedafter the two console fixes733 passed, 3 skipped, 1 failure, since fixedpytest packaging/messagefoundry-webconsole/tests+ the validation suite, after that fix430 passed, 3 skippedscripts/docs/link_check.pyOK: every relative link resolves(5041 links, 322 files)scripts/docs/backlog_citation_check.pyOK(2 pre-existing advisory warnings, unrelated)scripts/docs/backlog_status_check.pyOK — 664 backlog items, each declaring exactly one statusNot run here, and they need reading on the runner:
windows-service-smokeandanything else that only runs hosted. I did not run the full suite in one pass; the
suites named above are the ones this change reaches.
One thing I noticed and did not change
docs/README.md:3claims "377 markdown files underdocs/".find docs -name "*.md" | wc -lreturns 285 before my change. The claim was already stale by a wide marginand I could not reconstruct what population it counted, so correcting it to a number
I cannot defend seemed worse than leaving it. Worth its own item.
🤖 Generated with Claude Code