Safe HTML/CSS/JS renderer for security researchers

Carapace

Carapace

Carapace fetches a URL, sanitises the page, analyses the JavaScript, and renders it to a PNG — with every outbound network request intercepted and blocked. Dynamic attacks render visibly in the screenshot; nothing ever leaves the machine. A threat report is produced alongside every render.

License Rust Chromium Docker

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.

Open-source (MIT): source, issues, and releases live at github.com/DanDreadless/Carapace. Carapace is standalone, but also drops in as the visual renderer for Insight — see Works With Insight.

How It Works

Every render runs the same eight-stage pipeline:

  1. Fetch — a hardened Rust HTTP client (reqwest, HTTP/2) fetches the URL with SSRF protection and decompression-bomb limits.
  2. Parse & sanitise — the HTML is scrubbed: <script> tags, event handlers, javascript: URIs, and data: URLs are stripped before the page reaches the browser.
  3. 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.
  4. 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.
  5. Inline resources — external stylesheets and images are fetched and inlined as data URIs; external url() references in CSS are blocked.
  6. 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.
  7. 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.
  8. 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_REQUEST finding.
  • --disable-blink-features=AutomationControlled suppresses the navigator.webdriver flag so evasive scripts execute their real attack path rather than a clean scanner path.
  • --virtual-time-budget=5000 lets JS timers fire for up to 5 seconds before capture, catching attacks that delay their overlay to evade scanners.
  • --run-all-compositor-stages-before-draw ensures layout, paint, and compositing complete before the PNG is captured, preventing partial blank renders.
  • Remote fonts are blocked; CSS @import, external url(), and @font-face are 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, and WebSocket — all network calls are logged and blocked.
  • Docker: runs as a non-root user (uid 1000) with all Linux capabilities dropped. The wkhtmltoimage fallback 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]
FlagDefaultDescription
-o, --output <FILE>requiredOutput file path
--output-format <FMT>pngpng or json
--width <PX>1280Viewport width in pixels
--height <PX>800Viewport height (0 = full-page capture)
--mobile-screenshotoffCapture a second screenshot at 375×844 (iPhone viewport)
--mobile-uaoffUse an iPhone/Safari UA — reveals pages that cloak content by User-Agent
--android-uaoffUse an Android/Chrome UA (takes precedence over --mobile-ua)
--timeout <SECS>30Request timeout
--max-size <SIZE>10MBMax response size (5MB, 500KB, …)
--max-redirects <N>5Max redirect hops
--block-private-ipsonBlock private/loopback/link-local IP ranges (SSRF protection)
--https-onlyoffReject plain HTTP URLs
--no-assetsoffSkip fetching sub-resources (images, stylesheets, fonts)
--no-browseroffUse the built-in Rust renderer instead of Chromium
--no-js-sandboxoffSkip the rquickjs runtime (static analysis only)
--threat-reportonWrite <output>.threat.json alongside the render
-v, --verboseoffEnable debug logging

serve

carapace serve [OPTIONS]
FlagDefaultDescription
--port <PORT>8080Port to listen on
--host <HOST>0.0.0.0Bind address
--api-key <KEY>noneRequire X-API-Key header (also CARAPACE_API_KEY env var)
--max-concurrent <N>4Max parallel render jobs
--block-private-ipsonBlock private/loopback IP ranges in submitted URLs
--https-onlyoffReject plain HTTP URLs submitted to the API
--timeout <SECS>30Per-request fetch timeout
-v, --verboseoffEnable 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_flags are the raw per-occurrence lists (used for accurate volume scoring); blocked_network lists every URL JavaScript tried to reach at runtime (all rejected).
  • render_mode"live", "offline_fallback", or "offline". render_blank / blank_ratio flag 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) and decoded_payloads (Tier-2, captured from eval/Function/document.write sinks) expose the cleartext behind obfuscation.
  • render_skippedtrue when the content-type was not text/html; the render pipeline is bypassed and OXC analysis runs on the raw body (e.g. a .js URL to /analyse).

Flag Codes

Findings are emitted as stable codes. The full set, grouped by detector:

JavaScript — static & runtime

