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) + } + } +}