What is Carapace
Carapace is a safe HTML/CSS/JS renderer for security researchers, written in Rust. Point it at a hostile URL and it fetches, sanitises, statically analyses, and renders the page to a PNG — while routing every outbound network request through a local logging proxy that rejects every connection. This lets dynamic overlays (ClickFix, SocGholish, ClearFake, wallet drainers) execute and become visible in the screenshot, revealing the actual attack UI rather than a blank page — without a single byte reaching the attacker's infrastructure.
How It Works
Every render runs the same eight-stage pipeline:
- Fetch — a hardened Rust HTTP client (reqwest, HTTP/2) fetches the URL with SSRF protection and decompression-bomb limits.
- Parse & sanitise — the HTML is scrubbed:
<script>tags, event handlers,javascript:URIs, anddata:URLs are stripped before the page reaches the browser. - Static JS analysis — all collected script content is walked with an AST analyser (OXC) before removal — detecting eval, obfuscation, exfiltration calls, DOM sinks, clipboard hijacks, and sandbox-evasion probes.
- JS sandbox — for framework-driven pages (React, Vue, Angular…), scripts run in an isolated rquickjs runtime; network APIs are shimmed to log and block all outbound calls.
- Inline resources — external stylesheets and images are fetched and inlined as data URIs; external
url()references in CSS are blocked. - Render — a self-contained HTML file is handed to Chromium headless with JavaScript enabled and all network requests routed through a local logging proxy that records every URL and immediately rejects the connection.
- Threat report — findings from static analysis, the JS sandbox, the CSS overlay detector, the post-render DOM diff, and the network intercept log are collected into a JSON report.
- Annotation — a verdict badge is composited onto every screenshot showing the domain, risk score, verdict label, and scan timestamp.
Security Model
- All outbound connections from Chromium are routed through a local logging proxy that immediately rejects every connection — no data ever leaves the machine. Every attempted URL is recorded as an
INTERCEPTED_REQUESTfinding. --disable-blink-features=AutomationControlledsuppresses thenavigator.webdriverflag so evasive scripts execute their real attack path rather than a clean scanner path.--virtual-time-budget=5000lets JS timers fire for up to 5 seconds before capture, catching attacks that delay their overlay to evade scanners.--run-all-compositor-stages-before-drawensures layout, paint, and compositing complete before the PNG is captured, preventing partial blank renders.- Remote fonts are blocked; CSS
@import, externalurl(), and@font-faceare stripped before injection. - The HTML sanitiser removes every
on*attribute,<script>,<iframe>,<object>,<embed>, and<form>before the page is handed to Chromium. - SSRF protection blocks private, loopback, and link-local IP ranges at both URL-validation and DNS-resolution time.
- The rquickjs sandbox shims
fetch,XMLHttpRequest, andWebSocket— all network calls are logged and blocked. - Docker: runs as a non-root user (uid 1000) with all Linux capabilities dropped. The
wkhtmltoimagefallback uses--disable-javascript.
Quick Start
Docker (recommended)
Build once:
docker build -t carapace:latest .
Render a URL to PNG:
docker run --rm --cap-drop=ALL --security-opt no-new-privileges:true \ -v "/tmp:/output" carapace:latest render https://example.com -o /output/render.png
Threat report only (no image):
docker run --rm --cap-drop=ALL --security-opt no-new-privileges:true \ -v "/tmp:/output" carapace:latest render https://example.com --output-format json -o /output/report.json
Start the HTTP API server:
docker run --rm --cap-drop=ALL --security-opt no-new-privileges:true \ -e CARAPACE_API_KEY=s3cr3t -p 8080:8080 carapace:latest serve --port 8080
Native (development)
cargo build --release ./target/release/carapace render https://example.com -o output.png ./target/release/carapace render https://example.com --output-format json -o report.json ./target/release/carapace serve --port 8080
CLI Reference
render
carapace render <URL> -o <FILE> [OPTIONS]
| Flag | Default | Description |
|---|---|---|
-o, --output <FILE> | required | Output file path |
--output-format <FMT> | png | png or json |
--width <PX> | 1280 | Viewport width in pixels |
--height <PX> | 800 | Viewport height (0 = full-page capture) |
--mobile-screenshot | off | Capture a second screenshot at 375×844 (iPhone viewport) |
--mobile-ua | off | Use an iPhone/Safari UA — reveals pages that cloak content by User-Agent |
--android-ua | off | Use an Android/Chrome UA (takes precedence over --mobile-ua) |
--timeout <SECS> | 30 | Request timeout |
--max-size <SIZE> | 10MB | Max response size (5MB, 500KB, …) |
--max-redirects <N> | 5 | Max redirect hops |
--block-private-ips | on | Block private/loopback/link-local IP ranges (SSRF protection) |
--https-only | off | Reject plain HTTP URLs |
--no-assets | off | Skip fetching sub-resources (images, stylesheets, fonts) |
--no-browser | off | Use the built-in Rust renderer instead of Chromium |
--no-js-sandbox | off | Skip the rquickjs runtime (static analysis only) |
--threat-report | on | Write <output>.threat.json alongside the render |
-v, --verbose | off | Enable debug logging |
serve
carapace serve [OPTIONS]
| Flag | Default | Description |
|---|---|---|
--port <PORT> | 8080 | Port to listen on |
--host <HOST> | 0.0.0.0 | Bind address |
--api-key <KEY> | none | Require X-API-Key header (also CARAPACE_API_KEY env var) |
--max-concurrent <N> | 4 | Max parallel render jobs |
--block-private-ips | on | Block private/loopback IP ranges in submitted URLs |
--https-only | off | Reject plain HTTP URLs submitted to the API |
--timeout <SECS> | 30 | Per-request fetch timeout |
-v, --verbose | off | Enable debug logging |
HTTP API
GET /health
curl http://localhost:8080/health
{ "status": "ok", "version": "0.2.3" }
POST /render
curl -s -X POST http://localhost:8080/render \
-H "Content-Type: application/json" -H "X-API-Key: s3cr3t" \
-d '{"url":"https://example.com","format":"png"}' | jq -r .output | base64 -d > render.png
Request body:
{
"url": "https://example.com",
"format": "png",
"width": 1280,
"height": 800,
"no_assets": false,
"no_browser": false,
"no_js_sandbox": false,
"mobile_screenshot": false,
"mobile_ua": false,
"android_ua": false,
"max_size": null
}
Response:
{
"url": "https://example.com",
"format": "png",
"output": "<base64-encoded PNG>",
"mobile_output": "<base64 PNG, or null if mobile_screenshot was false>",
"content_type": "image/png",
"threat_report": { ... }
}
output is null when format is "json" — the threat report is the entire response. mobile_output is a second PNG at 375×844, null unless mobile_screenshot: true was sent.
POST /analyse
Runs OXC static JS analysis on a URL or raw content without Chromium. Use this for direct .js script URLs — feeding raw JS to /render hangs Chromium because async JS never resolves in a blank-page context.
curl -s -X POST http://localhost:8080/analyse \
-H "Content-Type: application/json" -H "X-API-Key: s3cr3t" \
-d '{"url":"https://example.com/app.js"}' | jq .threat_report
Request body — either url or content must be provided (content wins if both are given):
{ "url": "https://example.com/app.js", "content": null, "max_size": null, "source_name": null }
For files larger than 512 KB, analysis runs on overlapping 256 KB chunks (32 KB overlap) to catch patterns split at a boundary. The response has the same shape as /render with output: null; only threat_report is populated.
Threat Report
Every render produces a threat report. From the CLI it is written to <output>.threat.json; via the API it is returned inline as threat_report.
{
"url": "https://example.com",
"timestamp": "2026-04-18T09:00:00Z",
"framework_detected": "Unknown",
"tech_stack": [],
"risk_score": 0,
"render_skipped": false,
"render_blank": false,
"blank_ratio": 0.0,
"render_mode": "live",
"rendered_html": "",
"normalized_scripts": [],
"decoded_payloads": [],
"html_flags": [],
"js_flags": [],
"blocked_network": [],
"flags": []
}
risk_score— 0–100. Static JS analysis, HTML sanitisation, CSS overlay detection, post-render DOM diff, runtime network interception, drive-by detection, QR-code (quishing) decoding, and Browser-in-the-Browser detection all contribute.flags— the deduplicated primary findings list.html_flags/js_flagsare the raw per-occurrence lists (used for accurate volume scoring);blocked_networklists every URL JavaScript tried to reach at runtime (all rejected).render_mode—"live","offline_fallback", or"offline".render_blank/blank_ratioflag a screenshot that came back effectively all-white after the retry ladder.rendered_html— the post-JS DOM of a live render (capped), so callers can re-analyse what the browser actually rendered (service-worker-gated content, JS-rendered SPAs).normalized_scripts(Tier-0 deobfuscation) anddecoded_payloads(Tier-2, captured from eval/Function/document.write sinks) expose the cleartext behind obfuscation.render_skipped—truewhen the content-type was nottext/html; the render pipeline is bypassed and OXC analysis runs on the raw body (e.g. a.jsURL to/analyse).
Flag Codes
Findings are emitted as stable codes. The full set, grouped by detector:
JavaScript — static & runtime
| Code | Description |
|---|---|
JS_EVAL_DETECTED | eval() call detected |
JS_FUNCTION_CONSTRUCTOR | new Function("...") — dynamic code from a string literal |
JS_FUNCTION_CONSTRUCTOR_DYNAMIC | new Function(expr) — body assembled at runtime; not statically readable |
BASE64_OBFUSCATION | atob() decoding a string literal (decoded value in evidence) |
HEX_OBFUSCATION | High-density \xNN hex escape sequences |
OBFUSCATION_UNRESOLVED | Obfuscated code that could not be statically resolved |
INNER_HTML_MUTATION | Assignment to innerHTML / outerHTML / insertAdjacentHTML |
DOCUMENT_WRITE | document.write() call |
TIMER_STRING_EXEC | setTimeout / setInterval called with a string argument |
WEBSOCKET_ATTEMPT | new WebSocket() constructor |
REDIRECT_ATTEMPT | Assignment to window.location or similar |
COOKIE_ACCESS | Write to document.cookie |
CRYPTO_WALLET_API | Wallet RPC method invoked (e.g. eth_sendTransaction) — drainer behaviour |
ETHERHIDING_ONCHAIN_READ | eth_call on-chain read with no wallet — ClearFake EtherHiding C2 |
CLIENTSIDE_DECRYPTED_PAGE | AES-GCM client-side page decryption injected into the DOM (EvilTokens ghost code) |
Overlays & ClickFix
| Code | Description |
|---|---|
CLIPBOARD_HIJACK | navigator.clipboard.writeText() or a copy handler wrote to the system clipboard |
CLIPBOARD_HIJACK_CLICKFIX | Clipboard write contained a shell command — confirmed ClickFix paste-and-run |
CSS_OVERLAY_INJECTED | Fullscreen position:fixed overlay in static CSS — ClickFix / SocGholish signature |
DYNAMIC_OVERLAY_INJECTED | Viewport-spanning element in the post-JS DOM but absent from static HTML |
DYNAMIC_OVERLAY_INJECTED_CLICKFIX | JS-injected overlay confirmed alongside clipboard hijack — full ClickFix chain |
BITB_FAKE_WINDOW | Critical — Browser-in-the-Browser: fake window frame rendering another brand's login URL above a password field |
QR_CODE_URL | URL payload decoded from a QR code in page images or the screenshot (quishing) |
Sandbox evasion (headless-detection probes)
| Code | Description |
|---|---|
SANDBOX_EVASION_WEBDRIVER | navigator.webdriver read |
SANDBOX_EVASION_HEADLESS_STRING | Headless identifier string (HeadlessChrome, PhantomJS, $cdc_) |
SANDBOX_EVASION_SCREEN_PROBE | window.outerHeight / outerWidth read |
SANDBOX_EVASION_PLUGINS_PROBE | navigator.plugins read |
SANDBOX_EVASION_CHROME_RUNTIME | window.chrome / chrome.runtime read |
SANDBOX_EVASION_FOCUS_PROBE | document.hasFocus() call |
SANDBOX_EVASION_CANVAS_FINGERPRINT | WebGL UNMASKED_RENDERER access — GPU fingerprinting |
SANDBOX_EVASION_LANGUAGES | navigator.languages read (empty in headless) |
SANDBOX_EVASION_NOTIFICATION | Notification.permission read |
SANDBOX_EVASION_HARDWARE_FINGERPRINT | navigator.deviceMemory / hardwareConcurrency read |
HTML sanitiser, network & fetcher
| Code | Description |
|---|---|
BLOCKED_ELEMENT_SCRIPT | <script> tag removed before render |
BLOCKED_ELEMENT_IFRAME | <iframe> tag removed before render |
BLOCKED_ELEMENT_OTHER | Other disallowed element (<object>, <embed>, <form>, …) removed |
EVENT_HANDLER_STRIPPED | Inline on* event handler removed |
JAVASCRIPT_URL_STRIPPED | javascript: URI or other blocked attribute removed |
META_REDIRECT_STRIPPED | <meta http-equiv="refresh"> redirect removed |
INTERCEPTED_REQUEST | URL Chromium attempted at runtime (all blocked); list in evidence |
NETWORK_ATTEMPT_BLOCKED | fetch / XHR / WebSocket attempted inside the rquickjs sandbox and blocked |
DRIVE_BY_DOWNLOAD | Automatic file download intercepted (executables/archives only) |
Tech Detection
Carapace fingerprints the technology stack from the pre-sanitisation DOM — before custom elements and
framework attributes are stripped — and records it under tech_stack. Coverage includes React, Vue,
Angular, Svelte, Next.js, Nuxt, HTMX, Alpine.js, Livewire, Tailwind CSS, Bootstrap, Bulma, shadcn/ui, WordPress,
Drupal, Joomla, Shopify, Magento, Wix, Squarespace, jQuery, Lodash, Moment.js, Axios, Socket.io, and more.
Renderer Fallback
The primary render path is Chromium headless with JavaScript enabled and the logging network proxy. If Chromium
is unavailable, Carapace falls back to wkhtmltoimage with JavaScript disabled. Pass --no-browser
to force the built-in Rust renderer (tiny-skia + taffy layout) — approximate, supporting the basic box model,
flexbox, and inline text only; use it when no headless browser is available.
Works With Insight
Carapace runs standalone, but it is also designed to drop in as the visual renderer for
Insight. Deploy it as a sidecar and point Insight at it with
CARAPACE_URL (and optionally CARAPACE_API_KEY): every Insight scan then calls Carapace's
/render, embeds the screenshot, and converts Carapace's threat flags into Insight findings under the
Renderer category. If Carapace is unavailable, the Insight scan continues normally — it is entirely
best-effort. See the Insight documentation for the integration details.
License
Carapace is released under the MIT License. See the repository for details.