Skip to content

web: add inline Adaptive Card rendering in chat#42307

Open
VikrantSingh01 wants to merge 4 commits intoopenclaw:mainfrom
VikrantSingh01:feat/adaptive-cards-web-rendering
Open

web: add inline Adaptive Card rendering in chat#42307
VikrantSingh01 wants to merge 4 commits intoopenclaw:mainfrom
VikrantSingh01:feat/adaptive-cards-web-rendering

Conversation

@VikrantSingh01
Copy link
Copy Markdown

@VikrantSingh01 VikrantSingh01 commented Mar 10, 2026

Summary

Adds inline Adaptive Card rendering to the Web UI. When a chat message contains <!--adaptive-card--> markers, the card is parsed and rendered natively inline in the chat bubble using a lightweight Lit-compatible renderer — covering the full v1.6 element set generated by adaptive-cards-mcp.

New files

  • ui/src/ui/chat/adaptive-card-parse.ts (74 lines) — marker extraction + JSON parsing
  • ui/src/ui/chat/adaptive-card-render.ts (~690 lines) — zero-dependency Lit renderer with full v1.6 coverage
  • ui/src/styles/chat/adaptive-cards.css (~530 lines) — card styles with ac- prefix, dark/light theme via existing CSS variables

Modified

  • ui/src/styles/chat.css — imports adaptive-cards.css
  • ui/src/ui/chat/grouped-render.tsrenderGroupedMessage() calls parseAdaptiveCardMarkers() before markdown conversion; renders card inline when found; integrated with main's collapsed tool cards and parsedCard guard for empty bubble suppression

Element Coverage (v1.6)

Category Elements
Text TextBlock (with markdown + heading style), RichTextBlock (inline runs with bold/italic/strike/highlight/links), CodeBlock (with language label)
Layout ColumnSet, Column, Container (emphasis/bleed), Table, ActionSet, Carousel (page badges), Accordion (collapsible panels), TabSet
Media Image (person style), ImageSet (gallery grid)
Data FactSet, Rating (star display), ProgressBar (with color variants), Badge, CompoundButton, Chart.* (Bar/Line/Pie/Donut as data tables)
Inputs Input.Text (multiline), Input.Number, Input.Date, Input.Time, Input.Toggle, Input.ChoiceSet (compact/expanded/multi-select) — rendered read-only in chat context
Actions Action.OpenUrl, Action.Submit, Action.Execute (with ac-action custom event dispatch), Action.ShowCard (expandable), Action.ToggleVisibility (DOM toggle)
Data binding ${expression} template expansion against templateData, including $root. prefix support

Design decisions

  • No external dependencies — built a lightweight renderer (~690 lines) instead of the 60KB adaptivecards npm package
  • Uses existing CSS variables for theme consistency
  • Unknown elements silently skipped (forward-compatible)
  • Fallback text rendered as markdown above the card
  • Inputs rendered as read-only (chat is a display context, not a form)
  • Charts rendered as labeled data tables (no canvas/SVG charting library needed)
  • Action.Submit/Execute dispatch ac-action CustomEvent on window for action routing integration
  • Action.ShowCard uses native <details> for zero-JS expand
  • Accordion/TabSet use <details>/<summary> for accessible collapsible sections

Performance target

<100ms including DOM insertion.

Ecosystem Context

This PR is part of the Adaptive Cards feature set powered by:

Package Version Role
adaptive-cards-mcp v2.3.0 Shared core — v1.6 JSON Schema validation, 7 host profiles, WCAG a11y scoring (0-100), 21 layout patterns, 924 tests
openclaw-adaptive-cards v4.0.0 OpenClaw plugin — adaptive_card tool, MCP bridge, channel-aware prompt guidance, fallback text generation, action routing

The plugin emits <!--adaptive-card--> markers in tool result text. This PR adds the Web UI client-side parser and Lit renderer that consumes those markers with full v1.6 element coverage.

Related PRs

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 10, 2026

Greptile Summary

This PR adds inline Adaptive Card rendering to the chat UI. A new lightweight Lit-based renderer (adaptive-card-render.ts) and marker parser (adaptive-card-parse.ts) are introduced, along with corresponding ac--prefixed styles. The integration into grouped-render.ts is minimal and correct.

