fix(ui): open app deep links from chat after confirmation (#2932)
* fix(ui): open app deep links from chat after confirmation DOMPurify's default URI policy stripped href from anchors with custom application schemes (obsidian://, vscode://, ...), so every app link rendered in chat was dead across web, desktop, VS Code, and mobile. - Classify safe app-link schemes in lib/url.ts (browser-handled, scriptable, webview-internal, network, and self-deep-link schemes stay excluded) and let openExternalUrl accept them - Keep app-link hrefs through the markdown sanitize hook - Intercept app-link clicks in the markdown renderer and route them through a confirmation dialog (Trust and open / Open once, dismiss to cancel) mounted in the desktop/web app root and the mobile shell - Persist per-device trusted schemes in a zustand store; trusted schemes open without asking again * feat(settings): manage trusted app link schemes in General Add an App links section to Settings > General listing the application schemes trusted on this device with a delete action; removing a scheme restores the confirmation dialog for it. Register the section in settings search. * fix(ui): enforce app link confirmation * fix(ui): handle app links by runtime * fix(vscode): keep app links unsupported * fix(settings): clarify trusted app links --------- Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
a5b0272f01
commit
3a78d86248
@@ -1,10 +1,43 @@
|
||||
import { describe, expect, mock, test } from 'bun:test';
|
||||
|
||||
type SanitizeAttribute = {
|
||||
attrName: string;
|
||||
attrValue: string;
|
||||
forceKeepAttr?: boolean;
|
||||
};
|
||||
|
||||
class TestAnchorElement {
|
||||
target = '';
|
||||
|
||||
setAttribute(name: string, value: string): void {
|
||||
if (name === 'target') this.target = value;
|
||||
}
|
||||
}
|
||||
|
||||
const sanitizeHooks: {
|
||||
uponSanitizeAttribute?: (node: unknown, data: SanitizeAttribute) => void;
|
||||
afterSanitizeAttributes?: (node: unknown) => void;
|
||||
} = {};
|
||||
|
||||
Object.assign(globalThis, {
|
||||
window: {},
|
||||
HTMLAnchorElement: TestAnchorElement,
|
||||
});
|
||||
|
||||
mock.module('dompurify', () => ({
|
||||
default: {
|
||||
isSupported: true,
|
||||
addHook: () => undefined,
|
||||
sanitize: (html: string) => html,
|
||||
addHook: (name: keyof typeof sanitizeHooks, hook: never) => {
|
||||
sanitizeHooks[name] = hook;
|
||||
},
|
||||
sanitize: (html: string) => html.replace(/ href="([^"]*)"/g, (attribute, href: string) => {
|
||||
const anchor = new TestAnchorElement();
|
||||
const data: SanitizeAttribute = { attrName: 'href', attrValue: href };
|
||||
sanitizeHooks.uponSanitizeAttribute?.(anchor, data);
|
||||
sanitizeHooks.afterSanitizeAttributes?.(anchor);
|
||||
|
||||
return data.forceKeepAttr || /^(?:https?|mailto|tel):/i.test(href) ? attribute : '';
|
||||
}),
|
||||
},
|
||||
}));
|
||||
mock.module('./markdown-worker', () => ({
|
||||
@@ -40,6 +73,21 @@ describe('markdown sanitization', () => {
|
||||
expect(isLocalFileUrl('file://remote-host/share/report.html')).toBe(false);
|
||||
expect(isLocalFileUrl('javascript:alert(1)')).toBe(false);
|
||||
});
|
||||
|
||||
test('keeps app and local file links while stripping blocked schemes', () => {
|
||||
const html = renderMarkdownSync([
|
||||
'[app](obsidian://open?vault=Notebook)',
|
||||
'[file](file:///workspace/notes.md)',
|
||||
'[script](javascript:alert(1))',
|
||||
'[diagnostic](ms-msdt:/id%20PCWDiagnostic)',
|
||||
].join('\n\n'), 'inline');
|
||||
|
||||
expect(html).toContain('href="obsidian://open?vault=Notebook"');
|
||||
expect(html).toContain('href="file:///workspace/notes.md"');
|
||||
expect(html).not.toContain('href="javascript:alert(1)"');
|
||||
expect(html).not.toContain('href="ms-msdt:/id%20PCWDiagnostic"');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('Markdown images', () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import remend from 'remend';
|
||||
import katex from 'katex';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { buildAgentMentionUrl, parseAgentHref, parseSkillHref } from '@/lib/messages/inlineMessageLinks';
|
||||
import { isAppLinkUrl } from '@/lib/url';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { contentFingerprint, HighlightResultCache, utf16Bytes } from './highlightResultCache';
|
||||
import { highlightCodeInWorker } from './markdown-worker';
|
||||
@@ -472,7 +473,10 @@ const ensureSanitizeHook = (): void => {
|
||||
sanitizeHookInstalled = true;
|
||||
DOMPurify.addHook('uponSanitizeAttribute', (node, data) => {
|
||||
if (!(node instanceof HTMLAnchorElement) || data.attrName !== 'href') return;
|
||||
if (isLocalFileUrl(data.attrValue)) data.forceKeepAttr = true;
|
||||
// DOMPurify's default URI policy strips custom application schemes
|
||||
// (obsidian://, vscode://, ...). Keep them for anchors; dangerous schemes
|
||||
// stay excluded via isAppLinkUrl and clicks go through confirmation.
|
||||
if (isLocalFileUrl(data.attrValue) || isAppLinkUrl(data.attrValue)) data.forceKeepAttr = true;
|
||||
});
|
||||
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
||||
if (!(node instanceof HTMLAnchorElement)) return;
|
||||
@@ -544,7 +548,10 @@ export const __markdownBlockCacheSizesForTests = (): { full: number; live: numbe
|
||||
live: liveBlockCache.size,
|
||||
});
|
||||
|
||||
const parseBlock = async (block: MarkdownBlock, imageMode: MarkdownImageMode): Promise<string> => {
|
||||
const parseBlock = async (
|
||||
block: MarkdownBlock,
|
||||
imageMode: MarkdownImageMode,
|
||||
): Promise<string> => {
|
||||
const parser = imageMode === 'label' ? imageLabelParser : inlineImageParser;
|
||||
const parsed = await Promise.resolve(parser.parse(block.src));
|
||||
const withMath = renderMathExpressions(parsed);
|
||||
@@ -561,7 +568,10 @@ const parseBlock = async (block: MarkdownBlock, imageMode: MarkdownImageMode): P
|
||||
* is synchronous (marked is not configured `async`), so this never blocks on a
|
||||
* worker round-trip.
|
||||
*/
|
||||
export const renderMarkdownSync = (text: string, imageMode: MarkdownImageMode = 'inline'): string => {
|
||||
export const renderMarkdownSync = (
|
||||
text: string,
|
||||
imageMode: MarkdownImageMode = 'inline',
|
||||
): string => {
|
||||
if (!text) return '';
|
||||
const parser = imageMode === 'label' ? imageLabelParser : inlineImageParser;
|
||||
const parsed = parser.parse(text) as string;
|
||||
|
||||
Reference in New Issue
Block a user