From e17cd5ef764706dc499f427eeeb47ef34ec1640f Mon Sep 17 00:00:00 2001 From: gaoxizhi Date: Wed, 29 Jul 2026 15:34:56 +0800 Subject: [PATCH] [fix] harden base path URL handling --- ui/eslint-rules/no-root-relative-url.mjs | 51 ++++++++++++++++++++ ui/eslint.config.mjs | 19 ++++++++ ui/src/domain/provider.ts | 4 +- ui/src/pages/ConsoleLayout.tsx | 4 +- ui/src/pages/login/Login.tsx | 4 +- ui/src/pages/settings/SettingsAppearance.tsx | 4 +- ui/src/pages/workflows/WorkflowNew.tsx | 6 +-- ui/src/utils/url.ts | 11 ++++- 8 files changed, 90 insertions(+), 13 deletions(-) create mode 100644 ui/eslint-rules/no-root-relative-url.mjs diff --git a/ui/eslint-rules/no-root-relative-url.mjs b/ui/eslint-rules/no-root-relative-url.mjs new file mode 100644 index 000000000..15c783112 --- /dev/null +++ b/ui/eslint-rules/no-root-relative-url.mjs @@ -0,0 +1,51 @@ +const TARGET_ATTRIBUTES = new Set(["href", "src"]); + +const getStaticPrefix = (node) => { + if (!node) { + return; + } + + if (node.type === "Literal") { + return typeof node.value === "string" ? node.value : void 0; + } + + if (node.type === "TemplateLiteral") { + return node.quasis[0]?.value.raw; + } + + if (node.type === "BinaryExpression" && node.operator === "+") { + return getStaticPrefix(node.left); + } +}; + +export default { + meta: { + type: "problem", + docs: { + description: "Disallow root-relative URLs in JSX href and src attributes", + }, + messages: { + useBasePath: "Root-relative {{attribute}} bypasses the application base path. Wrap the URL with withBasePath().", + }, + schema: [], + }, + create(context) { + return { + JSXAttribute(node) { + const attribute = node.name.type === "JSXIdentifier" ? node.name.name : ""; + if (!TARGET_ATTRIBUTES.has(attribute)) { + return; + } + + const value = node.value?.type === "JSXExpressionContainer" ? getStaticPrefix(node.value.expression) : getStaticPrefix(node.value); + if (value?.startsWith("/") && !value.startsWith("//")) { + context.report({ + node: node.value ?? node, + messageId: "useBasePath", + data: { attribute }, + }); + } + }, + }; + }, +}; diff --git a/ui/eslint.config.mjs b/ui/eslint.config.mjs index 0e8af6895..ca9573752 100644 --- a/ui/eslint.config.mjs +++ b/ui/eslint.config.mjs @@ -7,6 +7,14 @@ import reactHooksPlugin from "eslint-plugin-react-hooks"; import reactRefreshPlugin from "eslint-plugin-react-refresh"; import typescriptPlugin from "typescript-eslint"; +import noRootRelativeUrlRule from "./eslint-rules/no-root-relative-url.mjs"; + +const certimatePlugin = { + rules: { + "no-root-relative-url": noRootRelativeUrlRule, + }, +}; + /** * @type {import("eslint").Linter.Config[]} */ @@ -137,6 +145,17 @@ export default defineConfig( }, }, + // Certimate + { + name: "certimate", + plugins: { + certimate: certimatePlugin, + }, + rules: { + "certimate/no-root-relative-url": "error", + }, + }, + // TailwindCSS { name: "tailwindcss", diff --git a/ui/src/domain/provider.ts b/ui/src/domain/provider.ts index e52827d11..21be1ee09 100644 --- a/ui/src/domain/provider.ts +++ b/ui/src/domain/provider.ts @@ -1,4 +1,4 @@ -import { resolveAppPath } from "@/utils/url"; +import { withBasePath } from "@/utils/url"; interface BaseProvider

