Skip to content

Sanitize card HTML at render and add a Content-Security-Policy - #22

Open
rubysolo wants to merge 1 commit into
browser-use:mainfrom
rubysolo:fix/card-html-sanitizer
Open

rubysolo wants to merge 1 commit into
browser-use:mainfrom
rubysolo:fix/card-html-sanitizer

Conversation

@rubysolo

@rubysolo rubysolo commented Sep 15, 2026

Copy link
Copy Markdown

Summary

Card HTML is written by agents that read email, Slack and web pages, so injected source content can shape it. The ingest check was a regex, and several common HTML and CSS forms got past it. A card could then load remote resources or run script when it rendered, and no Content-Security-Policy backed the check. This PR sanitizes cards at render time and adds a CSP.

Changes

  • Sanitizer: new lib/card-html.ts runs DOMPurify with the HTML profile plus two hooks:
    • URL attributes (src, srcset, poster, background and similar) are dropped unless they resolve to the app's own origin. Inline image, video and audio data URLs are allowed.
    • CSS is removed if it contains @import, a remote url(), or a quoted absolute URL. That last rule covers image-set() and custom properties.
    • Anchors, forms, frames, object/embed and link/meta/base elements are removed.
  • Where it runs: in AgentCard, the only place card HTML is injected, so cards already in the database are covered too.
  • CSP (next.config.ts): images, media, fonts and requests are limited to 'self'; frames and objects are blocked, and base-uri is 'none'. script-src still allows inline scripts because the app's own inline scripts need that, so the sanitizer is what removes injected script. There is an explicit / rule because vinext's /:path* doesn't match the root page.
  • Ingest check: it now also catches event handlers with no whitespace before them. A comment marks it as early feedback for agents, not the security boundary.
  • Docs: the README notes that remote assets are stripped.
  • Dependency: adds dompurify 3.4.15.

Testing

  • npm run lint shows only the one warning that was already there. npm run build passes.

  • In headless Chrome, rendered 44 bypass-style payloads with a local listener recording outbound requests. They covered unquoted and entity-encoded URLs, srcset, media elements, CSS escapes, image-set(), handlers without whitespace and mutation XSS:

    Mode Outbound requests Script executions
    Raw render 32 5
    Sanitizer only 0 0
    CSP only 0 5
  • A realistic card with styles, local images, srcset, video, details and action buttons came through unchanged.

  • In the running app, a hostile card written straight into the database made no outbound requests, ran no script and caused no CSP violations. Its action button still queued a job.

Notes

🤖 Generated with Claude Code


Summary by cubic

Prevents agent-written cards from loading remote resources or running injected script. Card HTML is now sanitized at render time and the page sends a Content-Security-Policy, closing bypasses in the ingest regex.

Sanitizer and CSP

  • New lib/card-html.ts runs DOMPurify (3.4.15): URL attributes must point at the app's own origin, and CSS is removed if it contains @import, a remote url(), or a quoted absolute URL.
  • Anchors, forms, frames, object/embed, and link/meta/base elements are dropped.
  • The sanitizer runs in AgentCard, so cards already in the database are covered.
  • The CSP limits images, media, fonts and requests to 'self' and blocks frames and objects; an explicit / rule is needed because /:path* misses the root page.
  • script-src keeps 'unsafe-inline' for the app's own scripts, so the sanitizer stays the boundary for injected script.

Ingest check

  • The regex now also catches event handlers without leading whitespace, but remains early feedback for agents rather than the security boundary.

Written for commit b5555e8. Summary will update on new commits.

Review in cubic

Agent-written card HTML was filtered by a regex that was easy to bypass, so a
card shaped by injected email or Slack content could load remote resources or
run script. Cards now pass through DOMPurify with URL and CSS checks before
they render, which also covers cards already stored. A CSP limits images,
media, fonts and requests to the app's origin as a backstop. The ingest regex
now also catches handlers without leading whitespace.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 issues found across 7 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="README.md">

<violation number="1" location="README.md:307">
P3: The CSP description omits its explicit `data:`/`blob:` exceptions, making “to the app itself” inaccurate for images, media, and fonts. Mention those allowed inline URL schemes so card authors understand which non-network assets remain supported.</violation>
</file>

<file name="app/api/ideas/route.ts">

<violation number="1" location="app/api/ideas/route.ts:43">
P2: Cards containing ordinary text like `"/onboarding="` are rejected because this whole-document regex treats quote- or slash-prefixed words ending in `=` as event handlers. Restrict the early check to actual tag attributes (or remove this heuristic) and leave the render-time sanitizer as the security boundary.</violation>
</file>

<file name="app/agency.tsx">

<violation number="1" location="app/agency.tsx:184">
P1: When a hostile card is opened from Done, `data-radar-url` survives sanitization and DoneList passes it directly to `window.open`, so `javascript:` or `data:` URLs can execute code. Enforce HTTP(S) in that callback or reject non-HTTP(S) `data-radar-url` values during sanitization.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Fix all with cubic | Re-trigger cubic

