fix(security): restrict external URL opening

Close: 7800
This commit is contained in:
Tunglies
2026-08-25 16:11:47 +08:00
parent b7c4f7d39b
commit 695fadf21e
17 changed files with 136 additions and 61 deletions
Generated
+23
View File
@@ -1170,6 +1170,7 @@ dependencies = [
"tauri-plugin-http",
"tauri-plugin-mihomo",
"tauri-plugin-notification",
"tauri-plugin-opener",
"tauri-plugin-process",
"tauri-plugin-shell",
"tauri-plugin-updater",
@@ -7462,6 +7463,28 @@ dependencies = [
"url",
]
[[package]]
name = "tauri-plugin-opener"
version = "2.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17e1bea14edce6b793a04e2417e3fd924b9bc4faae83cdee7d714156cceeed29"
dependencies = [
"dunce",
"glob",
"objc2-app-kit",
"objc2-foundation",
"open",
"schemars 0.8.22",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.20",
"url",
"windows 0.61.3",
"zbus",
]
[[package]]
name = "tauri-plugin-process"
version = "2.3.1"
+1 -1
View File
@@ -56,7 +56,7 @@
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-fs": "^2.5.1",
"@tauri-apps/plugin-http": "~2.5.9",
"@tauri-apps/plugin-shell": "2.3.5",
"@tauri-apps/plugin-opener": "2.5.4",
"@tauri-apps/plugin-updater": "2.10.1",
"ahooks": "^3.9.7",
"cidr-block": "^2.3.0",
+6 -6
View File
@@ -53,9 +53,9 @@ importers:
'@tauri-apps/plugin-http':
specifier: ~2.5.9
version: 2.5.9
'@tauri-apps/plugin-shell':
specifier: 2.3.5
version: 2.3.5
'@tauri-apps/plugin-opener':
specifier: 2.5.4
version: 2.5.4
'@tauri-apps/plugin-updater':
specifier: 2.10.1
version: 2.10.1
@@ -1874,8 +1874,8 @@ packages:
'@tauri-apps/plugin-http@2.5.9':
resolution: {integrity: sha512-lCiY0+vs4HvIUSvZrBs8TC3TiCB0MOPRmiUjTq4prW7SlcJE2jdLeT6KBsJrT9Tlplufl7W1pY6SFAO3gCWxDA==}
'@tauri-apps/plugin-shell@2.3.5':
resolution: {integrity: sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==}
'@tauri-apps/plugin-opener@2.5.4':
resolution: {integrity: sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ==}
'@tauri-apps/plugin-updater@2.10.1':
resolution: {integrity: sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==}
@@ -5830,7 +5830,7 @@ snapshots:
dependencies:
'@tauri-apps/api': 2.11.1
'@tauri-apps/plugin-shell@2.3.5':
'@tauri-apps/plugin-opener@2.5.4':
dependencies:
'@tauri-apps/api': 2.11.1
+1
View File
@@ -74,6 +74,7 @@ sysproxy = { git = "https://github.com/clash-verge-rev/sysproxy-rs.git", rev = "
] }
network-interface = { version = "2.0.5", features = ["serde"] }
tauri-plugin-shell = "2.3.5"
tauri-plugin-opener = "2.5.4"
tauri-plugin-dialog = "2.7.2"
tauri-plugin-fs = "2.5.1"
tauri-plugin-process = "2.3.1"
+4 -6
View File
@@ -63,11 +63,10 @@
"core:window:allow-set-maximizable",
"core:window:allow-set-minimizable",
"core:webview:allow-print",
"shell:allow-execute",
"shell:allow-open",
"shell:allow-kill",
"shell:allow-spawn",
"shell:allow-stdin-write",
{
"identifier": "opener:allow-open-url",
"allow": [{ "url": "http://*" }, { "url": "https://*" }]
},
"dialog:allow-open",
"global-shortcut:allow-is-registered",
"global-shortcut:allow-register",
@@ -77,7 +76,6 @@
"process:allow-exit",
"clipboard-manager:allow-read-text",
"clipboard-manager:allow-write-text",
"shell:default",
"dialog:default"
]
}
-5
View File
@@ -23,11 +23,6 @@ pub async fn open_logs_dir() -> CmdResult<()> {
open::that(log_dir).stringify_err()
}
#[tauri::command]
pub fn open_web_url(url: String) -> CmdResult<()> {
open::that(url.as_str()).stringify_err()
}
#[tauri::command]
pub fn open_devtools(app_handle: AppHandle) {
if let Some(window) = app_handle.get_webview_window("main") {
+15 -7
View File
@@ -16,6 +16,17 @@ use tokio::fs;
use reqwest_dav::re_exports::url::form_urlencoded;
use tauri::Url;
pub(super) fn normalize_profile_home_url(raw: &str) -> Option<String> {
let url = Url::parse(raw.trim()).ok()?;
if !matches!(url.scheme(), "http" | "https") {
return None;
}
url.host_str()?;
Some(url.to_string().into())
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct PrfItem {
pub uid: Option<String>,
@@ -332,13 +343,10 @@ impl PrfItem {
},
};
let home = match header.get("profile-web-page-url") {
Some(value) => {
let str_value = value.to_str().unwrap_or("");
Some(str_value.into())
}
None => None,
};
let home = header
.get("profile-web-page-url")
.and_then(|value| value.to_str().ok())
.and_then(normalize_profile_home_url);
let uid = help::get_uid("R").into();
let file = format!("{uid}.yaml").into();
+15 -1
View File
@@ -1,6 +1,6 @@
use super::{
PrfOption,
prfitem::{PrfItem, PrfSelected},
prfitem::{PrfItem, PrfSelected, normalize_profile_home_url},
};
use crate::{
core::{handle, tray::Tray},
@@ -106,6 +106,7 @@ impl IProfiles {
match help::read_yaml::<Self>(&path).await {
Ok(mut profiles) => {
let items = profiles.items.get_or_insert_with(Vec::new);
let mut home_changed = false;
for item in items.iter_mut() {
if item.uid.is_none() {
item.uid = Some(help::get_uid("d").into());
@@ -116,6 +117,19 @@ impl IProfiles {
.allow_auto_update
.get_or_insert(true);
}
if item
.home
.as_deref()
.is_some_and(|home| normalize_profile_home_url(home).is_none())
{
item.home = None;
home_changed = true;
}
}
if home_changed && let Err(err) = profiles.save_file().await {
logging!(error, Type::Config, "无法保存已清理的 profiles.yaml: {err}");
}
profiles
}
+1 -1
View File
@@ -51,6 +51,7 @@ mod app_init {
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_deep_link::init())
.plugin(tauri_plugin_http::init())
.plugin(
@@ -128,7 +129,6 @@ mod app_init {
cmd::get_embedded_server_port,
cmd::open_app_dir,
cmd::open_logs_dir,
cmd::open_web_url,
cmd::open_core_dir,
cmd::get_portable_flag,
cmd::get_network_interfaces,
+8 -3
View File
@@ -25,8 +25,9 @@ import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router'
import { useAppRefreshers } from '@/providers/app-data-context'
import { openWebUrl, updateProfile } from '@/services/cmds'
import { updateProfile } from '@/services/cmds'
import { showNotice } from '@/services/notice-service'
import { openExternalUrl } from '@/utils/open-external-url'
import parseTraffic from '@/utils/parse-traffic'
import { EnhancedCard } from './enhanced-card'
@@ -49,6 +50,10 @@ const parseExpire = (expire?: number) => {
return dayjs(expire * 1000).format('YYYY-MM-DD')
}
const openProfileHome = (url: string) => {
void openExternalUrl(url).catch(showNotice.error)
}
// 使用类型定义,而不是导入
interface ProfileExtra {
upload: number
@@ -115,7 +120,7 @@ const ProfileDetails = ({
{current.home ? (
<Link
component="button"
onClick={() => current.home && openWebUrl(current.home)}
onClick={() => current.home && openProfileHome(current.home)}
sx={{
display: 'inline-flex',
alignItems: 'center',
@@ -318,7 +323,7 @@ export const HomeProfileCard = ({
<Link
component="button"
variant="h6"
onClick={() => current.home && openWebUrl(current.home)}
onClick={() => current.home && openProfileHome(current.home)}
sx={{
color: 'inherit',
textDecoration: 'none',
+3 -2
View File
@@ -18,7 +18,6 @@ import {
MenuItem,
Typography,
} from '@mui/material'
import { open } from '@tauri-apps/plugin-shell'
import { useLockFn } from 'ahooks'
import dayjs from 'dayjs'
import {
@@ -47,6 +46,7 @@ import { showNotice } from '@/services/notice-service'
import { useLoadingCache, useSetLoadingCache } from '@/services/states'
import type { TranslationKey } from '@/types/generated/i18n-keys'
import { debugLog } from '@/utils/debug'
import { openExternalUrl } from '@/utils/open-external-url'
import parseTraffic from '@/utils/parse-traffic'
import { ProfileBox } from './profile-box'
@@ -316,7 +316,8 @@ const ProfileItemBase = (props: ProfileItemProps) => {
const onOpenHome = () => {
setAnchorEl(null)
open(itemData.home ?? '')
if (!itemData.home) return
void openExternalUrl(itemData.home).catch(showNotice.error)
}
const onEditInfo = () => {
+24 -5
View File
@@ -1,5 +1,4 @@
import { alpha, Box, Button, LinearProgress } from '@mui/material'
import { open as openUrl } from '@tauri-apps/plugin-shell'
import type { DownloadEvent } from '@tauri-apps/plugin-updater'
import { useLockFn } from 'ahooks'
import type { Ref } from 'react'
@@ -19,6 +18,7 @@ import { useUpdate } from '@/hooks/use-update'
import { restartApp } from '@/services/cmds'
import { showNotice } from '@/services/notice-service'
import { useSetUpdateState, useUpdateState } from '@/services/states'
import { openExternalUrl } from '@/utils/open-external-url'
type MarkdownNode = {
type: string
@@ -151,6 +151,10 @@ export function UpdateViewer({ ref }: { ref?: Ref<DialogRef> }) {
const downloadedRef = useRef(0)
const totalRef = useRef(0)
const openUrlWithNotice = (url: string) => {
void openExternalUrl(url).catch(showNotice.error)
}
const progress = useMemo(() => {
if (total <= 0) return 0
return Math.min((downloaded / total) * 100, 100)
@@ -276,7 +280,7 @@ export function UpdateViewer({ ref }: { ref?: Ref<DialogRef> }) {
size="small"
sx={{ whiteSpace: 'nowrap' }}
onClick={() => {
openUrl(
openUrlWithNotice(
`https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/v${updateInfo?.version}`,
)
}}
@@ -420,10 +424,25 @@ export function UpdateViewer({ ref }: { ref?: Ref<DialogRef> }) {
<LazyReactMarkdown
remarkPlugins={[remarkGitHubAlertsPlugin]}
components={{
a: ({ ...props }) => {
const { children } = props
a: ({ href, children, ...props }) => {
return (
<a {...props} target="_blank" rel="noreferrer">
<a
{...props}
href={href ? '#' : undefined}
target={undefined}
rel={undefined}
onClick={(event) => {
event.preventDefault()
if (!href) return
openUrlWithNotice(href)
}}
onAuxClick={(event) => {
event.preventDefault()
if (event.button === 1 && href) {
openUrlWithNotice(href)
}
}}
>
{children}
</a>
)
@@ -7,8 +7,8 @@ import { useTranslation } from 'react-i18next'
import { BaseDialog, BaseEmpty, DialogRef } from '@/components/base'
import { useClashInfo } from '@/hooks/use-clash'
import { useVerge } from '@/hooks/use-verge'
import { openWebUrl } from '@/services/cmds'
import { showNotice } from '@/services/notice-service'
import { openExternalUrl } from '@/utils/open-external-url'
import { WebUIItem } from './web-ui-item'
@@ -98,7 +98,7 @@ export function WebUIViewer({ ref }: { ref?: Ref<DialogRef> }) {
)
}
await openWebUrl(url)
await openExternalUrl(url)
} catch (e: any) {
showNotice.error('settings.modals.webUI.errors.openFailed', e)
}
+8 -4
View File
@@ -34,7 +34,9 @@ import { HomeProfileCard } from '@/components/home/home-profile-card'
import { ProxyTunCard } from '@/components/home/proxy-tun-card'
import { useProfiles } from '@/hooks/use-profiles'
import { useVerge } from '@/hooks/use-verge'
import { entry_lightweight_mode, openWebUrl } from '@/services/cmds'
import { entry_lightweight_mode } from '@/services/cmds'
import { showNotice } from '@/services/notice-service'
import { openExternalUrl } from '@/utils/open-external-url'
const preloadTestCard = () =>
import('@/components/home/test-card').then((module) => ({
@@ -240,9 +242,11 @@ const HomePage = () => {
(verge?.home_cards as HomeCardsSettings | undefined) ?? DEFAULT_HOME_CARDS
// 文档链接函数
const toGithubDoc = useLockFn(() => {
return openWebUrl('https://clash-verge-rev.github.io/index.html')
})
const toGithubDoc = useLockFn(() =>
openExternalUrl('https://clash-verge-rev.github.io/index.html').catch(
showNotice.error,
),
)
// 新增:打开设置弹窗
const openSettings = useCallback(() => {
+14 -10
View File
@@ -8,9 +8,9 @@ import SettingClash from '@/components/setting/setting-clash'
import SettingSystem from '@/components/setting/setting-system'
import SettingVergeAdvanced from '@/components/setting/setting-verge-advanced'
import SettingVergeBasic from '@/components/setting/setting-verge-basic'
import { openWebUrl } from '@/services/cmds'
import { showNotice } from '@/services/notice-service'
import { useThemeMode } from '@/services/states'
import { openExternalUrl } from '@/utils/open-external-url'
const SettingPage = () => {
const { t } = useTranslation()
@@ -19,17 +19,21 @@ const SettingPage = () => {
showNotice.error(err)
}
const toGithubRepo = useLockFn(() => {
return openWebUrl('https://github.com/clash-verge-rev/clash-verge-rev')
})
const toGithubRepo = useLockFn(() =>
openExternalUrl('https://github.com/clash-verge-rev/clash-verge-rev').catch(
onError,
),
)
const toGithubDoc = useLockFn(() => {
return openWebUrl('https://clash-verge-rev.github.io/index.html')
})
const toGithubDoc = useLockFn(() =>
openExternalUrl('https://clash-verge-rev.github.io/index.html').catch(
onError,
),
)
const toTelegramChannel = useLockFn(() => {
return openWebUrl('https://t.me/clash_verge_re')
})
const toTelegramChannel = useLockFn(() =>
openExternalUrl('https://t.me/clash_verge_re').catch(onError),
)
const mode = useThemeMode()
const isDark = mode === 'light' ? false : true
-8
View File
@@ -241,14 +241,6 @@ export async function openLogsDir() {
return invoke<void>('open_logs_dir').catch((err) => showNotice.error(err))
}
export const openWebUrl = async (url: string) => {
try {
await invoke('open_web_url', { url })
} catch (err: any) {
showNotice.error(err)
}
}
export async function cmdTestDelay(url: string) {
return invoke<number>('test_delay', { url })
}
+11
View File
@@ -0,0 +1,11 @@
import { openUrl } from '@tauri-apps/plugin-opener'
export async function openExternalUrl(raw: string) {
const url = new URL(raw.trim())
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new Error('Only HTTP and HTTPS URLs are allowed')
}
await openUrl(url.toString())
}