Kaynağa Gözat

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.
MHSanaei 15 saat önce
ebeveyn
işleme
a08bb91f58

+ 2 - 1
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<T = unknown> {
   success?: boolean;
@@ -150,7 +151,7 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
             </SettingListItem>
 
             <SettingListItem paddings="small" title={t('pages.settings.panelUrlPath')} description={t('pages.settings.panelUrlPathDesc')}>
-              <Input value={allSetting.webBasePath} onChange={(e) => updateSetting({ webBasePath: e.target.value })} />
+              <Input value={allSetting.webBasePath} onChange={(e) => updateSetting({ webBasePath: sanitizePath(e.target.value) })} />
             </SettingListItem>
 
             <SettingListItem paddings="small" title={t('pages.settings.sessionMaxAge')} description={t('pages.settings.sessionMaxAgeDesc')}>

+ 1 - 12
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<T>(raw: string, fallback: T): T {
   try {
     if (!raw) return fallback;

+ 1 - 12
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<AllSetting>) => 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();
 

+ 17 - 0
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;
+}

+ 23 - 0
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
 	}

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