From a08bb91f5816c9a5a98b2a258818683c6d37d133 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 30 May 2026 23:29:08 +0200 Subject: [PATCH] fix(settings): reject spaces, '\' and control chars in URI path settings webBasePath, subPath, subJsonPath and subClashPath are URL paths, so '/' stays allowed, but spaces, backslashes and control characters break routing. Strip them as you type (shared sanitizePath helper, now also applied to the panel base path) and reject them on save in AllSetting.CheckValid so direct API callers are covered too. --- frontend/src/pages/settings/GeneralTab.tsx | 3 +- .../pages/settings/SubscriptionFormatsTab.tsx | 13 +------- .../pages/settings/SubscriptionGeneralTab.tsx | 13 +------- frontend/src/pages/settings/uriPath.ts | 17 ++++++++++ web/entity/entity.go | 23 +++++++++++++ web/entity/path_validation_test.go | 32 +++++++++++++++++++ 6 files changed, 76 insertions(+), 25 deletions(-) create mode 100644 frontend/src/pages/settings/uriPath.ts create mode 100644 web/entity/path_validation_test.go diff --git a/frontend/src/pages/settings/GeneralTab.tsx b/frontend/src/pages/settings/GeneralTab.tsx index bd177e8c..5c163cd8 100644 --- a/frontend/src/pages/settings/GeneralTab.tsx +++ b/frontend/src/pages/settings/GeneralTab.tsx @@ -11,6 +11,7 @@ import { import type { AllSetting } from '@/models/setting'; import { HttpUtil, LanguageManager } from '@/utils'; import { SettingListItem } from '@/components/ui'; +import { sanitizePath } from './uriPath'; interface ApiMsg { success?: boolean; @@ -150,7 +151,7 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp - updateSetting({ webBasePath: e.target.value })} /> + updateSetting({ webBasePath: sanitizePath(e.target.value) })} /> diff --git a/frontend/src/pages/settings/SubscriptionFormatsTab.tsx b/frontend/src/pages/settings/SubscriptionFormatsTab.tsx index acc20bdc..89628c20 100644 --- a/frontend/src/pages/settings/SubscriptionFormatsTab.tsx +++ b/frontend/src/pages/settings/SubscriptionFormatsTab.tsx @@ -11,6 +11,7 @@ import { } from 'antd'; import type { AllSetting } from '@/models/setting'; import { SettingListItem } from '@/components/ui'; +import { sanitizePath, normalizePath } from './uriPath'; import './SubscriptionFormatsTab.css'; interface SubscriptionFormatsTabProps { @@ -60,18 +61,6 @@ const directDomainsOptions = [ { label: 'Google', value: 'geosite:google' }, ]; -function sanitizePath(input: string): string { - return String(input ?? '').replace(/[:*]/g, ''); -} - -function normalizePath(input: string): string { - let p = input || '/'; - if (!p.startsWith('/')) p = '/' + p; - if (!p.endsWith('/')) p += '/'; - p = p.replace(/\/+/g, '/'); - return p; -} - function readJson(raw: string, fallback: T): T { try { if (!raw) return fallback; diff --git a/frontend/src/pages/settings/SubscriptionGeneralTab.tsx b/frontend/src/pages/settings/SubscriptionGeneralTab.tsx index cc5143c7..7ea181ff 100644 --- a/frontend/src/pages/settings/SubscriptionGeneralTab.tsx +++ b/frontend/src/pages/settings/SubscriptionGeneralTab.tsx @@ -2,24 +2,13 @@ import { Collapse, Divider, Input, InputNumber, Switch } from 'antd'; import { useTranslation } from 'react-i18next'; import type { AllSetting } from '@/models/setting'; import { SettingListItem } from '@/components/ui'; +import { sanitizePath, normalizePath } from './uriPath'; interface SubscriptionGeneralTabProps { allSetting: AllSetting; updateSetting: (patch: Partial) => void; } -function sanitizePath(input: string): string { - return String(input ?? '').replace(/[:*]/g, ''); -} - -function normalizePath(input: string): string { - let p = input || '/'; - if (!p.startsWith('/')) p = '/' + p; - if (!p.endsWith('/')) p += '/'; - p = p.replace(/\/+/g, '/'); - return p; -} - export default function SubscriptionGeneralTab({ allSetting, updateSetting }: SubscriptionGeneralTabProps) { const { t } = useTranslation(); diff --git a/frontend/src/pages/settings/uriPath.ts b/frontend/src/pages/settings/uriPath.ts new file mode 100644 index 00000000..88c55bc1 --- /dev/null +++ b/frontend/src/pages/settings/uriPath.ts @@ -0,0 +1,17 @@ +export function sanitizePath(input: string): string { + let out = ''; + for (const ch of String(input ?? '')) { + const code = ch.charCodeAt(0); + if (ch === ':' || ch === '*' || ch === ' ' || ch === '\\' || code < 0x20 || code === 0x7f) continue; + out += ch; + } + return out; +} + +export function normalizePath(input: string): string { + let p = input || '/'; + if (!p.startsWith('/')) p = '/' + p; + if (!p.endsWith('/')) p += '/'; + p = p.replace(/\/+/g, '/'); + return p; +} diff --git a/web/entity/entity.go b/web/entity/entity.go index 31ca623c..77967fe0 100644 --- a/web/entity/entity.go +++ b/web/entity/entity.go @@ -128,6 +128,15 @@ type AllSettingView struct { } // CheckValid validates all settings in the AllSetting struct, checking IP addresses, ports, SSL certificates, and other configuration values. +func pathHasForbiddenChar(s string) bool { + for _, r := range s { + if r == '\\' || r == ' ' || r < 0x20 || r == 0x7f { + return true + } + } + return false +} + func (s *AllSetting) CheckValid() error { if s.WebListen != "" { ip := net.ParseIP(s.WebListen) @@ -169,6 +178,20 @@ func (s *AllSetting) CheckValid() error { } } + for _, p := range []struct { + name string + value string + }{ + {"web base path", s.WebBasePath}, + {"subscription path", s.SubPath}, + {"subscription JSON path", s.SubJsonPath}, + {"subscription Clash path", s.SubClashPath}, + } { + if pathHasForbiddenChar(p.value) { + return common.NewError("URI path contains an invalid character:", p.name) + } + } + if !strings.HasPrefix(s.WebBasePath, "/") { s.WebBasePath = "/" + s.WebBasePath } diff --git a/web/entity/path_validation_test.go b/web/entity/path_validation_test.go new file mode 100644 index 00000000..9148667c --- /dev/null +++ b/web/entity/path_validation_test.go @@ -0,0 +1,32 @@ +package entity + +import "testing" + +func TestPathHasForbiddenChar(t *testing.T) { + valid := []string{ + "", + "/", + "/sub/", + "/json/", + "/a/b/c/", + "/My-Path_123/", + } + for _, p := range valid { + if pathHasForbiddenChar(p) { + t.Errorf("pathHasForbiddenChar(%q) = true, want false", p) + } + } + + invalid := []string{ + "/sub path/", + "/back\\slash/", + "/tab\there/", + "/new\nline/", + "/\x7f/", + } + for _, p := range invalid { + if !pathHasForbiddenChar(p) { + t.Errorf("pathHasForbiddenChar(%q) = false, want true", p) + } + } +}