Snap2Sim / docs /reviews /security-hardening.md
jasondo
Finalize docs and archive prompts
7006f4b
|
Raw
History Blame Contribute Delete
14.8 kB

A newer version of the Gradio SDK is available: 6.20.0

Upgrade

Security Hardening Review

Review date: 2026-06-14. Reviewer: Claude (Opus 4.8), for OpenAI Codex to implement.

Final submission note, 2026-06-15: this review was implemented before the Space became public under build-small-hackathon/Snap2Sim.

This review answers three questions:

  1. Are the endpoints secure?
  2. Is the UI/UX good?
  3. Should we prioritize Three.js or A-Frame for animation rendering?

Two product decisions were confirmed with the owner and shape the priorities below:

  • The Space is public now. Findings that were only mitigated by private visibility were treated as blockers and were resolved before publication.
  • Screen-only viewing; no VR/AR goal. WebXR is not required, which removes A-Frame's main structural advantage.

File references use path:line from the state of the repo at review time.


1. Endpoint Security

Architecture summary

  • app.py is the public Hugging Face Space (gradio.Server). It serves index.html at / and exposes POST /analyze_image and POST /generate_scene. The browser calls these same-origin, and the server attaches the Modal bearer token when it forwards to Modal. This split (token stays server-side) is correct and matches SECURITY.md.
  • modal_app.py exposes the GPU/inference web endpoints, each guarded by a bearer token via require_authorization.

The Modal layer is in good shape. The Space layer is the exposure.

HIGH — The Space's own /analyze_image and /generate_scene are unauthenticated (app.py:64, app.py:74)

These endpoints have no auth of their own. At review time, they were gated only by the Space being private. Because the Space is now public, anyone on the internet can POST to them unless the implemented abuse controls remain active.

  • With INFERENCE_BACKEND=local (current default), they return placeholder data — low impact but a free compute/bandwidth sink (base64 decode + PIL decode + scene build per request).
  • With INFERENCE_BACKEND=modal, every public request is proxied to Modal using the server's own bearer token, turning the Space into an open proxy that spends GPU credits on behalf of any anonymous caller. The Modal bearer token protects Modal from direct callers, but the Space is a trusted caller, so the token does not help here. This directly contradicts the stated intent in SECURITY.md that the token is "the real protection against credit-spending spam."

Recommendation (implemented before publication):

  • Add abuse controls at the Space layer that do not require leaking a secret to the browser. Options, roughly in order of preference:
    1. Server-side rate limiting / quota per client IP and a global ceiling (e.g. slowapi/limits, or a small in-process token bucket). This is the most important mitigation since the Space is intentionally token-free on the client side.
    2. A short-lived, server-issued session/CSRF-style token handed to the page on GET / and required on the POST endpoints, to stop trivial scripted abuse (not a strong control, but raises the bar).
    3. Hard caps: max upload size, max requests/min, and a circuit breaker that falls back to local placeholder mode if a Modal spend threshold is hit.
  • Consider HF Space-level protections too (HF supports gating), but do not rely on them as the only layer once public.

HIGH — Untrusted scene HTML is injected via innerHTML (index.html:545)

renderAframe() does viewport.innerHTML = sceneHtml; where sceneHtml comes from /generate_scene. In the deterministic path the HTML is built server-side with html.escape (snap2sim/aframe_scene.py:14-16, safe). But the model-generation path (generate_scene_llamacpp in modal_app.py:462) returns raw model output, extracted by parse_scene_response (snap2sim/model_io.py:49) which does no sanitization — it slices everything between <a-scene> and </a-scene> verbatim.

Because the scene is derived from a user-uploaded image, an attacker can prompt-inject the vision/scene model (text in the photo, adversarial content) into emitting markup such as <a-entity onloaded="..."> or <img src=x onerror=...>. innerHTML will not execute injected <script> tags, but it does run inline event-handler attributes and A-Frame can execute component JS — so this is a realistic stored/reflected XSS vector that becomes internet-reachable when the Space is public.

The validation in validate_analysis protects the JSON analysis path but does nothing for the free-form HTML scene path.

Recommendation: Do not inject free-form model HTML into the DOM. See §3 — the cleanest fix is to make scene rendering deterministic from the validated JSON (which is already escaped/validated) and stop returning model-authored HTML. If any model-authored HTML is ever rendered, sanitize it with an allowlist (e.g. DOMPurify with an A-Frame-aware tag/attribute allowlist) before innerHTML, and strip all on* attributes.

