OCR-based content sanitization for LLM search pipelines.
A local-first defense layer that sanitizes web content before it reaches your LLM. Strips prompt injections, invisible Unicode attacks, exfiltration channels, and adversarial payloads using a 5-layer pipeline — including a text→image→OCR round-trip that eliminates anything invisible to the human eye.
Built for developers who use local LLMs via an OpenAI-compatible endpoint (e.g., LM Studio) and want to search the web without getting pwned.
Web Content → OCR → Regex Detect → Redact → URL Strip → Trust Wrap → Clean to LLM
When your LLM searches the web, every fetched page is an attack surface. Poisoned web content can contain:
- Invisible Unicode injection — zero-width characters, bidi overrides, homoglyphs that hide instructions
- Prompt injection — "ignore previous instructions" embedded in web pages
- Data exfiltration — markdown image tags that encode your data in URL parameters
- Role hijacking — fake system prompts in fetched content
- Obfuscated attacks — base64 payloads, typoglycemia, delimiter injection
Google DeepMind's research (arXiv:2505.14534) showed that even their best model-level defenses fail 53.6% of the time against adaptive attacks. The "Attacker Moves Second" paper demonstrated that all 12 published defenses were bypassed at >90% success rate.
This tool takes a different approach: instead of asking the LLM to resist injection, it removes the attack text before the LLM ever sees it.
Five independent defense layers, each catching a different class of attack:
| Layer | What it does | What it catches |
|---|---|---|
| 1. OCR | Renders text to image, OCRs back | All invisible characters, Unicode steganography, bidi overrides |
| 2. Regex Detect | 31 compiled patterns from OWASP, DeepMind, CrAIBench | Instruction overrides, role hijacking, system tag injection, delimiter attacks |
| 3. Regex Redact | Replaces detected patterns with [REDACTED] |
Prevents detected attacks from reaching the LLM |
| 4. URL/Email Redact | Strips URLs, emails, IP:port not on allowlist | Eliminates exfiltration channels (markdown imgs, hidden endpoints) |
| 5. Trust Wrap | Tags content as HOSTILE or UNTRUSTED |
Gives the LLM provenance metadata about content trustworthiness |
Since we generate the image (not scan a document), we control every variable:
- 300 DPI — Tesseract minimum for reliable results
- 20pt DejaVu Sans Mono — cap height ~30px (optimal 20-40px range)
- 2400px width — ~100 chars/line, minimizes wrapping
- TIFF format — lossless, no alpha channel issues
- LSTM engine (
--oem 1) — 5-15% better than legacy - Grayscale, sharpened, bordered — every documented optimization applied
12 adversarial payloads covering 7 attack categories. All 12 neutralized.
── T01: Instruction Override ──
✓ NEUTRALIZED | regex:2 unicode:0 urls:0 ocr:True
── T02: Unicode Steganography ──
✓ NEUTRALIZED | regex:2 unicode:15 urls:0 ocr:True
── T03: Bidi Override ──
✓ NEUTRALIZED | regex:0 unicode:4 urls:0 ocr:True
── T04: Markdown Exfil ──
✓ NEUTRALIZED | regex:1 unicode:0 urls:0 ocr:True
...
Passed: 12/12 Failed: 0 Errors: 0
✓ ALL ATTACKS NEUTRALIZED
Run the red team yourself: python3 redteam.py
Requirements: Docker + an OpenAI-compatible LLM endpoint (e.g., LM Studio) serving google/gemma-4-26b-a4b-qat
git clone https://github.com/Morfasco/search-sanitizer.git
cd search-sanitizer
bash setup.shThis builds the containers, generates a SearXNG secret, and starts the stack.
Copy .env.example to .env, then set the runtime variables used by the service:
cp .env.example .envLLM_BASE_URL(default:http://host.docker.internal:1234/v1)LLM_API_KEY(default sentinel:lm-studio; value is ignored by LM Studio)FILTER_MODEL(default:google/gemma-4-26b-a4b-qat)OCR_MAX_CONCURRENCYOCR_CHUNK_SIZESEARXNG_URL
Test it:
# Health check
curl -s http://localhost:8000/health | python3 -m json.tool
# Unified search + sanitize
curl -s -X POST http://localhost:8000/search \
-H "Content-Type: application/json" \
-d '{
"original_query": "how to harden llm search pipelines",
"mode": "mixed",
"max_results": 4
}' \
| python3 -m json.tool
# Run the red team
python3 redteam.py| Endpoint | Model | OCR | Use case |
|---|---|---|---|
POST /search |
google/gemma-4-26b-a4b-qat |
✅ | Unified search + sanitize |
GET /health |
— | — | Service health |
GET / |
— | — | Service info/root |
Every fetched page from POST /search goes through the full sanitization pipeline. There is no unsanitized path.
┌─────────────────────────────────────────────────────────┐
│ Your LLM (OpenAI-compatible, e.g. LM Studio) │
│ Only sees sanitized, redacted, trust-wrapped content │
└──────────────────────────▲──────────────────────────────┘
│
┌──────────────────────────┴──────────────────────────────┐
│ search-sanitizer (Docker) │
│ │
│ ┌─── Fetch ───┐ ┌─── Sanitize ──────────────────┐ │
│ │ SearXNG │ │ 1. OCR (text→image→OCR) │ │
│ │ (internal) │→ │ 2. Regex detect (31 patterns) │ │
│ │ port 8080 │ │ 3. Regex redact [REDACTED] │ │
│ └─────────────┘ │ 4. URL/email/endpoint strip │ │
│ │ 5. Trust wrap (HOSTILE/UNTRUST) │ │
│ └────────────────────────────────┘ │
│ port 8000 (localhost only) │
└──────────────────────────────────────────────────────────┘
original_query(string, required, min length3) — user's natural-language question used for filtering/grounding.queries(string[], optional, max8) — pre-built search queries; if omitted, derived fromoriginal_query.mode("code" | "concepts" | "mixed", default"mixed").max_results(int,1–15, default6).engines(string[], optional, max8) — caller-explicit engine targeting. Allowed values:bing,duckduckgo,brave,qwant,github,stackexchange. Unknown engine names are ignored (logged and dropped). If omitted, engines are selected frommode.
{
"original_query": "how to monitor new security advisories for dependencies",
"queries": [
"python dependency vulnerability feed",
"github security advisories api"
],
"mode": "code",
"max_results": 5,
"engines": ["github", "stackexchange", "bing"]
}original_querysearch_queries_used(string[])moderesults_found(int)pages_fetched(int)content(string) — sanitized assembled content.token_estimate(int)elapsed_seconds(float)sources({ title, url, chars_used, engine }[]) — includes the per-source engine that returned each result.unresponsive_engines({ engine, reason }[]) — engines that returned nothing due to CAPTCHA/rate-limit/transient failure.
{
"original_query": "how to monitor new security advisories for dependencies",
"search_queries_used": [
"python dependency vulnerability feed",
"github security advisories api"
],
"mode": "code",
"results_found": 6,
"pages_fetched": 5,
"content": "[UNTRUSTED] ... sanitized content ...",
"token_estimate": 1812,
"elapsed_seconds": 2.47,
"sources": [
{
"title": "GitHub Advisory Database",
"url": "https://github.com/advisories",
"chars_used": 1342,
"engine": "github"
}
],
"unresponsive_engines": [
{
"engine": "bing",
"reason": "rate_limited"
}
]
}- Explicit engine targeting: pass
enginesinPOST /searchto target a caller-selected subset of the allowed engines. - Mode → engine routing defaults:
code→github,stackexchange,bingconcepts→bing,duckduckgo,brave,qwantmixed→ all six (bing,duckduckgo,brave,qwant,github,stackexchange)
- Coverage transparency:
unresponsive_enginesreports per-engine CAPTCHA/rate-limit/failure conditions so thin results are diagnosable by callers. - Retry/backoff: up to 2 retries (3 attempts total) with exponential backoff
0.5 * 2^attemptseconds plus random jitter in[0, 0.5). Retries apply to transient failures (HTTP 5xx, timeouts, transport errors) and SearXNG “too many requests” responses. CAPTCHA and other HTTP 4xx responses are not retried; CAPTCHA is surfaced inunresponsive_engines. - Engine set + CAPTCHA avoidance: SearXNG is constrained via
keep_onlytobing,duckduckgo,brave,qwant,github, plus a customstackexchangeengine targetingstackoverflow.com. Engines known to trigger aggressive self-hosted CAPTCHA/rate-limit behavior (for examplegoogleandstartpage) are removed entirely.
| Feature | search-sanitizer | Rebuff | Vigil | IPI-Scanner |
|---|---|---|---|---|
| OCR sanitization | ✅ | ❌ | ❌ | ❌ |
| Active redaction | ✅ | ❌ | ❌ | ❌ |
| URL/email stripping | ✅ | ❌ | ❌ | ❌ |
| Local-first (no cloud API) | ✅ | ❌ | ✅ | ✅ |
| Integrated search agent | ✅ | ❌ | ❌ | ❌ |
| Red team test suite | ✅ | ❌ | ❌ | ✅ |
| Trust-tier wrapping | ✅ | ❌ | ❌ | ❌ |
This tool is not a complete solution to prompt injection. Per Google DeepMind's research, prompt injection may never be fully solved with current LLM architectures.
What this tool does NOT catch:
- Semantic injection — natural-language attacks that don't match syntactic patterns
- Cross-page composite attacks — injections split across multiple search results
- Adaptive attacks — attackers who study the regex patterns and craft bypasses
- Model-level manipulation — the filter LLM is still an LLM
What this tool DOES catch:
- All invisible character attacks (OCR is pattern-agnostic)
- Known injection syntactic patterns (regex + redaction)
- Data exfiltration channels (URL/email/endpoint stripping)
- Unicode steganography, bidi overrides, homoglyphs
Defense in depth means no single layer is perfect, but together they raise the cost of attack significantly.
- This service is tuned around open-weight models on an OpenAI-compatible endpoint (default
google/gemma-4-26b-a4b-qat, roughly the 4B–26B class). - Default endpoint is LM Studio at
http://host.docker.internal:1234/v1, but any OpenAI-compatible server works (vLLM, text-generation-webui, llama.cpp server, Ollama's OpenAI-compatible endpoint, or a remote hosted API). - Point
LLM_BASE_URL,LLM_API_KEY, andFILTER_MODELat whatever stack you run. FILTER_SYSTEMinagent.pyis tuned for concise smaller open-weight models doing grounded extraction (faithful reconstruction, not summarization/rewriting). If you switch to a much larger/hosted model, expect to retune this prompt. If you want summarization/rewriting behavior, editFILTER_SYSTEMdirectly.
- This service was originally consumed by OpenCode via a small custom tool that POSTed to
/search. - That OpenCode glue is intentionally not shipped here; this repo is the standalone HTTP service.
- Any agent/framework can drive it by calling
POST /search(Claude Code, Cursor, custom scripts, etc.). - Request body:
{ queries: string[], original_query: string, mode: "code"|"concepts"|"mixed", max_results?: number, engines?: string[] }(queriesis optional; max 8). - Response includes
content,sources[](title,url,engine,chars_used),unresponsive_engines[]({ engine, reason }), and counts/metrics (results_found,pages_fetched,token_estimate,elapsed_seconds).
- Context/token budgets:
TOTAL_CONTEXT,CONTENT_BUDGET_TOKENS,OUTPUT_BUDGET_TOKENS, plusMAX_PAGES_TO_FETCHandMAX_PAGE_CHARSinagent.pyare tuned to the default model's ~125k context. Retune for your model window. - OCR tuning + security tradeoff: OCR runs on every fetched page by design (core injection defense). It is CPU-heavy and adds latency. Use
OCR_MAX_CONCURRENCY/OCR_CHUNK_SIZEto tune throughput. Reducing OCR coverage is faster but deliberately weakens the defense. - Search engines / CAPTCHA:
keep_onlyisbing,duckduckgo,brave,qwant,github(+ customstackexchangetargeting stackoverflow.com). Google/startpage are intentionally excluded to avoid CAPTCHA/rate-limit issues.unresponsive_enginesreports per-engine CAPTCHA/rate-limit; transient failures are retried with exponential backoff + jitter, CAPTCHA is surfaced and not retried. - Engine/site targeting: pass
enginesto force a source set, or usesite:operators in queries for domain targeting. Ifenginesis omitted, mode-based defaults are used. secret_key: each deployment should generate its own SearXNGsecret_key(openssl rand -hex 32);setup.shdoes this automatically. Never commit a real key.- Local-first / privacy: SearXNG is internal to the Docker network and not exposed to the host; the agent binds to
127.0.0.1. Keep your filter LLM local if you do not want fetched content sent to a third party. - Hardware: plan for CPU headroom for parallel OCR plus host/GPU capacity for the local LLM.
- Lessons from Defending Gemini Against Indirect Prompt Injections — Google DeepMind, May 2025
- The Attacker Moves Second — OpenAI/Anthropic/DeepMind, Oct 2025
- CaMeL: Capability-based Access Control for LLMs — DeepMind/ETH Zurich, 2025
- OWASP Top 10 for LLM Applications 2025
- Agents Rule of Two — Meta, Oct 2025
Apache 2.0