feat(nullpii): mask template syntax ranges from PII detection - #44
Conversation
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>
|
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 ( The target case no longer reproduces on
|
| 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.
Summary
After #43 stripped the PUA sentinels around user-authored
{{...}}, the GLiNER model still tagged template variable names (short-kebab-case-slug,user_name, …) asprivate_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 statementPartial overlap counts — a span crossing
}}would also corrupt bracket count after vault substitution.Files
src/template-mask.ts(new) —findTemplateRanges,dropSpansInsideTemplatessrc/nullpii.ts— wire post-applyThresholds, pre-refineSpanBoundariestest/template-mask.test.ts(new) — 11 unit teststest/nullpii.test.ts— integration: recognizer FP inside{{...}}gets maskedTest plan
npm test— 283 passing (was 271)npm run typecheck/lint/build— clean{{short-kebab-case-slug}}survives round-trip without nested-brace garbageNotes
{{ {{x}} }}resolves on inner pair (acceptable trade-off; outer becomes bare braces which GLiNER no longer sentinel-confuses).🤖 Generated with Claude Code