Skip to content

Reject unrecognized parameters in set_configuration instead of reporting success - #2272

Open
kriszyp wants to merge 7 commits into
mainfrom
kris/config-unrecognized-params
Open

Reject unrecognized parameters in set_configuration instead of reporting success#2272
kriszyp wants to merge 7 commits into
mainfrom
kris/config-unrecognized-params

Conversation

@kriszyp

@kriszyp kriszyp commented Aug 22, 2026

Copy link
Copy Markdown
Member

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 Configuration 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 models block and logging_levl both returned success and changed nothing.

For the human reviewer

  1. Which layer enforces this. The preflight sits in setConfiguration, not in updateConfigValue's bulk loop, and not as a Joi schema in operationsValidation.js like most operations. The writer must stay permissive: createConfigFile merges installParams carrying install-only keys (INSTALL_PROMPTS.DEFAULTS_MODE, HDB_CONFIG) at utility/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/_port rule outside the config subsystem. Cheap to move later, except that the error text and status become API surface the moment this ships.

  2. This is a breaking change; it is milestoned v5.3 (Kris's call, 2026-08-22). set_configuration has tolerated extra keys for years. Any client that sends one — Studio spreads arbitrary ...changes from 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/createConfigFile previously threw a TypeError on an inherited or non-string param name and now return undefined/skip, which removes a crash rather than adding one. Everything else is a 200-that-silently-discarded becoming a 400.

  3. Typo-shaped names are still accepted. The preflight reuses the _package/_port escape hatch verbatim, so logging_port or htpp_port passes 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.

  4. hdb_auth_header is stripped silently, where registryAuth is 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 the registryAuth precedent instead.

  5. Escape-hatch case sensitivity is now pinned by a test. _package/_port match case-sensitively while every mapped param matches case-insensitively, so TYPO_PORT is 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.setIn throws is caught, logged, and still reported as success) and #2270 (on a replicated: true fan-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 in response.replicated. Worth deciding alongside decision 2 below.

Verification

  • Route (a) — extended the existing integration suite. integrationTests/apiTests/configuration.test.mjs gains 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-up get_configuration proves the recognized half did not land; a _package entry still writes (asserted via get_configuration, then removed so it does not leak into later tests).
  • Unit: npx mocha --conditions=typestrip unitTests/config/configUtils.test.js → 87 passing.
  • Fails-on-base: restored config/configUtils.ts from origin/main, deleted dist/config/configUtils.js and 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.
  • Live: the models and logging_levl payloads 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 confirmed toString/hasOwnProperty/valueOf take the same silent-success path, while constructor is already blocked upstream by a body-property guard — which is why the Object.hasOwn guard 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

…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.
@kriszyp
kriszyp requested review from dawsontoth and heskew August 22, 2026 00:56
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.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread config/configUtils.ts
Comment thread config/configUtils.ts Outdated
Comment thread integrationTests/apiTests/configuration.test.mjs
@claude

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
kriszyp marked this pull request as ready for review August 22, 2026 13:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

set_configuration silently discards unrecognized params and returns success (no error on a CONFIG_PARAM_MAP miss)

1 participant