Comment thread app/agency.tsx
const hostStyle = document.createElement("style");
hostStyle.textContent = `:host{display:block;font-family:inherit}*{box-sizing:border-box}[data-radar-action]{min-height:44px;cursor:pointer}[data-radar-action="open"]{display:inline-flex!important;align-items:center;gap:.38em}[data-radar-action="open"]::after{content:"↗";font-size:.8em;line-height:1;opacity:.68;transform:translateY(-.08em)}`;
// Agent-written HTML may carry injected content from the sources it read.
root.replaceChildren(hostStyle, sanitizeCardHtml(idea.cardHtml));

@cubic-dev-ai cubic-dev-ai Bot Sep 15, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When a hostile card is opened from Done, data-radar-url survives sanitization and DoneList passes it directly to window.open, so javascript: or data: URLs can execute code. Enforce HTTP(S) in that callback or reject non-HTTP(S) data-radar-url values during sanitization.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/agency.tsx, line 184:

<comment>When a hostile card is opened from Done, `data-radar-url` survives sanitization and DoneList passes it directly to `window.open`, so `javascript:` or `data:` URLs can execute code. Enforce HTTP(S) in that callback or reject non-HTTP(S) `data-radar-url` values during sanitization.</comment>

<file context>
@@ -177,7 +178,10 @@ function AgentCard({ idea, actionable, onAction, onInteraction }: { idea: Idea;
+    const hostStyle = document.createElement("style");
+    hostStyle.textContent = `:host{display:block;font-family:inherit}*{box-sizing:border-box}[data-radar-action]{min-height:44px;cursor:pointer}[data-radar-action="open"]{display:inline-flex!important;align-items:center;gap:.38em}[data-radar-action="open"]::after{content:"↗";font-size:.8em;line-height:1;opacity:.68;transform:translateY(-.08em)}`;
+    // Agent-written HTML may carry injected content from the sources it read.
+    root.replaceChildren(hostStyle, sanitizeCardHtml(idea.cardHtml));
     root.querySelectorAll('[data-radar-action="change"], [data-radar-action="no"]').forEach((button) => button.remove());
     root.querySelectorAll("details").forEach((detail) => {
</file context>
Fix with cubic

Comment thread app/api/ideas/route.ts
function unsafeHtml(html: string) {
return /<\s*(script|iframe|object|embed|form|meta|base|link|svg|math|a)\b/i.test(html)
|| /\son[a-z]+\s*=/i.test(html)
|| /[\s"'/]on[a-z]+\s*=/i.test(html)

@cubic-dev-ai cubic-dev-ai Bot Sep 15, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Cards containing ordinary text like "/onboarding=" are rejected because this whole-document regex treats quote- or slash-prefixed words ending in = as event handlers. Restrict the early check to actual tag attributes (or remove this heuristic) and leave the render-time sanitizer as the security boundary.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/api/ideas/route.ts, line 43:

<comment>Cards containing ordinary text like `"/onboarding="` are rejected because this whole-document regex treats quote- or slash-prefixed words ending in `=` as event handlers. Restrict the early check to actual tag attributes (or remove this heuristic) and leave the render-time sanitizer as the security boundary.</comment>

<file context>
@@ -36,9 +36,11 @@ function canIngest(request: Request) {
 function unsafeHtml(html: string) {
   return /<\s*(script|iframe|object|embed|form|meta|base|link|svg|math|a)\b/i.test(html)
-    || /\son[a-z]+\s*=/i.test(html)
+    || /[\s"'/]on[a-z]+\s*=/i.test(html)
     || /javascript\s*:/i.test(html)
     || /@import\b/i.test(html)
</file context>
Fix with cubic

Comment thread README.md
The ingest check only catches common mistakes. The host sanitizes every card when it renders:
it strips scripts, event handlers, forbidden elements, and URLs or CSS that point at another origin.
The page also sends a Content-Security-Policy that limits images, media, fonts and requests to the
app itself. A remote asset simply disappears, so keep assets local.

@cubic-dev-ai cubic-dev-ai Bot Sep 15, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The CSP description omits its explicit data:/blob: exceptions, making “to the app itself” inaccurate for images, media, and fonts. Mention those allowed inline URL schemes so card authors understand which non-network assets remain supported.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At README.md, line 307:

<comment>The CSP description omits its explicit `data:`/`blob:` exceptions, making “to the app itself” inaccurate for images, media, and fonts. Mention those allowed inline URL schemes so card authors understand which non-network assets remain supported.</comment>

<file context>
@@ -301,6 +301,11 @@ Use standalone HTML/CSS and local assets. No scripts, event handlers, iframes, f
+The ingest check only catches common mistakes. The host sanitizes every card when it renders:
+it strips scripts, event handlers, forbidden elements, and URLs or CSS that point at another origin.
+The page also sends a Content-Security-Policy that limits images, media, fonts and requests to the
+app itself. A remote asset simply disappears, so keep assets local.
+
 - `data-radar-action="do"` plus `data-radar-prompt`: queues the exact shown action.
</file context>
Fix with cubic

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant