Writing Custom Escapers¶
The pipeline runs its universal stages, then an optional escaper that prepares the text for one destination. This page covers writing your own for destinations the built-in jinja2_escaper and path_escaper do not cover.
The Escaper Contract¶
An escaper is any function with the signature:
Rules:
- Accept a
strand return astr;clean()raisesTypeErrorfor any other return type - It runs after the universal stages, so null bytes, invisibles, NFKC and mapped homoglyphs are already handled
- Its output is not re-sanitized --- what you return is the final result, including any characters you add
- Exceptions it raises propagate to the caller of
clean()/walk() - Prefer pure functions; make them idempotent where the format allows, and otherwise make sure each value is escaped exactly once
Example: HTML Text Escaper¶
For text you insert into HTML yourself. A template engine with autoescaping is the better choice when you have one.
from navi_sanitize import clean
clean('<script>alert("xss")</script>', escaper=html_escaper)
# '<script>alert("xss")</script>'
html.escape is not idempotent (& becomes & again on a second pass), so escape each value once, at output time.
Example: SQL Identifier Allowlist¶
For identifiers (such as a sort column) that cannot be parameterized. This is not a substitute for parameterized queries, and you should still check the result against the identifiers you actually allow:
def sql_identifier_escaper(text: str) -> str:
"""Keep only letters, digits and underscores."""
return "".join(c for c in text if c.isalnum() or c == "_")
from navi_sanitize import clean
column = clean(user_input, escaper=sql_identifier_escaper)
if column not in {"name", "created_at"}:
raise ValueError("unsupported sort column")
Example: Composing Escapers¶
from navi_sanitize import Escaper, clean, jinja2_escaper, path_escaper
def compose(*escapers: Escaper) -> Escaper:
"""Apply escapers left to right."""
def composed(text: str) -> str:
for escaper in escapers:
text = escaper(text)
return text
return composed
path_then_jinja = compose(path_escaper, jinja2_escaper)
clean("../{{ x }}", escaper=path_then_jinja) # '\\{\\{ x \\}\\}'
Order matters. path_escaper turns backslashes into /, so running it after jinja2_escaper would rewrite the backslashes that escaper just added. Put the escaper whose output must survive last.
LLM Prompts¶
navi-sanitize does not ship a prompt escaper: vendor syntax changes quickly, and no character-level escaper stops instructions written in plain text. If your prompt format reserves delimiters, an escaper can keep untrusted text from forging them:
def fence_escaper(text: str) -> str:
"""Stop user text from closing a <user_input> ... </user_input> fence."""
return text.replace("<", "<").replace(">", ">")
Treat this as one layer. Model-side defenses, least-privilege tools and human review still apply.
Testing Escapers¶
from navi_sanitize import clean
def test_escaper_leaves_normal_text_alone():
assert clean("normal text", escaper=my_escaper) == "normal text"
def test_escaper_neutralizes_its_target():
assert dangerous_pattern not in clean(malicious_input, escaper=my_escaper)
def test_escaper_runs_after_universal_stages():
# e.g. a fullwidth or homoglyph variant of your target
assert dangerous_pattern not in clean(disguised_input, escaper=my_escaper)
def test_escaper_is_idempotent_where_expected():
once = my_escaper("dangerous input")
assert my_escaper(once) == once
Integration with walk()¶
Escapers work the same way with walk(), and apply to dict keys as well as values:
If your escaper maps different keys to the same string, walk() keeps the last value and logs a collision warning.