使用说明
WebGuard
Secure web fetching MCP server for LLM agents. Scans fetched content for prompt injection attacks before it enters the LLM context window. Malicious content is blocked entirely — zero leaked tokens.
Rust rewrite of webguard-mcp (Go). Same security model, same test suite, smaller binary, no GC pauses.
What Changed (Go → Rust)
- Binary: 6MB vs 10MB
- No garbage collector — consistent sub-millisecond scan latency
- BurntSushi's aho-corasick — ripgrep-grade pattern matching
- Streaming body reads — size limit enforced during download, not after
- Per-hop redirect SSRF validation — each redirect re-checked against blocklists
- Same 38 built-in patterns, same two-stage cascade, same SSRF protection
Install
# Homebrew (macOS/Linux)
brew install mark-liu/tap/webguard
# Cargo (from crates.io)
cargo install webguard
# From source
git clone https://github.com/mark-liu/webguard
cd webguard
cargo build --release
# Binary at target/release/webguard
Usage
# Add to Claude Code
claude mcp add webguard -s user -- /path/to/webguard
# Then use webguard_fetch in Claude Code to retrieve any URL
Architecture
Claude Code → webguard_fetch(url)
│
┌───────────┴───────────┐
│ 1. URL Validation │ SSRF prevention, scheme check
│ 2. DNS Pinning │ Resolve + validate all IPs
│ 3. HTTP Fetch │ HTTPS, streaming body, retry
│ 4. Redirect Recheck │ Re-validate SSRF on every hop
│ 5. Content Extraction │ HTML → markdown, strip scripts
│ 6. Preprocessing │ Comment extraction, entity decode,
│ │ base64/URL/hex decode, NFC normalize,
│ │ zero-width strip
│ 7. Stage 1: Patterns │ Aho-Corasick + regex (~0.3ms)
│ ↳ Category filter │ Suppress per-domain categories
│ ↳ Doc-path hints │ Auto-suppress for /docs/, /api/
│ 8. Stage 2: Heuristic │ Density, clustering, proximity
│ 9. Decision │ PASS/WARN/BLOCK based on mode
│ 10. Audit Log │ JSONL with pattern IDs + timing
└───────────────────────┘
Tools
webguard_fetch
Fetches a URL with SSRF protection and prompt injection scanning.
| Parameter | Type | Required | Description |
|---|---|---|---|
url |
string | yes | URL to fetch (http/https) |
headers |
object | no | Custom HTTP headers |
raw |
boolean | no | Return raw HTML instead of markdown |
max_chars |
number | no | Return at most N characters (chars, not bytes). Unset = server default (50000, env WEBGUARD_DEFAULT_MAX_CHARS); 0 = unlimited |
offset |
number | no | Character offset to start from (default 0). A truncated response ends with [webguard: chars A-B of TOTAL; pass offset: B to read the next window] |
On PASS: returns extracted markdown content + metadata.
On WARN (mode=warn): returns content with a warning banner + metadata.
On BLOCK (mode=block, default): returns [BLOCKED: prompt injection detected] + metadata. Zero page content leaked.
webguard_status
Returns server health: version, pattern count, mode, sensitivity, config.
webguard_report
Returns an audit report aggregated from the JSONL audit log.
| Parameter | Type | Required | Description |
|---|---|---|---|
days |
number | no | Number of days to include (default: 7) |
Test Pages
Live test pages for verifying detection: https://mark-liu.github.io/webguard/test/
| Page | Expected | Category |
|---|---|---|
| clean.html | pass | — |
| inject.html | block | instruction-override (HTML comment) |
| exfil.html | block | exfil-instruction |
| encoded.html | block | instruction-override (base64) |
| authority.html | block | authority-claim |
Classifier
Two-stage cascade — fast pattern match, then heuristic scoring only when needed.
Stage 1: Pattern Match
38 built-in patterns across 8 categories via hybrid Aho-Corasick (literals, single O(N) pass) + regex (structural patterns). Additional patterns can be loaded from external YAML files.
| Category | Patterns | Description |
|---|---|---|
| instruction-override | 7 | Attempts to override or reset prior instructions |
| prompt-marker | 6 | Fake system/instruction delimiters and chat markers |
| authority-claim | 6 | False claims of developer, admin, or elevated access |
| exfil-instruction | 5 | Data exfiltration via URLs or hidden elements |
| output-manipulation | 4 | Attempts to constrain or redirect model output |
| unicode-obfuscation | 4 | Zero-width chars, RTL overrides, Private Use Area |
| encoded-injection | 3 | Base64/eval/charcode obfuscated payloads |
| delimiter-injection | 3 | Fake prompt boundaries and role injections |
See PATTERNS.md for the full pattern list with examples and regex definitions.
Marker families never block alone
prompt-marker and delimiter-injection hits (chat-template tokens, [INST], <<SYS>>, role JSON) are routine in LLM source code, model cards and API docs. A page whose only hits are in these two families passes (the matches still appear in the audit log); a block needs at least one hit from another family, which then goes through the normal critical/scoring path.
Unicode folding
Before matching, text is folded so look-alike forms of a phrase match the plain one: Unicode space separators (NBSP, en/em spaces, ideographic space, etc.) become ASCII space, soft hyphens are removed, and NFKC maps fullwidth letters and compatibility forms to ASCII. Cyrillic and other cross-script confusables are not handled.
Category Suppression
Suppress specific pattern categories per domain to eliminate false positives:
domains:
"*.linkedin.com":
suppress: ["authority-claim"]
"interactivebrokers.com":
suppress: ["encoded-injection"]
Documentation URLs (/docs/, /api/, /reference/, /sdk/, etc.) automatically suppress exfil-instruction and encoded-injection.
Stage 2: Heuristic Scoring
Only runs when Stage 1 finds non-critical matches. Factors:
- Density: matches per 1000 chars (>2.0 = 1.2x multiplier)
- Clustering: matches within 200 chars of each other (1.5x)
- Proximity: authority-claim + instruction-override nearby (1.5x)
- Encoding penalty: decoded content matches (1.3x)
Sensitivity Levels
| Level | Threshold | Use Case |
|---|---|---|
low |
2.0 | Documentation, trusted sources |
medium |
1.0 | General browsing (default) |
high |
0.5 | Untrusted sources |
SSRF Prevention
All checks before any TCP connection:
- Private IP ranges (RFC 1918, loopback, link-local, carrier-grade NAT; see
cgnat_host_suffixesbelow for the one opt-in exception) - Cloud metadata (AWS, GCP, Azure, Alibaba, Oracle, ECS)
- Octal IP detection (
0177.0.0.01) - URL-encoded hostname rejection
@in URL authority rejection- DNS pinning (resolve once, connect to resolved IP)
- Re-validate on every redirect hop (max 5) — prevents DNS rebinding and redirect-to-internal bypasses
Configuration
~/.config/webguard-mcp/config.yaml — works with zero config (sensible defaults):
sensitivity: medium
mode: block
max_body_size: 5242880
request_timeout: 15s
patterns_dir: ""
domains:
"docs.python.org":
sensitivity: low
timeout: 30s
"*.github.com":
sensitivity: low
"*.linkedin.com":
suppress:
- authority-claim
allowlist: []
blocklist: ["*.evil.com"]
# Hostname suffixes allowed to resolve into 100.64.0.0/10 (default: none)
cgnat_host_suffixes: []
# - example-tailnet.ts.net
audit:
enabled: true
path: "" # default: ~/.local/share/webguard-mcp/audit.jsonl
cgnat_host_suffixes
Carrier-grade NAT (100.64.0.0/10) is blocked by default. Overlay networks such as Tailscale resolve their hostnames into that range, so a host whose name ends in a listed suffix may resolve there:
cgnat_host_suffixes:
- example-tailnet.ts.net
- Matches on a dot boundary:
example-tailnet.ts.netmatchesbox.example-tailnet.ts.net, neverevil-example-tailnet.ts.net. - Entries need at least three labels: list your full tailnet name, never
ts.netalone, or any tailnet's names would be reachable. - Lifts
100.64.0.0/10only. Loopback, link-local, RFC 1918, unique-local IPv6, cloud metadata IPs (including100.100.100.200) stay blocked. - Never applies to IP literals in the URL.
- Checked on the IP actually connected to, on the initial request and on every redirect hop; the connection stays pinned to the validated IP.
- All resolved addresses must pass, so a matching name that also returns a blocked address (for example an IPv6 unique-local record) is still refused.
Environment: WEBGUARD_DEFAULT_MAX_CHARS overrides the 50000-char response cap applied when a caller omits max_chars (a 25k-token MCP result is spilled to a file by the client and never reaches the model as text).
WEBGUARD_AUDIT_TAG labels every audit row the process writes ([A-Za-z0-9._-], 64 chars); webguard_report leaves tagged rows out and prints how many it skipped. Set it for replays and canaries so they do not skew block rates.
External Patterns
Add custom detection patterns by placing YAML files in the patterns_dir directory:
# patterns.d/custom.yaml
patterns:
- id: custom-001
category: instruction-override
severity: high
type: literal
value: "override all safety measures"
Development
cargo build # Build debug binary
cargo test # Run all 77 tests
cargo build --release # Build optimised binary
cargo clippy # Lint
cargo fmt --check # Format check
Project Structure
webguard/
├── src/
│ ├── main.rs # CLI entry, signal handling, MCP boot
│ ├── server.rs # MCP tool handlers (fetch/status/report)
│ ├── config.rs # YAML config, domain overrides
│ ├── audit.rs # JSONL audit logger + reader
│ ├── fetch/
│ │ ├── ssrf.rs # URL + IP validation, DNS pinning
│ │ ├── client.rs # HTTP fetch, redirect recheck, retry
│ │ └── extract.rs # HTML → markdown
│ └── classify/
│ ├── engine.rs # Two-stage orchestrator
│ ├── preprocess.rs # 8-step content preprocessing
│ ├── stage1.rs # Aho-Corasick + regex scanner
│ ├── stage2.rs # Heuristic scoring
│ ├── patterns.rs # 38 built-in patterns
│ ├── external.rs # External pattern YAML loader
│ ├── encoding.rs # Base64/URL/hex/ROT13 decode
│ └── result.rs # Result types
├── docs/test/ # GitHub Pages test pages
├── testdata/ # Test fixtures (14 JSON files)
└── patterns.d/ # External pattern directory
Related Projects
- webguard-mcp — the original Go implementation (superseded by this repo)
- mcpguard — MCP stdio proxy that scans tool results from any MCP server (Discord, Telegram, etc.) for prompt injection
- snap — MCP stdio compression proxy for Playwright snapshots
License
MIT