{ type: P; @@ -298,7 +298,7 @@ export const accessProvidersMap: Map { const navigate = useNavigate(); @@ -232,7 +232,7 @@ const SiderMenu = memo(({ collapsed, onSelect }: { collapsed?: boolean; onSelect <>

- + Certimate diff --git a/ui/src/pages/login/Login.tsx b/ui/src/pages/login/Login.tsx index dc6f42b57..80225927a 100644 --- a/ui/src/pages/login/Login.tsx +++ b/ui/src/pages/login/Login.tsx @@ -14,7 +14,7 @@ import { useAntdForm, useBrowserTheme } from "@/hooks"; import { authWithPassword } from "@/repository/admin"; import { unwrapErrMsg } from "@/utils/error"; -import { resolveAppPath } from "@/utils/url"; +import { withBasePath } from "@/utils/url"; const Login = () => { const navigage = useNavigate(); @@ -75,7 +75,7 @@ const Login = () => {
- +
diff --git a/ui/src/pages/settings/SettingsAppearance.tsx b/ui/src/pages/settings/SettingsAppearance.tsx index 5a00b283b..061fd5d08 100644 --- a/ui/src/pages/settings/SettingsAppearance.tsx +++ b/ui/src/pages/settings/SettingsAppearance.tsx @@ -6,7 +6,7 @@ import { produce } from "immer"; import { useAppLocaleMenuItems } from "@/components/AppLocale"; import { useAppThemeMenuItems } from "@/components/AppTheme"; import { useAppSettings, useBrowserTheme } from "@/hooks"; -import { resolveAppPath } from "@/utils/url"; +import { withBasePath } from "@/utils/url"; const SettingsAppearance = () => { const { t } = useTranslation(); @@ -59,7 +59,7 @@ const SettingsAppearanceTheme = ({ className, style }: { className?: string; sty {themeItems.map((item) => (
- +
{item.label}
diff --git a/ui/src/pages/workflows/WorkflowNew.tsx b/ui/src/pages/workflows/WorkflowNew.tsx index dd261ff0f..a8ec23ab2 100644 --- a/ui/src/pages/workflows/WorkflowNew.tsx +++ b/ui/src/pages/workflows/WorkflowNew.tsx @@ -16,7 +16,7 @@ import { } from "@/domain/workflow"; import { save as saveWorkflow } from "@/repository/workflow"; import { unwrapErrMsg } from "@/utils/error"; -import { resolveAppPath } from "@/utils/url"; +import { withBasePath } from "@/utils/url"; const TEMPLATE_KEY_BLANK = "blank" as const; const TEMPLATE_KEY_STANDARD = "standard" as const; @@ -35,13 +35,13 @@ const WorkflowNew = () => { key: TEMPLATE_KEY_STANDARD, name: t("workflow.new.templates.template.standard.title"), description: t("workflow.new.templates.template.standard.description"), - image: resolveAppPath("/imgs/workflow/tpl-standard.png"), + image: withBasePath("/imgs/workflow/tpl-standard.png"), }, { key: TEMPLATE_KEY_CERTTEST, name: t("workflow.new.templates.template.certtest.title"), description: t("workflow.new.templates.template.certtest.description"), - image: resolveAppPath("/imgs/workflow/tpl-certtest.png"), + image: withBasePath("/imgs/workflow/tpl-certtest.png"), }, ]; const [templateSelectKey, setTemplateSelectKey] = useState(); diff --git a/ui/src/utils/url.ts b/ui/src/utils/url.ts index 69cc4c6a2..ba8cbc7cd 100644 --- a/ui/src/utils/url.ts +++ b/ui/src/utils/url.ts @@ -1,8 +1,15 @@ const appBaseUrl = new URL(import.meta.env.BASE_URL, document.baseURI); +const urlSchemePattern = /^[a-z][a-z\d+.-]*:/i; // PocketBase 会把自身的 /api 路径拼接到这里,因此基础路径必须以斜杠结尾。 export const APP_BASE_PATH = appBaseUrl.pathname; -export const resolveAppPath = (path: string) => { - return new URL(path.replace(/^\/+/, ""), appBaseUrl).pathname; +export const withBasePath = (path: string) => { + // 完整 URL 和协议相对 URL 不属于应用内资源,保持调用方传入的地址不变。 + if (urlSchemePattern.test(path) || path.startsWith("//")) { + return path; + } + + const url = new URL(path.replace(/^\/+/, ""), appBaseUrl); + return `${url.pathname}${url.search}${url.hash}`; };