Issues found:

  • XSS vulnerabilityAction.OpenUrl writes a.url directly into an href attribute without any scheme validation. Lit does not sanitize javascript: URLs in attribute bindings, so a malicious card payload could execute JavaScript when a user clicks the button. The existing openExternalUrlSafe utility (already imported in grouped-render.ts) should be used here.
  • Non-functional Action.Submit — The submit button's click handler only calls console.log and never dispatches an event or invokes any application callback. Users who click it will see no response, and the debug log should not reach production.
  • Dead code — templateData never consumedparseAdaptiveCardMarkers extracts and returns templateData from <!--adaptive-card-data--> blocks, but renderAdaptiveCard only destructures card and silently ignores the data. Template variable substitution is not implemented.
  • Type safety — renderImage return type lie — The function is declared as returning TemplateResult but casts nothing as unknown as TemplateResult in the early-return path, defeating TypeScript's type checker.

Confidence Score: 2/5

  • Not safe to merge — contains a javascript: XSS vector and a non-functional submit action.
  • The Action.OpenUrl href injection is a genuine security vulnerability that needs to be resolved before shipping. Additionally, Action.Submit is functionally broken (no event dispatched), and templateData parsing is entirely dead code. These are not minor polish issues.
  • ui/src/ui/chat/adaptive-card-render.ts requires the most attention — specifically the renderAction function (XSS) and the overall completeness of the Action.Submit and templateData flows.

Last reviewed commit: 1f04ba1

