Skip to content
Merged
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
67 changes: 67 additions & 0 deletions app/src/components/notifications/NotificationBody.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/**
* Shared `<openhuman-link>` rendering for notification surfaces
* (`NotificationCard` and the `Notifications` page).
*
* Notification bodies emitted by Rust error helpers can contain
* `<openhuman-link path="…">…</openhuman-link>` tags (e.g. morning-briefing
* failures pointing users at Discord / Settings). Without parsing, the raw
* tag leaks as literal text. This module mirrors the chat-side pill so the
* tag renders as a clickable pill instead.
*
* **Why this lives here and not in a global shared spot:** the chat-side
* `OpenhumanLinkPill` is a non-exported function inside `AgentMessageBubble.tsx`
* (`app/src/pages/conversations/`). Extracting from chat would change the chat
* render path — out of scope for this fix. Instead, we keep the grammar / parsing
* shared (reuses `parseBubbleSegments` from conversations) but reimplement the
* pill locally. Both notification surfaces share *this* file so the diff stays
* testable with one Vitest suite.
*
* Safety: this component renders **only** text + button elements. It never
* uses `dangerouslySetInnerHTML`, never sets an `href`, and the dispatched
* `OPENHUMAN_LINK_EVENT` is consumed by `OpenhumanLinkModal`, which hard-
* allowlists `path` values before routing. See `OpenhumanLinkModal.tsx`
* `ALLOWED_PATHS_SET`.
*/
import { parseBubbleSegments } from '../../pages/conversations/utils/format';
import { OPENHUMAN_LINK_EVENT } from '../OpenhumanLinkModal';

function NotificationLinkPill({ path, label }: { path: string; label: string }) {
return (
<button
type="button"
onClick={e => {
// Don't trigger the surrounding card / row click — the pill is its
// own action.
e.stopPropagation();
window.dispatchEvent(new CustomEvent(OPENHUMAN_LINK_EVENT, { detail: { path } }));
}}
className="inline-flex items-center gap-1 rounded-full border border-primary-200 bg-primary-50 px-2 py-0.5 text-[11px] font-medium text-primary-700 transition-colors hover:bg-primary-100">
{label}
<svg className="h-2.5 w-2.5" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 12h14M13 6l6 6-6 6"
/>
</svg>
</button>
);
}

export default function NotificationBody({ body }: { body: string }) {
const segments = parseBubbleSegments(body);
return (
<>
{segments.map((seg, i) =>
seg.kind === 'link' ? (
<NotificationLinkPill key={i} path={seg.path} label={seg.label} />
) : (
// React auto-escapes text content, so any other markup in the body
// (e.g. `<script>…</script>`) renders as literal text.
<span key={i}>{seg.text}</span>
)
)}
</>
);
}
193 changes: 159 additions & 34 deletions app/src/components/notifications/NotificationCard.test.tsx
Original file line number Diff line number Diff line change
@@ -1,50 +1,175 @@
import { render, screen } from '@testing-library/react';
/**
* Markup-leak guard for `<openhuman-link>` rendering in notification bodies.
*
* These tests are the airtight contract for issue #2279 (Bug A) — they assert:
* - well-formed tags become pills (no raw `<openhuman-link>` text leaks),
* - attacker-influenceable `path` values can't become a navigable
* `javascript:` link, and
* - non-link bodies and stray markup render as literal, auto-escaped text
* (no `<script>` element gets injected into the DOM).
*
* NotificationCard and the Notifications page both render bodies the same way,
* so a single suite covers both call sites — we render NotificationCard for
* the local body component (since the page uses an identical inline helper)
* and the page exports its own helper too.
*/
import { fireEvent, render, screen, within } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';

import type { IntegrationNotification } from '../../types/notifications';
import NotificationCard, { formatNotificationBodyPreview } from './NotificationCard';
import { OPENHUMAN_LINK_EVENT } from '../OpenhumanLinkModal';
import NotificationCard from './NotificationCard';

