[fix] harden base path URL handling

This commit is contained in:
gaoxizhi
2026-07-29 15:34:56 +08:00
parent 1741c6c957
commit e17cd5ef76
8 changed files with 90 additions and 13 deletions
+51
View File
@@ -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 },
});
}
},
};
},
};
+19
View File
@@ -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",
+2 -2
View File
@@ -1,4 +1,4 @@
import { resolveAppPath } from "@/utils/url";
import { withBasePath } from "@/utils/url";
interface BaseProvider<P> {
type: P;
@@ -298,7 +298,7 @@ export const accessProvidersMap: Map<AccessProvider["type"] | string, AccessProv
{
type: type,
name: name,
icon: resolveAppPath(icon),
icon: withBasePath(icon),
usages: usages,
builtin: builtin === "builtin",
},
+2 -2
View File
@@ -25,7 +25,7 @@ import { APP_DOCUMENT_URL, APP_REPO_URL } from "@/domain/app";
import { useTriggerElement } from "@/hooks";
import { getAuthStore } from "@/repository/admin";
import { isBrowserHappy } from "@/utils/browser";
import { resolveAppPath } from "@/utils/url";
import { withBasePath } from "@/utils/url";
const ConsoleLayout = () => {
const navigate = useNavigate();
@@ -232,7 +232,7 @@ const SiderMenu = memo(({ collapsed, onSelect }: { collapsed?: boolean; onSelect
<>
<div className="h-[64px] w-full overflow-hidden px-4 py-2 max-md:py-0">
<div className="flex size-full items-center justify-around gap-2">
<img src={resolveAppPath("/logo.svg")} className="size-[36px]" />
<img src={withBasePath("/logo.svg")} className="size-[36px]" />
<Show when={!collapsed}>
<span className="w-[81px] truncate text-base leading-[64px] font-semibold">Certimate</span>
<AppVersion.LinkButton className="text-xs" />
+2 -2
View File
@@ -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 = () => {
<Card className="w-120 max-w-full rounded-md shadow-md max-sm:size-full max-sm:rounded-none">
<div className="px-4 py-8">
<div className="mb-12 flex items-center justify-center">
<img src={resolveAppPath("/logo.svg")} className="w-16" />
<img src={withBasePath("/logo.svg")} className="w-16" />
</div>
<Form {...formProps} form={formInst} disabled={formPending} layout="vertical" validateTrigger="onBlur">
+2 -2
View File
@@ -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) => (
<div className="relative max-w-44 flex-1/3 max-md:flex-1/2 max-sm:flex-1" key={item.key}>
<div className="overflow-hidden rounded-lg border border-solid" style={{ borderColor: themeToken.colorBorder }}>
<img className="mb-2 w-full" src={resolveAppPath(`/imgs/themes/${item.key}.png`)} />
<img className="mb-2 w-full" src={withBasePath(`/imgs/themes/${item.key}.png`)} />
<div className="mb-2 px-2">
<Radio value={item.key}>{item.label}</Radio>
</div>
+3 -3
View File
@@ -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<TemplateKeys>();
+9 -2
View File
@@ -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}`;
};