From 72f526e3f8366a5898812f1496c8d286e2528daa Mon Sep 17 00:00:00 2001 From: lucaronin Date: Tue, 5 May 2026 13:54:00 +0200 Subject: [PATCH] fix: block unsafe mobile editor links --- apps/mobile/src/mobileEditorDraft.test.ts | 43 +++++++++++++++++++++ apps/mobile/src/mobileEditorHtmlMarkdown.ts | 39 ++++++++++++++++++- docs/MOBILE_PROGRESS.md | 5 +++ 3 files changed, 85 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/mobileEditorDraft.test.ts b/apps/mobile/src/mobileEditorDraft.test.ts index bfccc4fc..1769daa3 100644 --- a/apps/mobile/src/mobileEditorDraft.test.ts +++ b/apps/mobile/src/mobileEditorDraft.test.ts @@ -80,6 +80,32 @@ describe('mobile editor draft', () => { }) }) + it('serializes safe link destinations and decodes escaped link URLs', () => { + const draft = createMobileEditorDraft({ + note: { + id: 'links', + title: 'Links', + content: '# Links', + }, + editorHtml: [ + '

Website

', + '

Email

', + '

Relative note

', + ].join(''), + }) + + expect(draft).toMatchObject({ + persistable: true, + canonicalMarkdown: [ + '[Website](https://tolaria.app?ref=notes&device=ios)', + '', + '[Email](mailto:hello@tolaria.app)', + '', + '[Relative note](notes/workflow.md)', + ].join('\n'), + }) + }) + it('serializes blockquotes, code blocks, and strikethrough', () => { const draft = createMobileEditorDraft({ note: { @@ -149,6 +175,23 @@ describe('mobile editor draft', () => { }) }) + it('blocks unsafe link destinations instead of persisting risky Markdown', () => { + expect( + createMobileEditorDraft({ + note: { + id: 'links', + title: 'Links', + content: '# Links', + }, + editorHtml: '

Unsafe

', + }), + ).toMatchObject({ + noteId: 'links', + persistable: false, + blockedReason: 'unsupportedEditorHtml', + }) + }) + it('serializes simple TenTap tables as Markdown tables', () => { expect( createMobileEditorDraft({ diff --git a/apps/mobile/src/mobileEditorHtmlMarkdown.ts b/apps/mobile/src/mobileEditorHtmlMarkdown.ts index e9da0aec..e408b130 100644 --- a/apps/mobile/src/mobileEditorHtmlMarkdown.ts +++ b/apps/mobile/src/mobileEditorHtmlMarkdown.ts @@ -185,7 +185,7 @@ function markInlineHtml(input: HtmlInput) { .replace(/<(em|i)(?:\s[^>]*)?>([\s\S]*?)<\/\1>/gi, '*$2*') .replace(/<(s|strike|del)(?:\s[^>]*)?>([\s\S]*?)<\/\1>/gi, '~~$2~~') .replace(/]*)?>([\s\S]*?)<\/code>/gi, '`$1`') - .replace(/]*)?href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi, '[$2]($1)') + .replace(/]*>([\s\S]*?)<\/a>/gi, (tag, label) => linkMarkdown({ tag, label }) ?? tag) } function containsUnsupportedTag(input: HtmlInput) { @@ -194,13 +194,17 @@ function containsUnsupportedTag(input: HtmlInput) { } function blocksUnsafeEditorOutput(input: HtmlInput) { - return containsUnsupportedTag(input) || containsUnsafeImage(input) || containsUnsafeTable(input) + return containsUnsupportedTag(input) || containsUnsafeImage(input) || containsUnsafeLink(input) || containsUnsafeTable(input) } function containsUnsafeImage(input: HtmlInput) { return [...input.html.matchAll(/]*>/gi)].some((match) => !imageMarkdown({ tag: match[0] })) } +function containsUnsafeLink(input: HtmlInput) { + return [...input.html.matchAll(/]*>/gi)].some((match) => !linkMarkdown({ tag: match[0], label: '' })) +} + function containsUnsafeTable(input: HtmlInput) { return Boolean(isMobileEditorTableBlock(input)) && !canSerializeMobileEditorTable(input) } @@ -220,6 +224,16 @@ function imageSource(input: { tag: string }) { return src && isPersistableImageSource({ src }) ? src : null } +function linkMarkdown(input: { tag: string; label: string }) { + const href = linkDestination(input) + return href ? `[${input.label}](${href})` : null +} + +function linkDestination(input: { tag: string }) { + const href = htmlAttribute({ tag: input.tag, name: 'href' }) + return href && isPersistableLinkDestination({ href }) ? decodeHtmlEntities({ text: href }) : null +} + function htmlAttribute(input: { tag: string; name: string }) { const match = input.tag.match(new RegExp(`${input.name}=["']([^"']+)["']`, 'i')) return match?.[1] ?? null @@ -241,6 +255,27 @@ function isRelativeImageSource(input: { src: string }) { return !input.src.startsWith('/') && !input.src.startsWith('//') && !input.src.match(/^[A-Za-z][A-Za-z0-9+.-]*:/) } +function isPersistableLinkDestination(input: { href: string }) { + const href = decodeHtmlEntities({ text: input.href }) + if (href.match(/[\n\r]/)) { + return false + } + + return isRemoteLinkDestination({ href }) || isMailLinkDestination({ href }) || isRelativeLinkDestination({ href }) +} + +function isRemoteLinkDestination(input: { href: string }) { + return input.href.startsWith('https://') || input.href.startsWith('http://') +} + +function isMailLinkDestination(input: { href: string }) { + return input.href.startsWith('mailto:') +} + +function isRelativeLinkDestination(input: { href: string }) { + return !input.href.startsWith('/') && !input.href.startsWith('//') && !input.href.match(/^[A-Za-z][A-Za-z0-9+.-]*:/) +} + function stripRemainingTags(value: string) { return value.replace(/<[^>]+>/g, '') } diff --git a/docs/MOBILE_PROGRESS.md b/docs/MOBILE_PROGRESS.md index 126437cf..d1eef4df 100644 --- a/docs/MOBILE_PROGRESS.md +++ b/docs/MOBILE_PROGRESS.md @@ -98,6 +98,7 @@ This file is the resumable working log for Tolaria mobile. The strategy and road - Separated the mobile Git remote setup prompt styles from note-creation prompt styles so vault-management UI can evolve without coupling to compose UI names. - Added a sidebar vault-management card that shows the active app-local vault, Git sync state, and an explicit Git remote action on iPad and compact sidebar surfaces. - Added the first mobile Git transport execution boundary behind the existing sync/auth plan, including explicit pull/push routing and a visible unavailable-transport failure until the native Git implementation lands. +- Hardened TenTap link serialization so safe HTTP, mailto, and relative links persist while unsafe link destinations block draft persistence. ## Next Action @@ -371,6 +372,10 @@ Continue Phase 4 with editor durability: - `pnpm --filter @tolaria/mobile typecheck` passed after adding the mobile Git transport execution boundary. - CodeScene after adding the mobile Git transport execution boundary: `apps/mobile/src/MobileApp.tsx`, `apps/mobile/src/useMobileGitSyncFlow.ts`, `apps/mobile/src/mobileGitSyncPlan.ts`, `apps/mobile/src/mobileGitPrimaryAction.ts`, `apps/mobile/src/mobileGitPrimaryAction.test.ts`, `apps/mobile/src/mobileGitTransport.ts`, `apps/mobile/src/mobileGitTransport.test.ts`, `apps/mobile/src/mobileGitSyncFlowAction.ts`, and `apps/mobile/src/mobileGitSyncFlowAction.test.ts` scored `10`. - `pnpm --filter @tolaria/mobile exec expo export --platform ios --output-dir /tmp/tolaria-mobile-export` passed after adding the mobile Git transport execution boundary. +- `pnpm --filter @tolaria/mobile test -- src/mobileEditorDraft.test.ts src/mobileEditorDraftSave.test.ts` passed after hardening TenTap link serialization: 43 files / 145 tests. +- `pnpm --filter @tolaria/mobile typecheck` passed after hardening TenTap link serialization. +- CodeScene after hardening TenTap link serialization: `apps/mobile/src/mobileEditorHtmlMarkdown.ts` and `apps/mobile/src/mobileEditorDraft.test.ts` scored `10`. +- `pnpm --filter @tolaria/mobile exec expo export --platform ios --output-dir /tmp/tolaria-mobile-export` passed after hardening TenTap link serialization. ## Risks / Watch Items