-
Notifications
You must be signed in to change notification settings - Fork 2.7k
fix(notifications): render <openhuman-link> tags in notification bodies (#2279) #2339
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
senamakel
merged 3 commits into
tinyhumansai:main
from
sanil-23:fix/2279-notification-link-markup
May 21, 2026
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
193
app/src/components/notifications/NotificationCard.test.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| }); | ||
| }); | ||
|
|
||
| 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(); | ||
| }); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.