Comment on lines +219 to +226
if (type === "Action.OpenUrl") {
const url = typeof a.url === "string" ? a.url : "#";
return html`<a
class="ac-action ac-action--link"
href="${url}"
target="_blank"
rel="noreferrer noopener"
>${title}</a>`;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

XSS vulnerability via unsanitized href in Action.OpenUrl

a.url is written directly into an href attribute with no validation. Lit's html tagged template does not strip javascript: URLs from attribute bindings, so a card payload containing "url": "javascript:alert(document.cookie)" becomes a working XSS vector when the user clicks the link.

The existing codebase already exposes openExternalUrlSafe (imported in grouped-render.ts) which validates URLs before opening them. The Action.OpenUrl handler should use a <button> with a click handler (or at minimum validate the scheme before placing it in href) instead of a raw anchor with an unchecked URL.

Suggested change
if (type === "Action.OpenUrl") {
const url = typeof a.url === "string" ? a.url : "#";
return html`<a
class="ac-action ac-action--link"
href="${url}"
target="_blank"
rel="noreferrer noopener"
>${title}</a>`;
if (type === "Action.OpenUrl") {
const url = typeof a.url === "string" ? a.url : "";
if (!url) return nothing;
return html`<button
class="ac-action ac-action--link"
type="button"
@click=${() => openExternalUrlSafe(url)}
>${title}</button>`;
}

(You will also need to import openExternalUrlSafe from "../open-external-url.ts" at the top of this file.)

Prompt To Fix With AI
This is a comment left during a code review.
Path: ui/src/ui/chat/adaptive-card-render.ts
Line: 219-226

Comment:
**XSS vulnerability via unsanitized `href` in `Action.OpenUrl`**

`a.url` is written directly into an `href` attribute with no validation. Lit's `html` tagged template does **not** strip `javascript:` URLs from attribute bindings, so a card payload containing `"url": "javascript:alert(document.cookie)"` becomes a working XSS vector when the user clicks the link.

The existing codebase already exposes `openExternalUrlSafe` (imported in `grouped-render.ts`) which validates URLs before opening them. The `Action.OpenUrl` handler should use a `<button>` with a click handler (or at minimum validate the scheme before placing it in `href`) instead of a raw anchor with an unchecked URL.

```suggestion
  if (type === "Action.OpenUrl") {
    const url = typeof a.url === "string" ? a.url : "";
    if (!url) return nothing;
    return html`<button
      class="ac-action ac-action--link"
      type="button"
      @click=${() => openExternalUrlSafe(url)}
    >${title}</button>`;
  }
```

(You will also need to import `openExternalUrlSafe` from `"../open-external-url.ts"` at the top of this file.)

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 6f2eaab. Replaced <a href> with <button @click=${() => openExternalUrlSafe(url)}>. Imported openExternalUrlSafe which validates URL schemes (blocks javascript:, etc.).

Comment on lines +19 to +22
export function renderAdaptiveCard(
parsed: ParsedAdaptiveCard,
): TemplateResult {
const { card } = parsed;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

templateData is parsed but never consumed

renderAdaptiveCard destructures only { card } from the ParsedAdaptiveCard, so the templateData value extracted by the parser (from <!--adaptive-card-data--> blocks) is completely ignored at render time. Any template variable substitution — the primary reason to send a separate data block — is silently skipped.

If template binding isn't implemented yet, either remove the DATA_RE/STRIP_DATA_RE parsing logic from adaptive-card-parse.ts (and the templateData field from ParsedAdaptiveCard) to avoid dead code, or add a TODO comment so it isn't forgotten.

Prompt To Fix With AI
This is a comment left during a code review.
Path: ui/src/ui/chat/adaptive-card-render.ts
Line: 19-22

Comment:
**`templateData` is parsed but never consumed**

`renderAdaptiveCard` destructures only `{ card }` from the `ParsedAdaptiveCard`, so the `templateData` value extracted by the parser (from `<!--adaptive-card-data-->` blocks) is completely ignored at render time. Any template variable substitution — the primary reason to send a separate data block — is silently skipped.

If template binding isn't implemented yet, either remove the `DATA_RE`/`STRIP_DATA_RE` parsing logic from `adaptive-card-parse.ts` (and the `templateData` field from `ParsedAdaptiveCard`) to avoid dead code, or add a `TODO` comment so it isn't forgotten.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 6f2eaab. Added TODO comment at the destructure site. Template data parsing kept for forward-compatibility; binding implementation is a follow-up.

Comment on lines +140 to +148
function renderImage(el: Element): TemplateResult {
const url = typeof el.url === "string" ? el.url : "";
const alt = typeof el.altText === "string" ? el.altText : "";
const sizeClass = imageSizeClass(el.size);
if (!url) {
return nothing as unknown as TemplateResult;
}
return html`<img class="ac-image ${sizeClass}" src="${url}" alt="${alt}" />`;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Return type lie — nothing cast to TemplateResult

When url is empty the function returns nothing as unknown as TemplateResult, which defeats TypeScript's type checking and could cause subtle rendering bugs if callers branch on the return value. The declared return type should be widened to TemplateResult | typeof nothing to match reality.

Suggested change
function renderImage(el: Element): TemplateResult {
const url = typeof el.url === "string" ? el.url : "";
const alt = typeof el.altText === "string" ? el.altText : "";
const sizeClass = imageSizeClass(el.size);
if (!url) {
return nothing as unknown as TemplateResult;
}
return html`<img class="ac-image ${sizeClass}" src="${url}" alt="${alt}" />`;
}
function renderImage(el: Element): TemplateResult | typeof nothing {
const url = typeof el.url === "string" ? el.url : "";
const alt = typeof el.altText === "string" ? el.altText : "";
const sizeClass = imageSizeClass(el.size);
if (!url) {
return nothing;
}
return html`<img class="ac-image ${sizeClass}" src="${url}" alt="${alt}" />`;
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: ui/src/ui/chat/adaptive-card-render.ts
Line: 140-148

Comment:
**Return type lie — `nothing` cast to `TemplateResult`**

When `url` is empty the function returns `nothing as unknown as TemplateResult`, which defeats TypeScript's type checking and could cause subtle rendering bugs if callers branch on the return value. The declared return type should be widened to `TemplateResult | typeof nothing` to match reality.

```suggestion
function renderImage(el: Element): TemplateResult | typeof nothing {
  const url = typeof el.url === "string" ? el.url : "";
  const alt = typeof el.altText === "string" ? el.altText : "";
  const sizeClass = imageSizeClass(el.size);
  if (!url) {
    return nothing;
  }
  return html`<img class="ac-image ${sizeClass}" src="${url}" alt="${alt}" />`;
}
```

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 6f2eaab. Return type changed to TemplateResult | typeof nothing and the unsafe cast removed.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1f04ba1de0

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +220 to +223
const url = typeof a.url === "string" ? a.url : "#";
return html`<a
class="ac-action ac-action--link"
href="${url}"
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 Badge Reject unsafe protocols in Action.OpenUrl URLs

Action.OpenUrl values are written directly into an anchor href without any protocol allowlist, so a card payload like {"type":"Action.OpenUrl","url":"javascript:..."} will execute script when clicked. This is a security regression in the chat surface because other URL entry points already route through resolveSafeExternalUrl to block non-http(s)/blob schemes; this path should apply the same validation before rendering clickable links.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 6f2eaab. Action.OpenUrl now uses openExternalUrlSafe() which only allows http:, https:, and blob: protocols. No raw href injection.

return html`
<div class="ac-card">
${renderElements(card.body)}
${card.actions?.length ? renderActions(card.actions as Element[]) : nothing}
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 Badge Validate actions is an array before calling renderActions

The parser only validates card.body, but renderAdaptiveCard assumes any truthy card.actions?.length is mappable. If a malformed card contains "actions":"oops" (or any non-array with a length), renderActions will call .map on a non-array and throw, breaking message rendering. Guard this with Array.isArray(card.actions) before passing it into renderActions.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 6f2eaab. Added Array.isArray(card.actions) guard before calling renderActions.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6f2eaab484

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

VikrantSingh01 and others added 2 commits March 18, 2026 18:50
New files:
- ui/src/ui/chat/adaptive-card-parse.ts (74 lines) — marker extraction
- ui/src/ui/chat/adaptive-card-render.ts (272 lines) — lightweight Lit renderer
  for TextBlock, FactSet, ColumnSet, Container, Image, Table, actions
- ui/src/styles/chat/adaptive-cards.css (183 lines) — card styles with ac- prefix,
  dark/light theme via existing CSS variables

Modified:
- ui/src/styles/chat.css — import adaptive-cards.css
- ui/src/ui/chat/grouped-render.ts — parseAdaptiveCardMarkers() before markdown
  conversion; renders card inline when found, fallback text as markdown

No external dependencies added (no adaptivecards npm package).
Zero-dependency lightweight renderer covering the most common AC v1.5 elements.
1. XSS: replace Action.OpenUrl <a> with <button> using openExternalUrlSafe
2. Action.Submit: hide button, add TODO for custom event routing
3. templateData: add TODO for template data binding
4. renderImage: fix return type to TemplateResult | typeof nothing
5. Validate card.actions is an array before rendering
6. Fix no-base-to-string lint errors with typeof guards

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2f38c5876a

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

const markdown = markdownBase;

// Detect adaptive card markers before markdown conversion.
const parsedCard = markdownBase ? parseAdaptiveCardMarkers(markdownBase) : null;
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 Badge Parse adaptive-card markers only for assistant role

The new marker parsing runs on every message because parsedCard is computed from markdownBase without checking role. This means non-assistant content (for example user messages or tool logs that include literal <!--adaptive-card-->... text) is silently transformed into card UI instead of being shown as plain markdown text, which changes message semantics and can surface card UI where it was not intended. Restrict parsing to the trusted role(s) that actually emit these markers.

Useful? React with 👍 / 👎.

}

function renderAction(a: Element): TemplateResult | typeof nothing {
const type = typeof a.type === "string" ? a.type : "";
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 Badge Validate action entries before accessing action fields

renderActions passes each array element directly to renderAction, and renderAction immediately reads a.type/a.title. Because parsing only checks that actions is an array (not that each entry is a non-null object), a payload like "actions":[null] throws at runtime (Cannot read properties of null) and breaks rendering for the whole message bubble. Add a non-null object guard before dereferencing action fields.

Useful? React with 👍 / 👎.

Add support for: RichTextBlock, CodeBlock, ImageSet, ActionSet,
Carousel, Accordion, TabSet, Rating, ProgressBar, Badge,
CompoundButton, Chart.*, all Input elements (Text/Number/Date/
Time/Toggle/ChoiceSet), Action.Submit, Action.Execute,
Action.ShowCard, Action.ToggleVisibility.

Also adds template data binding (${expression} expansion),
markdown rendering in TextBlock, Container emphasis/bleed styles,
Image person style, and dispatches ac-action custom events for
card action routing.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c24a0bdbee

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +211 to +214
return html`<a class="ac-inline-link ${cls}" href="${url}" @click=${(e: Event) => {
e.preventDefault();
openExternalUrlSafe(url);
}}>${text}</a>`;
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 Badge Remove raw href from RichTextBlock OpenUrl runs

Even after Action.OpenUrl button clicks were routed through openExternalUrlSafe, this RichTextBlock branch still writes the unvalidated URL into an anchor href. That means non-primary navigations (for example middle-click or context-menu "open link") can bypass the safe opener and use the raw scheme directly, so disallowed protocols are still reachable from card content.

Useful? React with 👍 / 👎.

Comment on lines +704 to +705
const el = card.querySelector(`[data-ac-id="${id}"]`);
if (el) {
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 Badge Wire ToggleVisibility to actual rendered element IDs

Action.ToggleVisibility looks up targets via [data-ac-id="..."], but this renderer never emits data-ac-id on card elements, so valid toggle actions cannot find any targets and always no-op. The toggle handler needs to query an attribute that is actually rendered (or elements need to expose matching data-ac-id values).

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 08eefaccaf

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +30 to +32
return typeof val === "object"
? JSON.stringify(val)
: String(val as string | number | boolean);
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 Badge Preserve non-string types in template substitutions

bindTemplateData always coerces ${...} results to strings (or JSON strings for objects), so cards that bind numeric/boolean/object fields lose their original types. For example, ProgressBar.value: "${score}" becomes a string and then renders as 0% because renderProgressBar only accepts numbers; similar failures occur for other typed fields. This makes templated cards with non-text bindings render incorrect state even when templateData is valid.

Useful? React with 👍 / 👎.

Comment on lines +264 to +265
const width = typeof el.width === "string" ? el.width : "auto";
const flex = width === "stretch" ? "1 1 0%" : width === "auto" ? "0 0 auto" : `0 0 ${width}`;
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 Badge Support numeric Column.width values

renderColumn treats width as a string-only field and falls back to "auto" for non-strings, but Adaptive Cards allows numeric widths for weighted columns. A valid card with widths like 1 and 2 will lose the intended proportional layout because both columns are rendered as auto-width.

Useful? React with 👍 / 👎.

BradGroux

This comment was marked as duplicate.

Copy link
Copy Markdown
Contributor

@BradGroux BradGroux left a comment

Choose a reason for hiding this comment

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

Review: Web UI Adaptive Card Renderer

1,607 lines adding a Lit-based web renderer with zero external dependencies. Covers parsing (adaptive-card-parse.ts), rendering (adaptive-card-render.ts), CSS (adaptive-cards.css), and integration into grouped chat output (grouped-render.ts). The zero-dependency approach is commendable and the Lit custom element pattern is a good fit for the web UI architecture.

Blocker

1. Unsafe URL placement in <a href> allows javascript:/data: scheme exposure

In adaptive-card-render.ts (renderRichTextBlock), unsanitized URLs from card data are placed directly into href attributes:

return html`<a class="ac-inline-link ${cls}" href="${url}" @click=${(e: Event) => {
  e.preventDefault();
  openExternalUrlSafe(url);
}}>${text}</a>`;

While the click handler uses openExternalUrlSafe with preventDefault, the raw href is still injected into the DOM. Users can right-click and "Copy Link Address" or use browser affordances to access javascript:, data:, or other dangerous scheme URLs. The fix is to validate/sanitize the URL before assigning it to href (allowlist https and http schemes), or render as a <span> styled as a link and route all interaction through the safe opener exclusively.

Suggestions

2. Recursive template binding has no depth or size guard

bindTemplateData traverses untrusted JSON recursively with no depth cap or node count limit:

if (obj && typeof obj === "object") {
  const result: Record<string, unknown> = {};
  for (const [k, v] of Object.entries(obj)) {
    result[k] = bindTemplateData(v, data);
  }
}

Pathological or maliciously crafted card payloads with deep nesting could cause stack overflow or excessive CPU usage. Add a max depth parameter (e.g., 20 levels) and bail out gracefully when exceeded.

3. Parse result is not cached

Every time grouped-render.ts processes a message, the card JSON is re-parsed. For messages with large cards that remain in the visible chat history, caching the parsed result by message ID would reduce redundant work during re-renders.

Nits

4. nothing as unknown as TemplateResult force-casts

Multiple renderer functions use this pattern:

if (actions.length === 0) {
  return nothing as unknown as TemplateResult;
}

This weakens type safety. Prefer returning TemplateResult | typeof nothing from these functions and let Lit handle the nothing sentinel natively.

5. Malformed JSON fallback path

If the JSON between markers is malformed, the parser returns null and the card is silently dropped. Consider rendering the fallback text (text before the markers) when parse fails, so the user sees something rather than nothing.

Summary

Impressive zero-dependency renderer with comprehensive element coverage. The URL sanitization issue is the only blocker since it is a security concern that could be exploited through crafted card payloads. The recursive binding guard and caching suggestions are hardening improvements worth addressing before merge.

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants