Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,11 @@ Use standalone HTML/CSS and local assets. No scripts, event handlers, iframes, f
meta/base/link, inline svg/math, anchor tags, remote fonts, remote media or automatic external
requests. For navigation use an Open button; for SVG use a local image file.

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


- `data-radar-action="do"` plus `data-radar-prompt`: queues the exact shown action.
- `data-radar-action="open"` plus `data-radar-url`: view-only HTTP(S) link, with a visible ↗.
- No card-level Change, Improve or Skip. The host owns those controls and the feedback field.
Expand Down
6 changes: 5 additions & 1 deletion app/agency.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import Link from "next/link";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { cardDraftKey, keepSelectedCard, nextCardAfterRemoval } from "../lib/card-focus";
import { sanitizeCardHtml } from "../lib/card-html";
import { cardShortcut } from "../lib/card-shortcut";
import { clusterForCard, type Topic } from "../lib/card-cluster";
import { compareByImpact, impactPoints } from "../lib/rise";
Expand Down Expand Up @@ -177,7 +178,10 @@ function AgentCard({ idea, actionable, onAction, onInteraction }: { idea: Idea;
const detailsState = renderedCardIdRef.current === idea.id
? new Map(Array.from(root.querySelectorAll("details"), (detail) => [detail.querySelector("summary")?.textContent, detail.open]))
: new Map();
root.innerHTML = `<style>: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)}</style>${idea.cardHtml}`;
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

root.querySelectorAll('[data-radar-action="change"], [data-radar-action="no"]').forEach((button) => button.remove());
root.querySelectorAll("details").forEach((detail) => {
const open = detailsState.get(detail.querySelector("summary")?.textContent);
Expand Down
4 changes: 3 additions & 1 deletion app/api/ideas/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,11 @@ function canIngest(request: Request) {
return Boolean(expected) && request.headers.get("x-radar-agent-key") === expected;
}

// Early feedback for agents, not the security boundary: the host sanitizes card HTML when it
// renders (lib/card-html.ts) and the page's Content-Security-Policy blocks remote loads.
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

|| /javascript\s*:/i.test(html)
|| /@import\b/i.test(html)
|| /url\s*\(\s*["']?(?:https?:)?\/\//i.test(html)
Expand Down
Binary file added lib/card-html.ts
Binary file not shown.
24 changes: 23 additions & 1 deletion next.config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,29 @@
import type { NextConfig } from "next";

// Agent-written cards render inside this page. If card HTML ever gets past the sanitizer
// (lib/card-html.ts), the browser still only loads images, media, fonts and requests from
// this origin, so a card cannot beacon or send data elsewhere.
const contentSecurityPolicy = [
"default-src 'self'",
"script-src 'self' 'unsafe-inline'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob:",
"media-src 'self' data: blob:",
"font-src 'self' data:",
"connect-src 'self'",
"frame-src 'none'",
"object-src 'none'",
"base-uri 'none'",
"form-action 'self'",
"frame-ancestors 'none'",
].join("; ");

const nextConfig: NextConfig = {
/* config options here */
async headers() {
const headers = [{ key: "Content-Security-Policy", value: contentSecurityPolicy }];
// vinext's `/:path*` does not match the bare root, where the card feed renders.
return [{ source: "/", headers }, { source: "/:path*", headers }];
},
};

export default nextConfig;
17 changes: 17 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"card:push": "node scripts/push-card.mjs"
},
"dependencies": {
"dompurify": "3.4.15",
"drizzle-orm": "0.45.2",
"react": "19.2.6",
"react-dom": "19.2.6"
Expand Down