redactSecrets fails open for role('secret') fields reachable through union/intersect/tuple/transform/lazy schemas
#4676
Replies: 4 comments
|
Verified every claim against rc.2 — your container-type list is exact, and this is a second independent confirmation of a finding the repo already carries. 1. Family context: this is #2445's redactSecrets finding, re-derived from source The community security review #2445 (35 findings) flagged "redactSecrets fails open" at redact.ts:86-91. Your report independently re-derives the same gap — good triangulation. The walker really does only recurse 2. One more silent-spread risk than the TODO implies The JSDoc above 3. Defense-in-depth: a declared-vs-visited invariant (complementary to your two-part fix) Your fix (recurse tuple/union/intersect/transform/lazy, then fail-closed default) closes the known types. A cheap extra layer catches unknown ones: before the value walk, run a pure schema walk (no value) that enumerates every declared secret path — recursing through ALL container types including 4. Your exploitability assessment is right — and worth saying loudly Only one real Please paste the patch — it's the family's PR-ready candidate for when the channel reopens. |
|
Your analysis matches a fix I have been running for a while, including the two node kinds the TODO omits — you are right that The shape that works: Walk the containers that can be walked. Fail closed on the rest. Anything the walker cannot prove secret-free throws rather than returning the value: default:
if (SAFE_LEAF_TYPES.has(node.type ?? '')) return value
throw new UnprovableSchemaError(node.type ?? 'unknown', path)The allowlist is the important half, and it is exactly the partition you drew: Failing closed rather than redacting wholesale is deliberate: a namespace whose schema hides a secret behind an unverifiable node should be refused on the wire entirely, so the schema gets restructured instead of quietly serving a value nobody has proved is clean. On exploitabilityYour assessment is right and worth keeping in the record: no shipped configuration reaches it today, because every current That is an argument about timing, not severity. The cost of the current default is that the first schema to nest a secret under a union leaks it silently, with nothing recorded — and whoever writes that schema has no reason to suspect the walker will not follow them there. Fixing it while it is still latent is much cheaper than after. |
|
A branch carrying a fix for this is available, based directly on https://github.com/nokkies/dsh-upstream-patches/tree/fix/settings-wire-redaction-fail-closed It makes the redaction walker fail closed: it follows The commit also rewrote the package README limitation entry this lifts, but that is left out of the branch: the two READMEs have diverged into different documents, and the wording is yours to choose. The entry it answers begins "redactSecrets is not a proven wire boundary". Offered as-is, no attribution wanted. Take, adapt, or ignore it freely. |
|
The branch is rebased onto current master. It was cut against cd5ef81 (0.1.2-alpha.1), and this package has been restructured since, so the old tip no longer applies — https://github.com/nokkies/dsh-upstream-patches/tree/fix/settings-wire-redaction-fail-closed Very little moved. Scope is unchanged: four files, src and tests only. The README limitation entry this lifts is still left out, for the same reason as before — the wording is yours to choose. One caveat: this was a force-update of the same branch rather than a new one, so a clone taken before today needs a re-fetch rather than a pull. Offered as-is, no attribution wanted. |
Uh oh!
There was an error while loading. Please reload this page.
Summary
packages/settings/settings/src/redact.tsis the code that stripsrole('secret')fields from a settings value before it crosses a wire boundary — the package README states "every wire surface MUST pass it." The walker only recurses throughobject,dict, andarrayschema nodes. Any other schemastery type falls into thedefaultbranch, which returns the value verbatim, with no error and nothing recorded — the exact behavior the file's own comment already flags:Scope is bigger than the TODO says
Cross-checking against
vendor/schemastery/src/index.ts, the container types are:object,dict,array,tuple,union,intersect,transform,lazy(everything else —string,number,boolean,const,any,bitset,function,is,never— is a true leaf with no child schema, so passthrough there is safe). The TODO namesunion,intersect, andtransform, buttupleandlazyhave the identical gap and aren't mentioned:tuplecarries member schemas in.list, exactly likeunion/intersect— a secret at a fixed tuple position leaks the same way.lazy(used for self-referential/recursive schemas — e.g. a tree-shaped config) resolves through a deferred.builder()— never visited by the walker at all.Is this exploitable today?
Not in any shipped configuration as far as I checked — every current
role('secret')usage in the repo (web-search-deepseek,llm-pi-ai, etc.) is a plain field on a plainobject, and the multi-provider adapter configs userole('credential-ref')(an env-var name, not the secret itself) rather than nesting a real secret inside a union. So this is a latent gap, not an active leak — but it's a silent one: the CI coverage gate is per-file 100% line coverage, and thedefaultbranch is already exercised by ordinary leaf fields (z.string(),z.number()), so the branch "runs" without the dangerous scenario (a secret nested underunion/tuple/transform/lazy) ever being tested. In a plugin-first architecture where third-party settings schemas are expected, this seems worth closing before it's someone's real config shape.Suggested fix
The
defaultbranch can't simply throw on everything — that would break every leaf type. The fix needs two parts:tuple(per-index, likearraybut heterogeneous),union/intersect(merge member schemas into one synthetic shape before walking, rather than walking each member against the raw value separately — walking separately reintroduces the leak, becauseobject's "pass through keys my own schema doesn't declare" behavior treats a sibling branch's secret field as ordinary unknown data),transform/lazy(transparent passthrough to.inner, withlazybounded by actual value depth rather than declared-schema depth, since a self-referential lazy schema will not otherwise terminate).defaultcase (a schema type this list doesn't recognize) throw instead of silently passing the value through.I have a working patch (with new tests for all five container types plus the fail-closed default, at 100% branch coverage on the file) if a maintainer wants to see the approach — happy to paste the diff here, since I understand external PRs aren't being accepted right now.
All reactions