Skip to content

fix: fail closed when <private> markup is malformed - #208

Merged
EyJunge1 merged 1 commit into
tickernelz:mainfrom
LHMQ878:fix/private-tag-fails-open-on-malformed-markup
Jul 30, 2026
Merged

fix: fail closed when <private> markup is malformed#208
EyJunge1 merged 1 commit into
tickernelz:mainfrom
LHMQ878:fix/private-tag-fails-open-on-malformed-markup

Conversation

@LHMQ878

@LHMQ878 LHMQ878 commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Fixes #207

What was wrong

stripPrivateContent redacted with a single non-greedy pair match:

content.replace(/<private>[\s\S]*?<\/private>/gi, "[REDACTED]")

That construction fails open. A replace with no match returns the input unchanged, so malformed markup is stored verbatim with no signal that redaction did not happen. Measured against the implementation on main, with SECRET = "sk-live-abc123":

input before after leaked before
my key is <private>SECRET my key is <private>sk-live-abc123 my key is [REDACTED] yes
<private>SECRET <private>sk-live-abc123 [REDACTED] yes
<private>outer <private>inner</private> SECRET</private> [REDACTED] sk-live-abc123</private> [REDACTED] yes
<private >SECRET</private > <private >sk-live-abc123</private > [REDACTED] yes
visible </private> text visible </private> text visible text no
a <private>one</private> b a [REDACTED] b a [REDACTED] b no

isFullyPrivate returned false for all four leaking cases — including <private>SECRET with 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:548 passes it to memoryClient.addMemory, and :612 writes 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:

const PRIVATE_TAG = /<(\/?)private\s*>/gi;
  • Unclosed <private> redacts to the end of the input.
  • Nested tags close at the outer tag, so the region is not released early and no stray </private> survives.
  • Whitespace inside the tag (<private >) is tolerated, the way an XML reader would.
  • A stray </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 = 0 at 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.
  • The if (!content.includes("<")) return content fast path keeps the common no-markup case a single scan.

Verification

  • bun test tests/privacy.test.ts12 pass, all pre-existing tests unchanged. They describe well-formed input, which this fix does not alter.
  • bun test tests/privacy-malformed-tags.test.ts12 pass.
  • With src/services/privacy.ts reverted to main and the new tests kept: 8 fail / 4 pass.
  • Full bun test: 290 pass / 9 fail vs baseline 278 / 9 — exactly +12, no new failures. (Those 9 are pre-existing here: plugin-loader-contract and plugin-bundle-boundary require a built dist/.)
  • tsc --noEmit clean, prettier --check clean; the husky pre-commit hook ran both and passed.

Tests added

tests/privacy-malformed-tags.test.ts — 12 cases:

  • unclosed tag redacts to end of input; isFullyPrivate is true for it; a truncated message drops the trailing text too
  • nested region closes at the outer tag, with not.toContain("private>") so a stray tag cannot pass
  • <private > whitespace form
  • stray </private> dropped rather than surfaced
  • mixed case, including <Private> unclosed
  • two independent regions still redact separately with the text between them preserved
  • no-markup input untouched, including use Array<string> and a < b and the empty string
  • <privateer> not treated as the redaction tag
  • three consecutive calls with the same input all return [REDACTED] — pins the lastIndex reset
  • an unclosed region opening after a closed one

Scope

Redaction behaviour only; no change to what is stored on the success path, to the [REDACTED] marker (the profile path greps for it at src/index.ts:613), or to the two call sites.

`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
@EyJunge1
EyJunge1 merged commit 2a63c1f into tickernelz:main Jul 30, 2026
5 checks passed
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.

stripPrivateContent fails open on malformed <private> markup, storing the content verbatim

2 participants