refac: validate the URL scheme before rendering a link href (#29890)

Link URLs rendered from markdown, citations and web search results now pass a scheme check before they reach an anchor. A new safeLinkUrl helper sits beside isValidHttpUrl and keeps http, https, mailto, tel and relative URLs, returning undefined for anything else so the label renders without a link. Markdown links with an unusual scheme (ftp, sms, and application deep links such as obsidian or vscode) render as plain text from now on.

The citation checks move off a substring test for "http" onto isValidHttpUrl, which is what Citations.svelte already uses for the same question. That also drops two long-standing quirks: an uppercase HTTP:// source never rendered as a link, and a filename merely containing "http" rendered as a dead external one.
This commit is contained in:
Classic298 2026-09-12 23:06:58 +02:00 committed by GitHub
parent 746caa7c78
commit 2a44c9d384
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 28 additions and 10 deletions

View file

@ -6,6 +6,7 @@
import { WEBUI_API_BASE_URL } from '$lib/constants';
import { settings, config } from '$lib/stores';
import { injectCsp } from '$lib/utils/csp';
import { isValidHttpUrl } from '$lib/utils';
import XMark from '$lib/components/icons/XMark.svelte';
import Textarea from '$lib/components/common/Textarea.svelte';
@ -71,7 +72,7 @@
const baseUrl = file_id
? `${WEBUI_API_BASE_URL}/files/${file_id}/content${page !== undefined ? `#page=${page + 1}` : ''}`
: sourceUrl?.includes('http')
: isValidHttpUrl(sourceUrl)
? sourceUrl
: null;
@ -101,10 +102,10 @@
<div class=" text-sm font-medium self-center flex items-center">
{#if citation?.source?.name}
{@const document = mergedDocuments?.[0]}
{#if document?.metadata?.file_id || document.source?.url?.includes('http')}
{#if document?.metadata?.file_id || isValidHttpUrl(document.source?.url)}
<Tooltip
className="w-fit"
content={document.source?.url?.includes('http')
content={isValidHttpUrl(document.source?.url)
? $i18n.t('Open link')
: $i18n.t('Open file')}
placement="top-start"
@ -114,7 +115,7 @@
class="hover:text-gray-500 dark:hover:text-gray-100 underline grow line-clamp-1"
href={document?.metadata?.file_id
? `${WEBUI_API_BASE_URL}/files/${document?.metadata?.file_id}/content${document?.metadata?.page !== undefined ? `#page=${document.metadata.page + 1}` : ''}`
: document.source?.url?.includes('http')
: isValidHttpUrl(document.source?.url)
? document.source.url
: `#`}
target="_blank"
@ -161,7 +162,7 @@
<div
class=" text-sm font-normal dark:text-gray-300 flex items-center gap-2 w-fit mb-1"
>
{#if document.source?.url?.includes('http')}
{#if isValidHttpUrl(document.source?.url)}
{@const snippetUrl = getTextFragmentUrl(document)}
{#if snippetUrl}
<a

View file

@ -9,7 +9,7 @@
const i18n = getContext('i18n');
import { WEBUI_BASE_URL } from '$lib/constants';
import { copyToClipboard, unescapeHtml } from '$lib/utils';
import { copyToClipboard, safeLinkUrl, unescapeHtml } from '$lib/utils';
import Image from '$lib/components/common/Image.svelte';
import KatexRenderer from './KatexRenderer.svelte';
@ -74,11 +74,12 @@
<HtmlToken {id} {token} {onSourceClick} />
{:else if token.type === 'link'}
{@const noteId = getNoteIdFromHref(token.href)}
{@const safeHref = safeLinkUrl(token.href)}
{#if noteId}
<NoteLinkToken {noteId} href={token.href} />
{:else if token.tokens}
<a
href={token.href}
href={safeHref}
target="_blank"
rel="nofollow"
title={token.title}
@ -88,7 +89,7 @@
</a>
{:else}
<a
href={token.href}
href={safeHref}
target="_blank"
rel="nofollow"
title={token.title}

View file

@ -1,4 +1,6 @@
<script lang="ts">
import { safeLinkUrl } from '$lib/utils';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
import Search from '$lib/components/icons/Search.svelte';
@ -59,7 +61,7 @@
{#if status?.items}
{#each status.items as item, itemIdx}
<a
href={item.link}
href={safeLinkUrl(item.link)}
target="_blank"
class="flex w-full items-center p-1 px-3 group/item justify-between text-gray-800 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-850 rounded-lg font-normal! no-underline! mb-1"
>
@ -99,7 +101,7 @@
{:else if status?.urls}
{#each status.urls as url, urlIdx}
<a
href={url}
href={safeLinkUrl(url)}
target="_blank"
class="flex w-full items-center p-1 px-3 group/item justify-between text-gray-800 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-850 rounded-lg no-underline mb-1"
>

View file

@ -981,6 +981,20 @@ export const isValidHttpUrl = (string: string) => {
return url.protocol === 'http:' || url.protocol === 'https:';
};
const SAFE_LINK_PROTOCOLS = ['http:', 'https:', 'mailto:', 'tel:'];
export const safeLinkUrl = (url: string): string | undefined => {
let protocol;
try {
protocol = new URL(url).protocol;
} catch (_) {
// No scheme to parse, so the browser resolves it against our own origin.
return url;
}
return SAFE_LINK_PROTOCOLS.includes(protocol) ? url : undefined;
};
export const isYoutubeUrl = (url: string) => {
return (
url.startsWith('https://www.youtube.com') ||