Skip to content

fix(chat): open AI-generated links in new tab instead of replacing Lab UI#347

Merged
mbektas merged 2 commits into
plmbr:mainfrom
pjdoland:fix/344-markdown-link-new-tab
May 24, 2026
Merged

fix(chat): open AI-generated links in new tab instead of replacing Lab UI#347
mbektas merged 2 commits into
plmbr:mainfrom
pjdoland:fix/344-markdown-link-new-tab

Conversation

@pjdoland
Copy link
Copy Markdown
Collaborator

Summary

Issue #344 reports that AI-generated markdown links in the chat sidebar open in the same tab and replace the entire JupyterLab UI on click. react-markdown renders [text](url) as a bare <a href="..."> with no target attribute, so the browser navigates the top-level document and unloads the session.

Solution

Overrides react-markdown's a component with a new <MarkdownLink> that routes every link node through one of three branches:

  • Fragment-only (#section): rendered as inert plain text. New-tab navigation lands on about:blank#section; same-tab scrolls the wrong document. Neither matches what the LLM meant.
  • Workspace-relative (no scheme, no leading /, no // prefix): resolved against the active document's directory via PathExt.join, re-validated, and routed through Lab's docmanager:open command so a .ipynb opens with the notebook factory and a .md in the editor. The anchor's href stays "#" (the resolved path moves to title for hover preview) so a modifier-click can't bypass the React onClick and let the browser navigate /lab/<path> with session cookies attached.
  • External: handed to a new <SafeAnchor> that enforces the existing safeAnchorUri scheme allowlist (http / https / mailto) and emits target="_blank" rel="noopener noreferrer" with an SR-only "(opens in new tab)" suffix.

<SafeAnchor> also replaces the existing AnchorData render path in the chat sidebar and the five hand-rolled GitHub Copilot login-panel + device-activation anchors so the new-tab semantics, scheme allowlist, and SR-only announcement stay single-sourced.

A new hasDangerousTextCodepoints helper in src/utils.ts mirrors notebook_intelligence/util.py:has_dangerous_text_codepoints and is applied to the anchor title attribute so an LLM-emitted hover tooltip can't visually impersonate the link target via bidi-override or zero-width characters.

Post-join path validation rejects three attack/confusion shapes the naive workspace-relative branch would miss:

  • .. traversal escaping the workspace root (ContentsManager rejects server-side too, but failing closed visually keeps the title preview and devtools log from showing the traversal).
  • Embedded schemes that survive PathExt.join when baseDir is empty: [x](java<tab>script:alert(1)) would otherwise unmask into a javascript: href because the WHATWG URL parser strips C0 controls during scheme recognition.
  • Dangerous codepoints in the resolved path itself.

Testing

  • pytest tests/ --ignore=tests/test_claude_client.py -q: 1066 passed.
  • jlpm tsc --noEmit: clean.
  • jlpm lint:check: clean.
  • jlpm jest: 327 passed including the new tests/ts/safe-anchor.test.tsx (15 cases), tests/ts/markdown-renderer.test.tsx (16 cases), and extensions to tests/ts/utils.test.ts (hasDangerousTextCodepoints cases).
  • Manual smoke check: jlpm build && pip install -e ., restarted Lab, verified the chat sidebar loads cleanly with zero console errors and the federated @plmbr/notebook-intelligence chunk registered correctly.

Test coverage pins:

  • http(s) + mailto accepted; javascript: / data: / vbscript: / blob: / file: / C0-tab-unmasked / scheme-relative rejected.
  • Workspace-relative resolved against baseDir, href="#" rather than the resolved path so modifier-click can't bypass the click handler.
  • docmanager:open rejection caught and logged rather than surfacing as an unhandled promise rejection.
  • Fragment-only renders as inert text.
  • Traversal (../../etc/passwd), absolute (/etc/passwd), C0-unmask (java<tab>script:alert(1)), and codepoint-bearing (READ<RLO>ME.md) all fall through to the blocked-link branch.
  • Anchor title carrying bidi-override codepoints is dropped, with a fallback to the resolved path so the user still sees what a click would open.

Two reviewer rounds (six agents each: three /simplify plus frontend a11y, JupyterLab extension architect, and security). Round 1 surfaced four fix-before-merge items (path resolution against the active doc directory, href={path} instead of "#", docmanager rejection handling, raw-HTML test pin); all applied. Round 2 surfaced three more critical security findings (middle-click bypass with href={resolvedPath}, C0-scheme-unmask via empty baseDir, missing resolved-path codepoint scrub); all applied, including reverting the href change in favor of title for hover preview so modifier-clicks can't bypass the React onClick.

Risks / follow-ups

  • Lab does not have a "split-right" hint plumbed through for modifier-click intent. A power-user follow-up could honor e.ctrlKey || e.metaKey in the onClick by passing { options: { mode: 'split-right' } } to docmanager:open. Not blocking.
  • The blocked-link branch has no visible affordance for sighted users (only SR-only "(link blocked)"). A future style tweak could add text-decoration: line-through and a title="Link blocked for safety". Out of scope for this fix.
  • The SCHEME_PREFIX_RE constant in markdown-link.tsx mirrors SCHEME_RE in utils.ts. A future cleanup could export a shared hasUriScheme helper; the duplication is intentional and commented today.
  • No rehype-raw plugin: raw HTML in chat markdown still renders as literal text, so the only anchor sink is the CommonMark/GFM a node handled by MarkdownLink. A code comment pins this assumption for future contributors.

Closes #344.

…b UI

LLM-generated markdown links in the chat sidebar previously rendered as
bare <a href="..."> with no `target` attribute, so a click navigated the
top-level browser document and unloaded the entire JupyterLab session.

The fix overrides react-markdown's `a` component with a new
`<MarkdownLink>` that routes every link node through one of three
branches:

* Fragment-only (`#section`): inert plain text. Opening `about:blank#x`
  in a new tab or scrolling the wrong document in the same tab are both
  worse than no link.
* Workspace-relative (no scheme, no leading `/`, no `//` prefix):
  resolved against the active document's directory via `PathExt.join`,
  re-validated post-join, and routed through JupyterLab's
  `docmanager:open` so a `.ipynb` opens with the notebook factory and a
  `.md` opens in the editor. The anchor's `href` stays `"#"` so a
  modifier-click can't bypass the React onClick and let the browser
  navigate `/lab/<path>` with session cookies attached; the resolved
  path moves to `title` for hover preview.
* External: handed to the new `<SafeAnchor>`, which enforces the
  existing `safeAnchorUri` scheme allowlist (`http` / `https` /
  `mailto`) and emits `target="_blank" rel="noopener noreferrer"` with
  an SR-only "(opens in new tab)" suffix.

`<SafeAnchor>` also replaces the existing AnchorData render path and the
five hand-rolled GitHub Copilot login-panel + device-activation anchors
so the new-tab semantics, scheme allowlist, and SR-only announcement
stay single-sourced. An LLM-emitted `title` attribute is scrubbed
through a new `hasDangerousTextCodepoints` helper (mirror of the Python
`has_dangerous_text_codepoints`) so bidi-override and zero-width tricks
can't visually impersonate the link target on hover.

Post-join path validation rejects three attack/confusion shapes the
naive workspace-relative branch would miss:

* `..` traversal that escapes the workspace root (ContentsManager
  rejects server-side too, but failing closed visually keeps the
  status-bar title and devtools log from previewing a traversal
  target).
* Embedded schemes after `PathExt.join` returns the input verbatim,
  which would otherwise let `[x](java<tab>script:alert(1))` unmask into
  a `javascript:` href when `baseDir` is empty (any active doc at
  workspace root).
* Dangerous codepoints in the resolved path itself.

Closes plmbr#344.
@pjdoland pjdoland added the enhancement New feature or request label May 23, 2026
Playwright end-to-end exposed a gap in the round-2 implementation:
`react-markdown` v9's built-in `urlTransform` strips disallowed schemes
(`javascript:`, `data:`, ...) to an empty string before our `MarkdownLink`
override sees the link node. The pre-join scheme sniff therefore never
fires, the empty href falls into the workspace-relative branch, and the
link renders as an inert `<a href="#" title="">` instead of the
SafeAnchor blocked-link span. Functionally inert (the click handler
calls docmanager:open which 404s and the .catch logs it) but
user-visible: the malicious-link test message in the chat looked
indistinguishable from a real workspace link.

Extend `isUnsafeWorkspacePath` to reject empty, `.`, and `./` resolved
paths so the workspace-relative branch falls through to SafeAnchor's
blocked-link span, matching the rendering for every other rejected
href.

Pinned with a new test in `tests/ts/markdown-renderer.test.tsx` that
mirrors what react-markdown produces for `[click](javascript:...)`.
@pjdoland
Copy link
Copy Markdown
Collaborator Author

End-to-end Playwright validation pass on the live build (Lab restarted, jupyter labextension develop --overwrite . re-linked, fresh bundle remoteEntry.24584caad2a89e664072.js loaded). Sent a chat message containing all three branches as markdown:

Verify: [external](https://example.com) [workspace](README.md) [malicious](javascript:alert(1))

Rendered DOM:

<p>Verify:
  <a href="https://example.com" target="_blank" rel="noopener noreferrer">
    external<span class="nbi-sr-only"> (opens in new tab)</span>
  </a>
  <a href="#" title="README.md">workspace</a>
  <span>malicious<span class="nbi-sr-only"> (link blocked)</span></span>
</p>

All three branches behave as designed. Clicking the workspace link invoked docmanager:open and Lab navigated to /lab/tree/README.md (the file opened in the editor). Clicking the workspace link when the target file is missing surfaced the failure via console.warn: NBI: failed to open workspace path "README.md": ... 404 Not Found — the .catch handler caught the rejection so it never bubbled as an unhandled promise.

The Playwright pass also caught a gap in the round-2 implementation: react-markdown v9 strips disallowed schemes (javascript:, etc.) to an empty string via its built-in urlTransform before our MarkdownLink override sees the link node, so the pre-join scheme sniff never fired and the empty href fell into the workspace-relative branch. The malicious link was therefore rendering as an inert <a href="#" title=""> instead of the SafeAnchor blocked-link span. Functionally safe (the click 404s and .catch logs it), but user-visible: the malicious test link looked indistinguishable from a real workspace link.

Just pushed 43b3a44 to extend isUnsafeWorkspacePath to reject empty, ., and ./ paths so the malicious case renders as the blocked-link span. New test 'blocks empty href (react-markdown strips javascript: to empty)' pins the regression.

Copy link
Copy Markdown
Collaborator

@mbektas mbektas left a comment

Choose a reason for hiding this comment

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

thanks!

@mbektas mbektas merged commit da294cd into plmbr:main May 24, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

AI generated links should open in new tab

2 participants