Skip to content

feat(nullpii): mask template syntax ranges from PII detection - #44

Closed
lBroth wants to merge 1 commit into
mainfrom
fix/template-skip-ranges
Closed

feat(nullpii): mask template syntax ranges from PII detection#44
lBroth wants to merge 1 commit into
mainfrom
fix/template-skip-ranges

Conversation

@lBroth

@lBroth lBroth commented May 21, 2026

Copy link
Copy Markdown
Owner

Summary

After #43 stripped the PUA sentinels around user-authored {{...}}, the GLiNER model still tagged template variable names (short-kebab-case-slug, user_name, …) as private_person. Vault substitution then nested the PII placeholder inside the user's template, producing bracket-broken output like {{{{PII_PRIVATE_PERSON_*}}}}.

This adds a post-filter that finds the syntactic ranges of common templating dialects and drops any span overlapping one of them:

  • {{ ... }} — Mustache / Handlebars / Vue / Jinja2 expression
  • ${ ... } — JS / TS template literal
  • <% ... %> — ERB / EJS
  • {% ... %} — Jinja2 / Twig statement

Partial overlap counts — a span crossing }} would also corrupt bracket count after vault substitution.

Files

  • src/template-mask.ts (new) — findTemplateRanges, dropSpansInsideTemplates
  • src/nullpii.ts — wire post-applyThresholds, pre-refineSpanBoundaries
  • test/template-mask.test.ts (new) — 11 unit tests
  • test/nullpii.test.ts — integration: recognizer FP inside {{...}} gets masked

Test plan

  • npm test — 283 passing (was 271)
  • npm run typecheck / lint / build — clean
  • Repro user's gateway report case: system prompt with {{short-kebab-case-slug}} survives round-trip without nested-brace garbage

Notes

  • Template ranges computed on the original input — escape is length-preserving so offsets transfer to the escaped coord space spans live in.
  • Non-greedy regex match — nested {{ {{x}} }} resolves on inner pair (acceptable trade-off; outer becomes bare braces which GLiNER no longer sentinel-confuses).

🤖 Generated with Claude Code

After PR #43 stripped the PUA sentinels around user-authored `{{...}}`,
the GLiNER model still tagged template variable names (`short-kebab-case-slug`,
`user_name`, …) as `private_person`. Vault substitution then nested the
PII placeholder inside the user's template, producing bracket-broken
output like `{{{{PII_PRIVATE_PERSON_*}}}}`.

This adds a post-filter that finds the syntactic ranges of common
templating dialects and drops any span overlapping one of them:

  - `{{ ... }}` (Mustache / Handlebars / Vue / Jinja2 expression)
  - `${ ... }` (JS / TS template literal)
  - `<% ... %>` (ERB / EJS)
  - `{% ... %}` (Jinja2 / Twig statement)

Partial overlap counts — a span crossing `}}` would also corrupt
bracket count after vault substitution.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@lBroth

lBroth commented Jul 29, 2026

Copy link
Copy Markdown
Owner Author

Closing this — the pathology it targets is already fixed by #43, and the implementation carries a DoS plus four redaction regressions. Evidence below, all reproduced against the built branch with the real model (lBroth/nullpii, CPU).

The target case no longer reproduces on main

#43 strips the placeholder-escape PUA sentinels in normalizeForDetection, which was the actual reason {{...}} got tagged. With that in, ten template-variable probes on main produce zero tagged spans — including the two examples this PR's own description cites:

name: {{short-kebab-case-slug}}                                   → 0 spans
frontmatter: name: {{name}} description: {{description}}          → 0 spans
Dear {{customer_full_name}}, contact us at {{support_email}}      → 0 spans
Send to {{ user.email }} and cc {{ manager.email }}               → 0 spans
Hello {{ firstName }} {{ lastName }}, your order ships today      → 0 spans
const msg = `Hi ${userName}, your id is ${userId}`;               → 0 spans
<% @user.name %> signed on <%= @date %>                           → 0 spans
{% for person in people %} {{ person.name }} {% endfor %}          → 0 spans
Ciao {{nome_cliente}}, IBAN {{iban_cliente}}, data {{data_nascita}} → 0 spans
Author: {{author.name}} wrote this                                → 0 spans

test/nullpii.test.ts already carries the regression test for this ("preserves user-authored {{...}} templates around a real PII hit"), and it passes on main without this PR.

What the PR costs

1. Quadratic regex under the input cap. [\s\S]*? in three of the four patterns is unbounded, so a repeated opener with no closer makes every index a match start:

input time
'{{'.repeat(100_000) (200 KB) 2,694 ms
'{{'.repeat(250_000) (500 KB) 17,376 ms
'{{'.repeat(500_000) (1 MB, MAX_INPUT_BYTES) 69,738 ms
1 MB of ordinary prose 1.1 ms

The rest of the pipeline on that same 1 MB payload totals ~70 ms (escapePlaceholders 6, normalizeForDetection 7, detectBase64Pii 2, chunkText 31, runRecognizers 24), so this is a ~1000× regression on an otherwise fast path. It also contradicts the invariant written in src/defaults.ts:855-858"Hard byte cap … so adversarial 1 MB+ payloads with quadratic regex behaviour are not a DoS vector" — by introducing exactly that behaviour underneath the cap. The gateway binds to 127.0.0.1 so this is not remotely reachable, but sanitize() runs on tool_result content (packages/gateway/src/sanitizer.ts:168), i.e. fetched pages and MCP output, and DEFAULT_BODY_LIMIT is 10 MB. ${...} is the one linear pattern precisely because its inner class is [^{}]*.

2. Unbalanced delimiters suppress redaction in ordinary text. Because the inner class crosses newlines, one stray opener plus any later closer masks everything between:

struct u users[] = {{"alice@acme.io",1}, {"bob@acme.io",2}};   → 0 spans, both emails in the clear
Hi {{ name }\nEmail: alice@acme.io\nBye {{ other }}            → 0 spans, email in the clear
Report <% draft\nContact: alice@acme.io\ndone %> end           → 0 spans, email in the clear
Contact me at {{alice@acme.io}} thanks                         → 0 spans, email in the clear

All four redact correctly on main. The first is a C/C++/Rust/Java nested array initializer; the second is a one-character typo. Balanced Handlebars block helpers are fine ({{#if x}} … {{/if}} still redacts), so this is strictly an unbalanced-delimiter bug.

3. No opt-out. #45 ships urlAllowlist: 'none'; this ships unconditionally with no config key and no export, so a user who cannot accept the above has no escape short of forking.

If this comes back

The bound is the fix for the DoS — /\{\{[^{}\n]{0,200}\}\}/g and friends take the 1 MB payload from 69,738 ms to 2.3 ms. But note the \n exclusion and the {0,200} bound are independent knobs: the bound alone fixes the DoS (→ 225 ms), and the newline exclusion is what fixes the suppression — at the cost of multi-line ERB/EJS, the most common genuinely multi-line dialect in this feature's own domain. That trade should be made deliberately.

Given #43 already covers the motivating case, there is currently nothing on the benefit side to pay for it.

@lBroth lBroth closed this Jul 29, 2026
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.

1 participant