CodeDescription
JS_EVAL_DETECTEDeval() call detected
JS_FUNCTION_CONSTRUCTORnew Function("...") — dynamic code from a string literal
JS_FUNCTION_CONSTRUCTOR_DYNAMICnew Function(expr) — body assembled at runtime; not statically readable
BASE64_OBFUSCATIONatob() decoding a string literal (decoded value in evidence)
HEX_OBFUSCATIONHigh-density \xNN hex escape sequences
OBFUSCATION_UNRESOLVEDObfuscated code that could not be statically resolved
INNER_HTML_MUTATIONAssignment to innerHTML / outerHTML / insertAdjacentHTML
DOCUMENT_WRITEdocument.write() call
TIMER_STRING_EXECsetTimeout / setInterval called with a string argument
WEBSOCKET_ATTEMPTnew WebSocket() constructor
REDIRECT_ATTEMPTAssignment to window.location or similar
COOKIE_ACCESSWrite to document.cookie
CRYPTO_WALLET_APIWallet RPC method invoked (e.g. eth_sendTransaction) — drainer behaviour
ETHERHIDING_ONCHAIN_READeth_call on-chain read with no wallet — ClearFake EtherHiding C2
CLIENTSIDE_DECRYPTED_PAGEAES-GCM client-side page decryption injected into the DOM (EvilTokens ghost code)

Overlays & ClickFix

CodeDescription
CLIPBOARD_HIJACKnavigator.clipboard.writeText() or a copy handler wrote to the system clipboard
CLIPBOARD_HIJACK_CLICKFIXClipboard write contained a shell command — confirmed ClickFix paste-and-run
CSS_OVERLAY_INJECTEDFullscreen position:fixed overlay in static CSS — ClickFix / SocGholish signature
DYNAMIC_OVERLAY_INJECTEDViewport-spanning element in the post-JS DOM but absent from static HTML
DYNAMIC_OVERLAY_INJECTED_CLICKFIXJS-injected overlay confirmed alongside clipboard hijack — full ClickFix chain
BITB_FAKE_WINDOWCritical — Browser-in-the-Browser: fake window frame rendering another brand's login URL above a password field
QR_CODE_URLURL payload decoded from a QR code in page images or the screenshot (quishing)

Sandbox evasion (headless-detection probes)

CodeDescription
SANDBOX_EVASION_WEBDRIVERnavigator.webdriver read
SANDBOX_EVASION_HEADLESS_STRINGHeadless identifier string (HeadlessChrome, PhantomJS, $cdc_)
SANDBOX_EVASION_SCREEN_PROBEwindow.outerHeight / outerWidth read
SANDBOX_EVASION_PLUGINS_PROBEnavigator.plugins read
SANDBOX_EVASION_CHROME_RUNTIMEwindow.chrome / chrome.runtime read
SANDBOX_EVASION_FOCUS_PROBEdocument.hasFocus() call
SANDBOX_EVASION_CANVAS_FINGERPRINTWebGL UNMASKED_RENDERER access — GPU fingerprinting
SANDBOX_EVASION_LANGUAGESnavigator.languages read (empty in headless)
SANDBOX_EVASION_NOTIFICATIONNotification.permission read
SANDBOX_EVASION_HARDWARE_FINGERPRINTnavigator.deviceMemory / hardwareConcurrency read

HTML sanitiser, network & fetcher

CodeDescription
BLOCKED_ELEMENT_SCRIPT<script> tag removed before render
BLOCKED_ELEMENT_IFRAME<iframe> tag removed before render
BLOCKED_ELEMENT_OTHEROther disallowed element (<object>, <embed>, <form>, …) removed
EVENT_HANDLER_STRIPPEDInline on* event handler removed
JAVASCRIPT_URL_STRIPPEDjavascript: URI or other blocked attribute removed
META_REDIRECT_STRIPPED<meta http-equiv="refresh"> redirect removed
INTERCEPTED_REQUESTURL Chromium attempted at runtime (all blocked); list in evidence
NETWORK_ATTEMPT_BLOCKEDfetch / XHR / WebSocket attempted inside the rquickjs sandbox and blocked
DRIVE_BY_DOWNLOADAutomatic 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.