Conversation
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>
There was a problem hiding this comment.
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
| 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)); |
There was a problem hiding this comment.
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>
| 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) |
There was a problem hiding this comment.
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>
| 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. |
There was a problem hiding this comment.
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>
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
lib/card-html.tsruns DOMPurify with the HTML profile plus two hooks:src,srcset,poster,backgroundand similar) are dropped unless they resolve to the app's own origin. Inline image, video and audio data URLs are allowed.@import, a remoteurl(), or a quoted absolute URL. That last rule coversimage-set()and custom properties.AgentCard, the only place card HTML is injected, so cards already in the database are covered too.next.config.ts): images, media, fonts and requests are limited to'self'; frames and objects are blocked, andbase-uriis'none'.script-srcstill 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.dompurify3.4.15.Testing
npm run lintshows only the one warning that was already there.npm run buildpasses.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:A realistic card with styles, local images,
srcset, video,detailsand 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
app/agency.tsxandapp/api/ideas/route.ts. The two merge cleanly in either order.🤖 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
lib/card-html.tsruns DOMPurify (3.4.15): URL attributes must point at the app's own origin, and CSS is removed if it contains@import, a remoteurl(), or a quoted absolute URL.AgentCard, so cards already in the database are covered.'self'and blocks frames and objects; an explicit/rule is needed because/:path*misses the root page.script-srckeeps'unsafe-inline'for the app's own scripts, so the sanitizer stays the boundary for injected script.Ingest check
Written for commit b5555e8. Summary will update on new commits.