Skip to content

Repository files navigation

search-sanitizer

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

The Problem

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.

Pipeline

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

OCR Settings (optimized for maximum accuracy)

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

Red Team Results

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

Quick Start

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.sh

This 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 .env
  • LLM_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_CONCURRENCY
  • OCR_CHUNK_SIZE
  • SEARXNG_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

API Endpoints

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.

Architecture

┌─────────────────────────────────────────────────────────┐
│  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)                              │
└──────────────────────────────────────────────────────────┘

POST /search Schema

Request (SearchRequest)

  • original_query (string, required, min length 3) — user's natural-language question used for filtering/grounding.
  • queries (string[], optional, max 8) — pre-built search queries; if omitted, derived from original_query.
  • mode ("code" | "concepts" | "mixed", default "mixed").
  • max_results (int, 1–15, default 6).
  • engines (string[], optional, max 8) — 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 from mode.
{
  "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"]
}

Response (SearchResponse)

  • original_query
  • search_queries_used (string[])
  • mode
  • results_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"
    }
  ]
}

Search behavior

  • Explicit engine targeting: pass engines in POST /search to target a caller-selected subset of the allowed engines.
  • Mode → engine routing defaults:
    • codegithub, stackexchange, bing
    • conceptsbing, duckduckgo, brave, qwant
    • mixed → all six (bing, duckduckgo, brave, qwant, github, stackexchange)
  • Coverage transparency: unresponsive_engines reports 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^attempt seconds 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 in unresponsive_engines.
  • Engine set + CAPTCHA avoidance: SearXNG is constrained via keep_only to bing, duckduckgo, brave, qwant, github, plus a custom stackexchange engine targeting stackoverflow.com. Engines known to trigger aggressive self-hosted CAPTCHA/rate-limit behavior (for example google and startpage) are removed entirely.

How It Compares

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

Known Limitations

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.

Notes for Forkers

Built for open-weight LLMs

  • 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, and FILTER_MODEL at whatever stack you run.
  • FILTER_SYSTEM in agent.py is 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, edit FILTER_SYSTEM directly.

Originally OpenCode-driven, but framework-agnostic

  • 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[] } (queries is 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).

Config knobs worth knowing

  • Context/token budgets: TOTAL_CONTEXT, CONTENT_BUDGET_TOKENS, OUTPUT_BUDGET_TOKENS, plus MAX_PAGES_TO_FETCH and MAX_PAGE_CHARS in agent.py are 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_SIZE to tune throughput. Reducing OCR coverage is faster but deliberately weakens the defense.
  • Search engines / CAPTCHA: keep_only is bing, duckduckgo, brave, qwant, github (+ custom stackexchange targeting stackoverflow.com). Google/startpage are intentionally excluded to avoid CAPTCHA/rate-limit issues. unresponsive_engines reports per-engine CAPTCHA/rate-limit; transient failures are retried with exponential backoff + jitter, CAPTCHA is surfaced and not retried.
  • Engine/site targeting: pass engines to force a source set, or use site: operators in queries for domain targeting. If engines is omitted, mode-based defaults are used.
  • secret_key: each deployment should generate its own SearXNG secret_key (openssl rand -hex 32); setup.sh does 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.

References

License

Apache 2.0

About

OCR-based content sanitization for LLM search pipelines. Strips prompt injections, invisible Unicode attacks, and exfiltration channels before they reach your model.

Resources

Stars

4 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages