mirror of
https://github.com/certimate-go/certimate.git
synced 2026-09-21 20:50:38 +08:00
[fix] harden base path URL handling
This commit is contained in:
@@ -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 },
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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}`;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user