Reject unrecognized parameters in set_configuration instead of reporting success - #2272
Open
kriszyp wants to merge 7 commits into
Open
Reject unrecognized parameters in set_configuration instead of reporting success#2272kriszyp wants to merge 7 commits into
kriszyp wants to merge 7 commits into
Conversation
…success set_configuration resolved each request key through CONFIG_PARAM_MAP and, on a miss, ended the loop iteration with no error and no log, then returned its success message. A typo or an unsupported config block was accepted with HTTP 200 and never written. The single-parameter path in the same function already rejected the identical condition with a 400, so one function had two contracts and the API-facing one was the permissive half. The operation boundary now preflights every request key and rejects the whole request with a 400 naming each unrecognized param, before anything is written locally. The shared writer stays permissive: install merges installParams carrying install-only keys (INSTALL_PROMPTS.DEFAULTS_MODE, HDB_CONFIG) through the same path, so strictness there would abort install. Resolution is now expressed once, as lookupConfigParam plus isSuffixEscapedParam, and used by all three call sites (createConfigFile, the single-param path, and the multi-param loop) so validation and application cannot disagree. lookupConfigParam adds an Object.hasOwn guard so an inherited name cannot resolve to an Object.prototype member; that is defense-in-depth for direct callers rather than a reachable HTTP fix, since a body-property guard already rejects `constructor` and the lowercased lookup misses every other inherited name. hdb_auth_header is stripped alongside hdbAuthHeader: it is the legacy spelling of the same internal auth artifact, still attached to operation objects on the 4.x line, and would otherwise start failing as an unrecognized param. Two existing tests asserted the defect, passing operationsApi_processes -- a name that has never been in CONFIG_PARAM_MAP -- and expecting success. They now use a real param. Fixes #2266
- Move the new resolution helpers above updateConfigValue's JSDoc, which they
had orphaned from the function it documents.
- Route the read paths (getConfigValue, its sibling, and the config-object
updater) through lookupConfigParam too. getConfigValue('constructor') threw
`paramMap.toLowerCase is not a function` out of a helper that returns
undefined for every other miss, so a component calling env.get with a
user-supplied key could take down a request instead of getting undefined.
The two remaining raw lookups are safe: the composed key always contains '_'
so it cannot collide with a prototype name, and `for...in` skips
non-enumerable prototype members.
- Strip `impersonate`, a generic operation-body field declared on every
operation, so a future caller cannot have it reported as an unrecognized
config param.
- The _package integration test now asserts the entry actually landed (status
alone passed even if the write loop dropped it) and removes it afterward
instead of leaving a component entry in the live instance's config.
- Trim the added comments to what the next reader needs.
Assigning null does not remove the component entry -- set_configuration has no delete -- so the entry remains with a null package. componentLoader reads that as an application-only entry and skips it, which is enough to keep it inert for the rest of the suite, but the comment claimed removal. Assert the neutralized state rather than describing one that never happens.
The 400 echoes caller-supplied key names, and handleHDBError also surfaces the message to the operations log: a name containing a newline could forge a log line, and a body carrying thousands of unknown keys produced an unbounded message. Control characters are now replaced and the list is capped at ten names plus a count. Gated behind super_user set_configuration, so this is hardening rather than a reachable exploit. The atomicity integration case asserted logging.rotation.maxSize was still '12M', a value an earlier test in the file happens to set -- so in isolation or after a reorder it failed for a reason unrelated to the change. It now reads the value first and asserts it is unchanged.
The control-character replacement and the ten-name cap had no test, which round 4 flagged as a gap in new code. Both are now asserted directly: a name carrying a newline comes back with the character replaced and no newline in the message, and 25 unknown keys produce ten names plus "(and 15 more)". Also drops three comments the review called narration -- the suffix-escape rationale, the fan-out caveat on the preflight, and the integration setup note. The Object.hasOwn rationale and the sanitizer's log-safety note stay: neither is recoverable from the code.
Contributor
There was a problem hiding this comment.
Code Review
This pull request introduces robust validation for configuration parameters in config/configUtils.ts by checking for unrecognized parameters before applying any local writes, preventing partial updates, and returning a 400 Bad Request error. It also adds corresponding integration and unit tests. The review feedback suggests enhancing robustness by adding explicit string type checks in lookupConfigParam and isSuffixEscapedParam to prevent runtime TypeError errors. Additionally, it recommends using optional chaining in test assertions to avoid masking server errors, and using assert.strictEqual from the bare node:assert module in accordance with repository standards.
This comment has been minimized.
This comment has been minimized.
lookupConfigParam and isSuffixEscapedParam called string methods on their argument without checking it was a string. Not introduced here -- the bare CONFIG_PARAM_MAP[param.toLowerCase()] lookups they replaced would throw the same way -- but it is the same contract gap as the inherited-name case: getConfigValue is meant to return undefined for every miss, and a non-string still threw. Both helpers now return undefined/false instead. The added integration assertions indexed into r.body directly, so a non-JSON or empty response would surface as a TypeError inside the assertion instead of the server's actual error text. They now use optional chaining, and the strict assertion variants per the styleguide.
The optional chaining added for the previous review finding also meant that if both get_configuration reads returned an unexpected shape, `before` would be undefined and the post-rejection assertion would compare undefined to undefined -- green, having proved nothing. Assert the value is readable first, so the chaining still prevents a TypeError from masking the server error without weakening what the test proves.
kriszyp
marked this pull request as ready for review
August 22, 2026 13:14
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.
set_configurationresolved each request key throughCONFIG_PARAM_MAPand, on a miss, ended the loop iteration with no error and no log — then returnedConfiguration successfully set. A typo, or a config block Harper does not expose as a param, was accepted with HTTP 200 and never written. The operation now preflights every request key and rejects the whole request with a 400 naming each unrecognized param, before anything is written locally.Verified live on 5.2.4 before the fix: a
modelsblock andlogging_levlboth returned success and changed nothing.For the human reviewer
Which layer enforces this. The preflight sits in
setConfiguration, not inupdateConfigValue's bulk loop, and not as a Joi schema inoperationsValidation.jslike most operations. The writer must stay permissive:createConfigFilemergesinstallParamscarrying install-only keys (INSTALL_PROMPTS.DEFAULTS_MODE,HDB_CONFIG) atutility/install/installer.ts:617, so strictness there aborts install. A Joi schema would also cover future bulk writers and match how every other operation validates — but it would have to duplicate the dynamic alias table and the_package/_portrule outside the config subsystem. Cheap to move later, except that the error text and status become API surface the moment this ships.This is a breaking change; it is milestoned v5.3 (Kris's call, 2026-08-22).
set_configurationhas tolerated extra keys for years. Any client that sends one — Studio spreads arbitrary...changesfrom its config UI — starts failing on upgrade, and the failure is total rather than partial, so it holds for a minor rather than riding a 5.2.x patch.The scope question behind that call, for the record: no request that previously succeeded and applied its changes behaves differently. The only changes outside new error responses run the other way —
getConfigValue/createConfigFilepreviously threw aTypeErroron an inherited or non-string param name and now returnundefined/skip, which removes a crash rather than adding one. Everything else is a 200-that-silently-discarded becoming a 400.Typo-shaped names are still accepted. The preflight reuses the
_package/_portescape hatch verbatim, sologging_portorhtpp_portpasses validation and is written as a component entry. That is arguably the larger half of set_configuration silently discards unrecognized params and returns success (no error on a CONFIG_PARAM_MAP miss) #2266 for a real operator. Tightening it (requiring the prefix to look like a component name) is a follow-up, not a revert.hdb_auth_headeris stripped silently, whereregistryAuthis rejected with a rename message (components/operationsValidation.js:525). I chose stripping because it is the 4.x spelling of an internal auth artifact that clients never typed, so a 400 would break legacy-shaped callers for no benefit. A reviewer may want consistency with theregistryAuthprecedent instead.Escape-hatch case sensitivity is now pinned by a test.
_package/_portmatch case-sensitively while every mapped param matches case-insensitively, soTYPO_PORTis rejected. That asymmetry is pre-existing behavior I preserved rather than a decision I made — but the test now encodes it, so a reviewer reading it as a bug will find a test asserting it is intended.Round-3 review added two hardening nits, both applied: the error message now strips control characters and caps the number of names it lists (a key containing a newline could otherwise forge a line in the operations log, and a body with thousands of unknown keys produced an unbounded message), and the atomicity integration case now reads the current value rather than assuming what an earlier test left behind, so it holds under isolation or reordering.
One nit is knowingly carried: the new unit cases sit inside the file's existing Sinon/rewire describe block rather than a real-module seam. The rejection cases need no stub at all — they throw before the writer is reached — but the atomicity assertion has to observe that the writer was not called, and reusing the harness that already exists beat introducing a second one.
Two findings from the review are real, pre-existing, and deliberately not fixed here — both are the same false-success family and are filed: #2269 (a resolved param whose
configDoc.setInthrows is caught, logged, and still reported as success) and #2270 (on areplicated: truefan-out the origin overwrites the per-node response with the success string). #2270 matters more because of this PR: a peer that used to silently ignore an unknown name now 400s the whole request, so during a mixed-version window older peers apply neither param where they previously applied the recognized one. That widens divergence, and it is only visible inresponse.replicated. Worth deciding alongside decision 2 below.Verification
integrationTests/apiTests/configuration.test.mjsgains four HTTP-level cases: single unknown name 400s naming it; several unknown names produce one 400 naming each; a mixed recognized/unrecognized request 400s and a follow-upget_configurationproves the recognized half did not land; a_packageentry still writes (asserted viaget_configuration, then removed so it does not leak into later tests).npx mocha --conditions=typestrip unitTests/config/configUtils.test.js→ 87 passing.config/configUtils.tsfromorigin/main, deleteddist/config/configUtils.jsand the tsbuildinfo, rebuilt, re-ran → 6 failing (the five rejection cases plus the control-field expectation). Three control cases pass on either side. Restored and rebuilt → 87 passing.modelsandlogging_levlpayloads from set_configuration silently discards unrecognized params and returns success (no error on a CONFIG_PARAM_MAP miss) #2266 reproduced on a 5.2.4 instance; also confirmedtoString/hasOwnProperty/valueOftake the same silent-success path, whileconstructoris already blocked upstream by a body-property guard — which is why theObject.hasOwnguard is described as defense-in-depth for direct callers rather than a reachable HTTP fix.Fixes #2266
Complexity: medium
Review-Coverage: authored=claude; ran=codex,gemini; declined=cursor-grok,cursor-composer,domain; rounds=6 @ c98b40e
Human-Review-Need: 3 @ c98b40e