fix(chat): open AI-generated links in new tab instead of replacing Lab UI#347
Conversation
…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.
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:...)`.
|
End-to-end Playwright validation pass on the live build (Lab restarted, 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 The Playwright pass also caught a gap in the round-2 implementation: Just pushed |
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-markdownrenders[text](url)as a bare<a href="...">with notargetattribute, so the browser navigates the top-level document and unloads the session.Solution
Overrides
react-markdown'sacomponent with a new<MarkdownLink>that routes every link node through one of three branches:#section): rendered as inert plain text. New-tab navigation lands onabout:blank#section; same-tab scrolls the wrong document. Neither matches what the LLM meant./, no//prefix): resolved against the active document's directory viaPathExt.join, re-validated, and routed through Lab'sdocmanager:opencommand so a.ipynbopens with the notebook factory and a.mdin the editor. The anchor'shrefstays"#"(the resolved path moves totitlefor hover preview) so a modifier-click can't bypass the React onClick and let the browser navigate/lab/<path>with session cookies attached.<SafeAnchor>that enforces the existingsafeAnchorUrischeme allowlist (http/https/mailto) and emitstarget="_blank" rel="noopener noreferrer"with an SR-only "(opens in new tab)" suffix.<SafeAnchor>also replaces the existingAnchorDatarender 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
hasDangerousTextCodepointshelper insrc/utils.tsmirrorsnotebook_intelligence/util.py:has_dangerous_text_codepointsand is applied to the anchortitleattribute 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).PathExt.joinwhenbaseDiris empty:[x](java<tab>script:alert(1))would otherwise unmask into ajavascript:href because the WHATWG URL parser strips C0 controls during scheme recognition.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 newtests/ts/safe-anchor.test.tsx(15 cases),tests/ts/markdown-renderer.test.tsx(16 cases), and extensions totests/ts/utils.test.ts(hasDangerousTextCodepointscases).jlpm build && pip install -e ., restarted Lab, verified the chat sidebar loads cleanly with zero console errors and the federated@plmbr/notebook-intelligencechunk registered correctly.Test coverage pins:
http(s)+mailtoaccepted;javascript:/data:/vbscript:/blob:/file:/ C0-tab-unmasked / scheme-relative rejected.baseDir,href="#"rather than the resolved path so modifier-click can't bypass the click handler.docmanager:openrejection caught and logged rather than surfacing as an unhandled promise rejection.../../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.titlecarrying 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
/simplifyplus 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 withhref={resolvedPath}, C0-scheme-unmask via emptybaseDir, missing resolved-path codepoint scrub); all applied, including reverting thehrefchange in favor oftitlefor hover preview so modifier-clicks can't bypass the React onClick.Risks / follow-ups
e.ctrlKey || e.metaKeyin the onClick by passing{ options: { mode: 'split-right' } }todocmanager:open. Not blocking.text-decoration: line-throughand atitle="Link blocked for safety". Out of scope for this fix.SCHEME_PREFIX_REconstant inmarkdown-link.tsxmirrorsSCHEME_REinutils.ts. A future cleanup could export a sharedhasUriSchemehelper; the duplication is intentional and commented today.rehype-rawplugin: raw HTML in chat markdown still renders as literal text, so the only anchor sink is the CommonMark/GFManode handled byMarkdownLink. A code comment pins this assumption for future contributors.Closes #344.