fix: block unsafe mobile editor links
This commit is contained in:
@@ -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&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({
|
||||
|
||||
@@ -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, '')
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user