fix: block unsafe mobile editor links

This commit is contained in:
lucaronin
2026-05-05 13:54:00 +02:00
parent 0f33e8958b
commit 72f526e3f8
3 changed files with 85 additions and 2 deletions

View File

@@ -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: [
'<p><a href="https://tolaria.app?ref=notes&amp;device=ios">Website</a></p>',
'<p><a href="mailto:hello@tolaria.app">Email</a></p>',
'<p><a href="notes/workflow.md">Relative note</a></p>',
].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: '<p><a href="javascript:alert(1)">Unsafe</a></p>',
}),
).toMatchObject({
noteId: 'links',
persistable: false,
blockedReason: 'unsupportedEditorHtml',
})
})
it('serializes simple TenTap tables as Markdown tables', () => {
expect(
createMobileEditorDraft({

View File

@@ -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(/<code(?:\s[^>]*)?>([\s\S]*?)<\/code>/gi, '`$1`')
.replace(/<a(?:\s[^>]*)?href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi, '[$2]($1)')
.replace(/<a\b[^>]*>([\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(/<img\b[^>]*>/gi)].some((match) => !imageMarkdown({ tag: match[0] }))
}
function containsUnsafeLink(input: HtmlInput) {
return [...input.html.matchAll(/<a\b[^>]*>/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, '')
}

View File

@@ -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