MEDIUM — No upload size limit or decompression-bomb hardening (app.py:88, modal_app.py:327)

_decode_image and write_payload_image do base64.b64decode(...) then Image.open(BytesIO(...)) with no cap on the base64 string length or decoded pixel count. A large or crafted image (decompression bomb) can exhaust memory on cpu-basic. PIL's default DecompressionBombWarning only warns; it does not block at typical sizes.

Recommendation:

  • Reject requests whose base64 body exceeds a sane limit (e.g. ~8–12 MB) before decoding.
  • Set Image.MAX_IMAGE_PIXELS to an explicit ceiling and catch Image.DecompressionBombError.
  • Wrap decode failures and return a clean 400 instead of a 500/stack trace.

MEDIUM — Fixed temp file paths cause cross-request collisions (modal_app.py:199, modal_app.py:342)

Both the smoke test and write_payload_image write to fixed paths (/tmp/snap2sim-request-image.jpg). Concurrent requests on the same container overwrite each other's input image, so one user could be analyzed against another user's photo. This is both a correctness and a privacy concern.

Recommendation: Use tempfile.NamedTemporaryFile/mkstemp (unique per request) and clean up in a finally.

LOW — Third-party scripts loaded without Subresource Integrity (index.html:7-13)

A-Frame (aframe.io), the Gradio client (jsdelivr), and fonts (bunny.net) are loaded from CDNs with no integrity/SRI hashes. A CDN compromise would run arbitrary JS in the (soon public) page.

Recommendation: Pin versions and add SRI hashes where the CDN supports it. Note: if A-Frame is dropped (see §3) and Three.js is bundled/self-hosted, this surface shrinks substantially.

LOW — Unused third-party dependency loaded (index.html:10-13)

The Gradio JS client is imported into window.snap2simGradioClient but never used; all calls go through a plain fetch (postJson, index.html:765). Remove the import to cut an unnecessary CDN dependency and load.

Positives (keep these)

  • Modal bearer check uses constant-time comparison (token_secrets.compare_digest, modal_app.py:113) and returns 503 when the token is unconfigured, 401 on mismatch (modal_app.py:105-115). Unauthenticated 401 was verified per AGENTS.md.
  • Token never reaches the browser; server-side attaches it (snap2sim/backend.py:62-68). Matches SECURITY.md guidance.
  • The deterministic scene builders escape text with html.escape (snap2sim/aframe_scene.py:14-16) and the analysis readout uses textContent rather than innerHTML (index.html:513-536) — no XSS on that path.
  • GitHub→HF workflow uses least-privilege permissions: contents: read and keeps HF_TOKEN in secrets (.github/workflows/sync_to_hf.yml:19-21).
  • Schema validation (snap2sim/schema.py:176) runs before scene generation.

2. UI/UX

