Skip to content

Commit f386769

Browse files
authored
fix(files): allowlist the schemes a markdown link may target (#7012)
`normalizeLinkHref` rejected only `file` for a `scheme://` target, so any other scheme was returned unchanged. `scheme://` is well-formed for every scheme, so the check let through spellings that are not navigable targets at all. - Keep a scheme only when it is http(s), ftp(s), mailto, or tel; drop the rest - Leave an existing link alone when a committed target normalizes away, rather than unsetting it — the editor seeds that field with the current href, so committing an untouched one previously removed the link Detection is unchanged for relative, anchor, protocol-relative, and bare-domain targets. A document's stored markdown is untouched: normalization runs on the render and edit paths, never on parse or serialize, so a target that is refused still round-trips verbatim.
1 parent 465bdbd commit f386769

4 files changed

Lines changed: 128 additions & 13 deletions

File tree

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity.ts

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -76,21 +76,24 @@ export function applyFrontmatter(frontmatter: string, body: string): string {
7676
return frontmatter + body
7777
}
7878

79-
/** A leading `scheme://` URL (network protocol). */
80-
const SCHEME_URL = /^([a-z][a-z0-9+.-]*):\/\//i
8179
/** A leading `scheme:` token (per the URL grammar). */
8280
const HAS_SCHEME = /^[a-z][a-z0-9+.-]*:/i
8381
/** A bare `host:port` (digits after the colon) — looks scheme-like but is really a domain. */
8482
const HOST_PORT = /^[a-z0-9.-]+:\d+(?:[/?#]|$)/i
8583

84+
/**
85+
* The only schemes a document link may target — an allowlist, because `scheme://` is well-formed for
86+
* every scheme: rejecting just the ones known to be dangerous leaves the next one through, and
87+
* `javascript://…` is a valid URL whose `//` run is merely a comment.
88+
*/
89+
const SAFE_SCHEME = /^(?:(?:https?|ftps?):\/\/|(?:mailto|tel):)/i
90+
8691
/**
8792
* Normalize a user-entered link target: prefix a bare domain with `https://` so it doesn't resolve
8893
* as an in-app relative URL, while leaving already-qualified, relative (`./other.md`, `../doc.md`), and
89-
* protocol-relative URLs intact. Dangerous schemes are rejected outright rather than trusted or mangled:
90-
* any `scheme:` without `//` other than `mailto:`/`tel:` (so `javascript:`, `data:`, `vbscript:`,
91-
* `blob:`, …), and `file://` (local file access). Other network `scheme://` URLs (`http(s)`, `ftp`, …)
92-
* pass through. A bare `host:port` (digits after the colon) is a domain, not a scheme, so it still gets
93-
* the `https://` prefix.
94+
* protocol-relative URLs intact. A scheme is kept only when {@link SAFE_SCHEME} matches; every other
95+
* one is dropped to `''`, which callers render as inert text rather than a link. A bare `host:port`
96+
* (digits after the colon) is a domain, not a scheme, so it still gets the `https://` prefix.
9497
*/
9598
export function normalizeLinkHref(href: string): string {
9699
const trimmed = href.trim()
@@ -99,9 +102,7 @@ export function normalizeLinkHref(href: string): string {
99102
if (trimmed.startsWith('//')) return `https:${trimmed}`
100103
if (trimmed.startsWith('/')) return trimmed
101104
if (trimmed.startsWith('./') || trimmed.startsWith('../')) return trimmed
102-
if (/^(?:mailto|tel):/i.test(trimmed)) return trimmed
103-
const schemed = trimmed.match(SCHEME_URL)
104-
if (schemed) return /^file$/i.test(schemed[1]) ? '' : trimmed
105+
if (SAFE_SCHEME.test(trimmed)) return trimmed
105106
if (HAS_SCHEME.test(trimmed) && !HOST_PORT.test(trimmed)) return ''
106107
return `https://${trimmed}`
107108
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import type { ChainedCommands } from '@tiptap/core'
2+
import { describe, expect, it, vi } from 'vitest'
3+
import { applyLink } from './link-editing'
4+
5+
function chainSpy() {
6+
const calls: string[] = []
7+
const chain = {
8+
extendMarkRange: vi.fn(() => chain),
9+
setLink: vi.fn(({ href }: { href: string }) => {
10+
calls.push(`setLink:${href}`)
11+
return chain
12+
}),
13+
unsetLink: vi.fn(() => {
14+
calls.push('unsetLink')
15+
return chain
16+
}),
17+
run: vi.fn(() => true),
18+
}
19+
return { chain: chain as unknown as ChainedCommands, calls }
20+
}
21+
22+
describe('applyLink', () => {
23+
it('sets a link for a target that survives normalization', () => {
24+
const { chain, calls } = chainSpy()
25+
applyLink(chain, ' sim.ai ')
26+
expect(calls).toEqual(['setLink:https://sim.ai'])
27+
})
28+
29+
it('removes the link when the field is cleared', () => {
30+
const { chain, calls } = chainSpy()
31+
applyLink(chain, ' ')
32+
expect(calls).toEqual(['unsetLink'])
33+
})
34+
35+
/**
36+
* The field is seeded with the raw href, so committing one untouched must not be read as "remove".
37+
* Dropping an unsafe target is a refusal to link, not an instruction to delete what is already there.
38+
*/
39+
it('leaves the existing link untouched when the target normalizes away', () => {
40+
for (const target of ['javascript://%0aalert(1)', 'customproto://host/path']) {
41+
const { chain, calls } = chainSpy()
42+
applyLink(chain, target)
43+
expect(calls).toEqual([])
44+
}
45+
})
46+
})

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-editing.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,17 @@ import { normalizeLinkHref } from '../markdown-fidelity'
44

55
/**
66
* Applies a link to the chain's current selection: normalizes `rawHref`, expands to the full link
7-
* mark, and sets it — or removes the link when the href is empty/unsafe. The caller supplies a chain
8-
* already focused with the target selection (the captured bubble-menu range / the hovered link range).
7+
* mark, and sets it. Clearing the field removes the link; a target that survives normalization
8+
* replaces it. A target that normalizes away is neither set nor removed — the editor seeds this field
9+
* with the raw href, so committing an untouched one would otherwise delete a link the user only
10+
* opened, and dropping an unsafe target is not the same instruction as "remove this link". The
11+
* caller supplies a chain already focused with the target selection (the captured bubble-menu range /
12+
* the hovered link range).
913
*/
1014
export function applyLink(chain: ChainedCommands, rawHref: string): void {
11-
const href = normalizeLinkHref(rawHref.trim())
15+
const trimmed = rawHref.trim()
16+
const href = normalizeLinkHref(trimmed)
17+
if (!href && trimmed) return
1218
chain.extendMarkRange('link')
1319
if (href) chain.setLink({ href })
1420
else chain.unsetLink()

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
* be idempotent (a second pass changes nothing) so autosave never churns. Mirrors the exact
66
* pipeline the editor uses: split frontmatter out, serialize the body, re-attach + clean up.
77
*/
8+
import type { JSONContent } from '@tiptap/core'
89
import { Editor } from '@tiptap/core'
910
import { afterEach, describe, expect, it } from 'vitest'
1011
import { createMarkdownContentExtensions } from './extensions'
@@ -14,6 +15,7 @@ import {
1415
postProcessSerializedMarkdown,
1516
splitFrontmatter,
1617
} from './markdown-fidelity'
18+
import { parseMarkdownToDoc } from './markdown-parse'
1719

1820
let editor: Editor | null = null
1921

@@ -126,6 +128,66 @@ describe('markdown-fidelity utils', () => {
126128
expect(normalizeLinkHref('blob:https://x.com/uuid')).toBe('')
127129
expect(normalizeLinkHref('vbscript:msgbox(1)')).toBe('')
128130
expect(normalizeLinkHref('localhost:3000/path')).toBe('https://localhost:3000/path')
131+
// Adding `//` doesn't make a scheme safe, and an unknown scheme is dropped rather than trusted —
132+
// the allowlist is the whole rule.
133+
expect(normalizeLinkHref('javascript://%0aalert(1)')).toBe('')
134+
expect(normalizeLinkHref('customproto://host/path')).toBe('')
135+
})
136+
137+
/**
138+
* The property that matters, stated over the spellings a browser collapses before it resolves a
139+
* scheme: whatever comes back must not be executable. Padding and interior tabs/newlines are the
140+
* usual way a blocked scheme is smuggled past a matcher that only reads the literal text.
141+
*/
142+
it('never returns a target that resolves to an executable scheme', () => {
143+
const tab = String.fromCharCode(9)
144+
const lf = String.fromCharCode(10)
145+
const nbsp = String.fromCharCode(160)
146+
const inputs = [
147+
'javascript://%0aalert(1)',
148+
'javascript:alert(1)',
149+
'JAVASCRIPT://x',
150+
' javascript:alert(1) ',
151+
`${nbsp}javascript:alert(1)`,
152+
`java${tab}script://alert(1)`,
153+
`java${lf}script:alert(1)`,
154+
'data://text/html,<script>',
155+
'vbscript://x',
156+
'blob://x',
157+
'file://x',
158+
]
159+
160+
const executable = inputs.filter((input) =>
161+
/^(?:javascript|data|vbscript|blob|file):/.test(
162+
normalizeLinkHref(input)
163+
.replace(/[\t\n\r]/g, '')
164+
.toLowerCase()
165+
)
166+
)
167+
expect(executable).toEqual([])
168+
})
169+
170+
/**
171+
* A linked image carries its target in a node attribute rather than a link mark, so the mark's own
172+
* URI validation never sees it and the raw target survives parsing — which is correct, since the
173+
* document must serialize back verbatim. `image.tsx` builds its anchor from
174+
* `normalizeLinkHref(attrs.href)` and omits the anchor entirely when that is empty, so this is the
175+
* step that decides whether the target ever reaches the DOM.
176+
*/
177+
it('drops a dangerous linked-image target before it can reach an anchor', () => {
178+
const doc = parseMarkdownToDoc('[![a](https://x.example/i.png)](javascript://%0aalert(1))')
179+
const hrefs: string[] = []
180+
const walk = (node: JSONContent) => {
181+
if (node.type === 'image' && typeof node.attrs?.href === 'string') hrefs.push(node.attrs.href)
182+
node.content?.forEach(walk)
183+
}
184+
walk(doc)
185+
186+
// The parser preserves the authored target — serialization round-trips it verbatim.
187+
expect(hrefs).toHaveLength(1)
188+
expect(hrefs[0]).toContain('javascript://')
189+
// …and the renderer refuses to build an anchor out of it.
190+
expect(normalizeLinkHref(hrefs[0])).toBe('')
129191
})
130192

131193
it('collapses trailing blank lines and preserves leading whitespace', () => {

0 commit comments

Comments
 (0)