function notification(overrides: Partial<IntegrationNotification> = {}): IntegrationNotification {
function makeNotification(body: string): IntegrationNotification {
return {
id: 'notif-1',
provider: 'cron',
account_id: 'morning_briefing',
title: 'morning_briefing',
body: 'Morning briefing ready.',
id: 'n-1',
provider: 'gmail',
title: 'Morning briefing',
body,
raw_payload: {},
importance_score: 0.65,
triage_action: 'react',
triage_reason: 'Scheduled delivery',
status: 'unread',
received_at: new Date().toISOString(),
scored_at: new Date().toISOString(),
...overrides,
};
}

describe('formatNotificationBodyPreview', () => {
it('strips openhuman-link markup while preserving the label', () => {
function renderCard(body: string) {
return render(<NotificationCard notification={makeNotification(body)} onMarkRead={vi.fn()} />);
}

describe('NotificationCard <openhuman-link> rendering', () => {
it('renders an <openhuman-link> tag as a pill (no raw tag leaks)', () => {
const body = '<openhuman-link path="community/discord">Report on Discord</openhuman-link>';
renderCard(body);

const bodyEl = screen.getByTestId('notification-card-body');
// The pill is a <button> with the label as accessible name. The outer
// notification card is also a <button> wrapping everything, so we scope
// the query to the body element.
const pill = within(bodyEl).getByRole('button', { name: /Report on Discord/i });
expect(pill).toBeInTheDocument();

// Critically: the raw tag text must NOT appear anywhere in the rendered DOM.
expect(bodyEl.textContent ?? '').not.toContain('<openhuman-link');
expect(bodyEl.textContent ?? '').not.toContain('</openhuman-link>');
});

it('does NOT emit a navigable javascript: link for a malicious path (XSS guard)', () => {
const body = '<openhuman-link path="javascript:alert(1)">click me</openhuman-link>';
const { container } = renderCard(body);

// No <a href="javascript:..."> anywhere in the rendered tree. The pill is
// a <button>, never an <a>, but we assert the absolute invariant directly.
const anchors = container.querySelectorAll('a');
for (const a of anchors) {
const href = a.getAttribute('href') ?? '';
expect(href.toLowerCase().startsWith('javascript:')).toBe(false);
}

// Even though the pill exists (the parser doesn't allowlist `path` — the
// modal listener does), clicking it MUST NOT navigate. We verify by
// listening for the dispatched custom event and confirming the path is
// exactly what was parsed — the OpenhumanLinkModal listener (which
// hard-allowlists paths) is what stops the dangerous string from doing
// anything. See OpenhumanLinkModal.tsx ALLOWED_PATHS_SET.
const seen: string[] = [];
const listener = (e: Event) => {
const detail = (e as CustomEvent<{ path: string }>).detail;
seen.push(detail?.path ?? '');
};
window.addEventListener(OPENHUMAN_LINK_EVENT, listener);
try {
const bodyEl = screen.getByTestId('notification-card-body');
const pill = within(bodyEl).getByRole('button', { name: /click me/i });
pill.click();
} finally {
window.removeEventListener(OPENHUMAN_LINK_EVENT, listener);
}

// The dispatched event payload is exactly what was parsed — but `OpenhumanLinkModal`
// (the listener that actually opens UI) hard-allowlists paths, so the
// `javascript:` string never gets to act on anything. Both halves of the
// contract are asserted: dispatch is faithful, navigation is impossible.
expect(seen).toEqual(['javascript:alert(1)']);

// No href injection regardless of whether the pill rendered.
expect(
formatNotificationBodyPreview(
'You can <openhuman-link path="community/discord">Report on Discord</openhuman-link>.'
)
).toBe('You can Report on Discord.');
Array.from(container.querySelectorAll<HTMLElement>('[href]')).every(el => {
const href = el.getAttribute('href') ?? '';
return !href.toLowerCase().startsWith('javascript:');
})
).toBe(true);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

describe('NotificationCard', () => {
it('does not render raw openhuman-link markup in the body preview', () => {
const rendered = render(
<NotificationCard
notification={notification({
body: 'Something went wrong.\n<openhuman-link path="community/discord">Report on Discord</openhuman-link>',
})}
onMarkRead={vi.fn()}
/>
);

expect(screen.getByText(/Report on Discord/)).toBeInTheDocument();
expect(rendered.container.textContent).not.toContain('<openhuman-link');
expect(rendered.container.textContent).not.toContain('</openhuman-link>');
it('renders a plain-text body as the literal string (regression guard)', () => {
renderCard('plain text without any tags');

const bodyEl = screen.getByTestId('notification-card-body');
expect(bodyEl).toHaveTextContent('plain text without any tags');
// No pill rendered for tag-free body — scope to the body element to ignore
// the outer card <button>.
expect(within(bodyEl).queryByRole('button')).toBeNull();
});

it('renders <script> as literal text — does not inject a <script> element', () => {
const body = '<script>alert(1)</script>';
const { container } = renderCard(body);

// React auto-escapes anything that's not an `<openhuman-link>` segment.
const bodyEl = screen.getByTestId('notification-card-body');
expect(bodyEl.textContent).toBe('<script>alert(1)</script>');

// Hard guarantee: no actual <script> element anywhere in the rendered tree.
expect(container.querySelector('script')).toBeNull();
});

it('renders mixed text + link segments in order', () => {
const body = 'Before <openhuman-link path="community/discord">Discord</openhuman-link> after';
renderCard(body);

const bodyEl = screen.getByTestId('notification-card-body');
expect(bodyEl).toHaveTextContent(/Before/);
expect(bodyEl).toHaveTextContent(/after/);
expect(within(bodyEl).getByRole('button', { name: /Discord/i })).toBeInTheDocument();
expect(bodyEl.textContent ?? '').not.toContain('<openhuman-link');
});

// Keyboard-activation coverage for the `<div role="button">` wrapper we use
// instead of a real `<button>` (a real button can't legally contain the
// `<openhuman-link>` pill which is also a button). Exercises the `onKeyDown`
// branch so the diff-coverage gate sees those lines hit.
it('activates the card body via Enter and Space keys', () => {
const onMarkRead = vi.fn();
const notification = makeNotification('plain body, no pill so no inner button');
// Plain text body, so the only role=button in the card is the outer wrapper.
render(<NotificationCard notification={notification} onMarkRead={onMarkRead} />);

const wrapper = screen.getByRole('button');
fireEvent.keyDown(wrapper, { key: 'Enter' });
fireEvent.keyDown(wrapper, { key: ' ' });
// Status is `unread` (set by makeNotification) and no `onNavigate` was
// passed, so handleBodyClick falls through to onMarkRead each time.
expect(onMarkRead).toHaveBeenCalledTimes(2);
expect(onMarkRead).toHaveBeenCalledWith(notification.id);

// Other keys must NOT activate — guard against the branch being too loose.
onMarkRead.mockClear();
fireEvent.keyDown(wrapper, { key: 'a' });
fireEvent.keyDown(wrapper, { key: 'Tab' });
expect(onMarkRead).not.toHaveBeenCalled();
});

// Bubbling guard: pressing Enter/Space while focused on the inner pill must
// NOT also activate the surrounding card. Without `e.target !== e.currentTarget`
// the keydown would bubble up and trigger `handleBodyClick` accidentally.
it('does NOT activate the card when keydown bubbles from the inner pill', () => {
const onMarkRead = vi.fn();
const body = '<openhuman-link path="community/discord">Discord</openhuman-link>';
render(<NotificationCard notification={makeNotification(body)} onMarkRead={onMarkRead} />);

const bodyEl = screen.getByTestId('notification-card-body');
const pill = within(bodyEl).getByRole('button', { name: /Discord/i });
fireEvent.keyDown(pill, { key: 'Enter' });
fireEvent.keyDown(pill, { key: ' ' });
expect(onMarkRead).not.toHaveBeenCalled();
});
});
43 changes: 25 additions & 18 deletions app/src/components/notifications/NotificationCard.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useT } from '../../lib/i18n/I18nContext';
import type { IntegrationNotification } from '../../types/notifications';
import NotificationBody from './NotificationBody';

// ─────────────────────────────────────────────────────────────────────────────
// Helpers
Expand Down Expand Up @@ -44,17 +45,6 @@ function scoreBadgeClass(score: number): string {
return 'bg-sage-500/20 text-green-700 border-green-200';
}

const OPENHUMAN_LINK_TAG =
/<openhuman-link\s+path=(?:"[^"]*"|'[^']*')\s*>([\s\S]*?)<\/openhuman-link>/gi;

export function formatNotificationBodyPreview(body: string): string {
return body
.replace(OPENHUMAN_LINK_TAG, '$1')
.replace(/[ \t]+\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
}

// ─────────────────────────────────────────────────────────────────────────────
// Component
// ─────────────────────────────────────────────────────────────────────────────
Expand All @@ -69,7 +59,6 @@ interface Props {
const NotificationCard = ({ notification: n, onMarkRead, onNavigate, onDismiss }: Props) => {
const { t } = useT();
const isUnread = n.status === 'unread';
const bodyPreview = n.body ? formatNotificationBodyPreview(n.body) : '';

const handleBodyClick = () => {
if (onNavigate) {
Expand All @@ -92,8 +81,24 @@ const NotificationCard = ({ notification: n, onMarkRead, onNavigate, onDismiss }
)}
</div>

<button
{/* `role="button"` + key handler instead of a real `<button>` because
this wrapper contains `NotificationLinkPill` (also a `<button>`),
and nested interactive elements break keyboard / screen-reader
behaviour (HTML spec disallows it). */}
<div
role="button"
tabIndex={0}
onClick={handleBodyClick}
onKeyDown={e => {
// Ignore bubbled keydown from inner controls (e.g. the link pill).
// Without this, pressing Enter/Space on a focused pill would also
// activate the card body.
if (e.target !== e.currentTarget) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleBodyClick();
}
}}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
className="flex-1 min-w-0 text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-1 rounded-sm">
{/* Header row: provider badge + timestamp */}
<div className="flex items-center gap-2 mb-1">
Expand Down Expand Up @@ -129,13 +134,15 @@ const NotificationCard = ({ notification: n, onMarkRead, onNavigate, onDismiss }
{n.title}
</p>

{/* Body preview */}
{bodyPreview && (
<p className="text-xs text-stone-500 dark:text-neutral-400 mt-0.5 line-clamp-2">
{bodyPreview}
{/* Body preview — `<openhuman-link>` tags render as pills */}
{n.body && (
<p
data-testid="notification-card-body"
className="text-xs text-stone-500 dark:text-neutral-400 mt-0.5 line-clamp-2">
<NotificationBody body={n.body} />
</p>
)}
</button>
</div>
{onDismiss && (
<button
onClick={() => onDismiss(n.id)}
Expand Down
Loading
Loading