The visual direction is strong and cohesive: a "technical cutaway / field manual" aesthetic (amber #E8A33D + cyan #5FD4D0 on dark, blueprint grid, Chakra Petch / Fira Code). The two-pane layout (viewport + readout) is clear and the loading choreography (progress bar, scan-line reveal, staggered mesh reveal, cold-start "WAKING THE WORKSHOP" message after 6.5 s) is a genuinely nice touch for HF cold starts. Drag-and-drop plus a Load button, and a play/pause control, cover the core interactions. The fallback chain (A-Frame → deterministic Three.js → "3D runtime unavailable") is thoughtful.

Issues, roughly by impact

Accessibility (MEDIUM — matters more once public):

  • Status messages (index.html:392) are not announced to screen readers. Add aria-live="polite" (and role="status") so analysis progress/errors are read out.
  • #viewport injected 3D content has no text alternative. Provide an aria-label/visually-hidden description, or rely on the readout panel as the accessible representation and mark the viewport aria-hidden.
  • Low-contrast text: --text-muted #6B7280 on the dark panel is below WCAG AA for body text (trigger, raw JSON, drop-zone hint). Nudge it lighter.
  • The file input is triggered via a <label> drop zone (index.html:382); make sure keyboard focus + Enter works to open the picker, and the Load button is already focusable (good).

Functional gaps:

  • No preview of the uploaded photo. Users lose their reference image once the cutaway renders. Show a thumbnail of the source photo in the panel — it also reinforces the "this came from my object" story for a demo.
  • playButton initial state is confusing (index.html:379): it reads "Pause" while disabled, before any scene exists. Start as disabled "Play" or hide until a scene is ready.
  • Error UX is thin: errors render as uppercased status text only (index.html:495). Add a visible retry affordance and keep the drop zone reachable so the user can try another photo without hunting for the Load button.
  • No client-side file validation: accept="image/*" only. Reject non-images and oversized files before base64 inflation (~33% size increase) and surface a friendly message. (Pairs with the server-side size cap in §1.)
  • Mobile: the layout has a breakpoint at 860 px (good), but A-Frame look-controls can capture touch/scroll. If Three.js becomes the primary renderer (see §3), use OrbitControls tuned for touch and ensure the page still scrolls. Also consider capture="environment" to offer "take a photo" on mobile.
  • No confidence visualization: confidence is shown as a number (index.html:516); a small bar/gauge would read faster and fit the instrument aesthetic.
  • First-run onboarding is just "Drop component photo." A one-line example ("try a lock, a gear, a ratchet…") lowers the cold-start barrier for judges.

Verdict: UI/UX is above-average for a hackathon and the aesthetic is a real asset. The gaps are accessibility, an uploaded-image preview, and error/retry polish — none are large, and they raise the demo from "looks great" to "feels finished."


3. Three.js vs A-Frame for Rendering

Recommendation: consolidate on Three.js as the primary (and only) renderer, driven deterministically from the validated JSON analysis. Drop the A-Frame dependency and stop returning model-authored scene HTML.

Reasoning

  1. No VR/AR goal (confirmed). A-Frame's defining advantage is declarative WebXR. Screen-only viewing removes the main reason to carry it. Without VR, A-Frame is mostly a ~1.2 MB declarative wrapper over Three.js.

  2. The Three.js path is already the more capable implementation. The "fallback" in index.html:565-755 has OrbitControls, DOM-projected part labels, staggered reveal, gear extrusion, fog/lighting, and motion handling. It is richer than the A-Frame output (snap2sim/aframe_scene.py). You'd be promoting your best renderer, not rewriting one.

  3. True "cutaway" cross-sections need clipping planes. The product is an annotated technical cutaway. Three.js exposes renderer.localClippingEnabled + material.clippingPlanes natively, which is the clean way to slice a housing and reveal internals. A-Frame has no first-class clipping-plane support. If "cutaway" is to be more than parts floating in space, Three.js is required anyway.

  4. It removes the §1 HIGH XSS vector. Rendering deterministically from validated JSON means no innerHTML of untrusted model HTML. The model already produces the structured JSON (snap2sim/schema.py), which is validated and safe; let a deterministic Three.js builder turn that JSON into geometry. This is both safer and more reliable than asking an LLM to emit well-formed scene markup.

  5. Smaller, self-hostable footprint. Three.js can be pinned/bundled and served same-origin, shrinking the CDN/SRI surface from §1.

What this means concretely for implementation

  • Keep the model's job as the JSON analysis contract (it already is, snap2sim/schema.py). The model should not author HTML/JS scenes.
  • Make generate_scene deterministic from that JSON. The browser already has buildDeterministicScene() — promote it from fallback to the main path. You can drop the server returning scene HTML entirely, or keep the server returning the validated analysis and let the browser build the scene.
  • Remove A-Frame (index.html:9, the <a-scene> injection in renderAframe, and the A-Frame branch of generate_scene_llamacpp) once the Three.js path is the default. Delete snap2sim/aframe_scene.py and the A-Frame prompt template after migration, or keep them only if you want a server-rendered static preview.
  • If you keep any model-authored HTML path, it must be sanitized (DOMPurify, strip on*) per §1.

This also simplifies the mental model: one renderer, one safe data contract, no dual A-Frame/Three.js maintenance.


Suggested Implementation Order (for Codex)

Blockers resolved before the Space went public:

  1. Add server-side rate limiting / quota + max upload size to app.py endpoints (§1 HIGH).
  2. Eliminate the untrusted-HTML innerHTML path: move to deterministic Three.js rendering from validated JSON, or sanitize (§1 HIGH + §3).
  3. Add decompression-bomb guards and unique temp files (§1 MEDIUM).

Then:

  1. Consolidate rendering on Three.js; remove A-Frame and the dead Gradio client import; add SRI/self-hosting (§3, §1 LOW).
  2. UI/UX polish: uploaded-image preview, aria-live status, contrast fix, error/retry affordance, play-button initial state (§2).

Out of scope / leave as-is: Modal bearer auth (already correct), the GitHub→HF sync workflow, the schema contract.