fix: fail closed when <private> markup is malformed - #208
Merged
EyJunge1 merged 1 commit intoJul 30, 2026
Merged
Conversation
`stripPrivateContent` redacted with a non-greedy pair match, which fails open: an unclosed `<private>` matched nothing and the region was returned verbatim, and a nested region closed at the first inner `</private>`, releasing the remainder. `<private >` matched nothing at all. Both callers (`src/index.ts:548` for memories, `:612` for the user profile) persist the returned string, so an under-redaction is unrecoverable while an over-redaction only loses retypeable text. Scan tags with a depth counter instead. An unclosed region redacts to the end of the input, nested tags close at the outer tag, whitespace inside the tag is tolerated, and a stray `</private>` is dropped as markup rather than surfaced as content. Fixes tickernelz#207
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.
Fixes #207
What was wrong
stripPrivateContentredacted with a single non-greedy pair match:That construction fails open. A
replacewith no match returns the input unchanged, so malformed markup is stored verbatim with no signal that redaction did not happen. Measured against the implementation onmain, withSECRET = "sk-live-abc123":my key is <private>SECRETmy key is <private>sk-live-abc123my key is [REDACTED]<private>SECRET<private>sk-live-abc123[REDACTED]<private>outer <private>inner</private> SECRET</private>[REDACTED] sk-live-abc123</private>[REDACTED]<private >SECRET</private ><private >sk-live-abc123</private >[REDACTED]visible </private> textvisible </private> textvisible texta <private>one</private> ba [REDACTED] ba [REDACTED] bisFullyPrivatereturnedfalsefor all four leaking cases — including<private>SECRETwith nothing else in the message — so the write was not blocked either. The content was accepted and stored unredacted.Both callers persist the result:
src/index.ts:548passes it tomemoryClient.addMemory, and:612writes it into the user profile. An under-redaction there is not a rendering glitch — it is in the database, and for the memory path it has also been sent to a remote service.What changed
Scan tags with a depth counter instead of matching pairs, so the malformed shapes fail closed:
<private>redacts to the end of the input.</private>survives.<private >) is tolerated, the way an XML reader would.</private>with no opener is markup, not the user's text, so the tag is dropped rather than surfaced as content.Erring toward redaction is the only safe direction on a persisting path: over-redacting loses text the user can retype, under-redacting cannot be undone.
Two details worth calling out:
PRIVATE_TAG.lastIndex = 0at the top of each call. The literal is module-level and/g, so without the reset a second call would resume mid-string and miss the tag. There is a test for this.if (!content.includes("<")) return contentfast path keeps the common no-markup case a single scan.Verification
bun test tests/privacy.test.ts— 12 pass, all pre-existing tests unchanged. They describe well-formed input, which this fix does not alter.bun test tests/privacy-malformed-tags.test.ts— 12 pass.src/services/privacy.tsreverted tomainand the new tests kept: 8 fail / 4 pass.bun test: 290 pass / 9 fail vs baseline 278 / 9 — exactly +12, no new failures. (Those 9 are pre-existing here:plugin-loader-contractandplugin-bundle-boundaryrequire a builtdist/.)tsc --noEmitclean,prettier --checkclean; the husky pre-commit hook ran both and passed.Tests added
tests/privacy-malformed-tags.test.ts— 12 cases:isFullyPrivateistruefor it; a truncated message drops the trailing text toonot.toContain("private>")so a stray tag cannot pass<private >whitespace form</private>dropped rather than surfaced<Private>uncloseduse Array<string> and a < band the empty string<privateer>not treated as the redaction tag[REDACTED]— pins thelastIndexresetScope
Redaction behaviour only; no change to what is stored on the success path, to the
[REDACTED]marker (the profile path greps for it atsrc/index.ts:613), or to the two call sites.