Skip to content

Commit 8672a65

Browse files
authored
fix(ui): url sanitization link popover (#20)
* fix(ui): url sanitization link popover * chore(deps): bump package version
1 parent 7861b2d commit 8672a65

File tree

4 files changed

+179
-128
lines changed

4 files changed

+179
-128
lines changed

apps/web/src/components/tiptap-ui/link-popover/link-popover.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { LinkIcon } from "@/components/tiptap-icons/link-icon"
1111
import { TrashIcon } from "@/components/tiptap-icons/trash-icon"
1212

1313
// --- Lib ---
14-
import { isMarkInSchema } from "@/lib/tiptap-utils"
14+
import { isMarkInSchema, sanitizeUrl } from "@/lib/tiptap-utils"
1515

1616
// --- UI Primitives ---
1717
import type { ButtonProps } from "@/components/tiptap-ui-primitive/button"
@@ -151,6 +151,15 @@ const LinkMain: React.FC<LinkMainProps> = ({
151151
}
152152
}
153153

154+
const handleOpenLink = () => {
155+
if (!url) return
156+
157+
const safeUrl = sanitizeUrl(url, window.location.href)
158+
if (safeUrl !== "#") {
159+
window.open(safeUrl, "_blank", "noopener,noreferrer")
160+
}
161+
}
162+
154163
return (
155164
<>
156165
<input
@@ -182,7 +191,7 @@ const LinkMain: React.FC<LinkMainProps> = ({
182191
<div className="tiptap-button-group" data-orientation="horizontal">
183192
<Button
184193
type="button"
185-
onClick={() => window.open(url, "_blank")}
194+
onClick={handleOpenLink}
186195
title="Open in new window"
187196
disabled={!url && !isActive}
188197
data-style="ghost"

apps/web/src/lib/tiptap-utils.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,3 +208,83 @@ export const convertFileToBase64 = (
208208
reader.readAsDataURL(file)
209209
})
210210
}
211+
212+
type ProtocolOptions = {
213+
/**
214+
* The protocol scheme to be registered.
215+
* @default '''
216+
* @example 'ftp'
217+
* @example 'git'
218+
*/
219+
scheme: string
220+
221+
/**
222+
* If enabled, it allows optional slashes after the protocol.
223+
* @default false
224+
* @example true
225+
*/
226+
optionalSlashes?: boolean
227+
}
228+
229+
type ProtocolConfig = Array<ProtocolOptions | string>
230+
231+
const ATTR_WHITESPACE =
232+
// eslint-disable-next-line no-control-regex
233+
/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g
234+
235+
export function isAllowedUri(
236+
uri: string | undefined,
237+
protocols?: ProtocolConfig
238+
) {
239+
const allowedProtocols: string[] = [
240+
"http",
241+
"https",
242+
"ftp",
243+
"ftps",
244+
"mailto",
245+
"tel",
246+
"callto",
247+
"sms",
248+
"cid",
249+
"xmpp",
250+
]
251+
252+
if (protocols) {
253+
protocols.forEach((protocol) => {
254+
const nextProtocol =
255+
typeof protocol === "string" ? protocol : protocol.scheme
256+
257+
if (nextProtocol) {
258+
allowedProtocols.push(nextProtocol)
259+
}
260+
})
261+
}
262+
263+
return (
264+
!uri ||
265+
uri.replace(ATTR_WHITESPACE, "").match(
266+
new RegExp(
267+
// eslint-disable-next-line no-useless-escape
268+
`^(?:(?:${allowedProtocols.join("|")}):|[^a-z]|[a-z0-9+.\-]+(?:[^a-z+.\-:]|$))`,
269+
"i"
270+
)
271+
)
272+
)
273+
}
274+
275+
export function sanitizeUrl(
276+
inputUrl: string,
277+
baseUrl: string,
278+
protocols?: ProtocolConfig
279+
): string {
280+
try {
281+
const url = new URL(inputUrl, baseUrl)
282+
283+
if (isAllowedUri(url.href, protocols)) {
284+
return url.href
285+
}
286+
} catch {
287+
// If URL creation fails, it's considered invalid
288+
}
289+
return "#"
290+
}

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@
1313
},
1414
"devDependencies": {
1515
"eslint": "^8.57.1",
16-
"prettier": "^3.5.3",
17-
"turbo": "^2.5.3"
16+
"prettier": "^3.6.1",
17+
"turbo": "^2.5.4"
1818
},
1919
"packageManager": "[email protected]"
2020
}

0 commit comments

Comments
 (0)