feat: 增强文件路径处理逻辑,添加安全验证以防止路径遍历攻击,并优化查询参数提取

This commit is contained in:
时瑾 2025-09-09 00:27:48 +08:00
parent 2f9bcd88ba
commit 84fa9da762
No known key found for this signature in database
GPG Key ID: 023F70A1B8F8C196
3 changed files with 84 additions and 44 deletions

View File

@ -92,42 +92,65 @@ export default function WebLoginPage() {
</CardHeader>
<CardBody className="flex gap-5 py-5 px-5 md:px-10">
<Input
isClearable
type="password"
classNames={{
label: 'text-black/50 dark:text-white/90',
input: [
'bg-transparent',
'text-black/90 dark:text-white/90',
'placeholder:text-default-700/50 dark:placeholder:text-white/60'
],
innerWrapper: 'bg-transparent',
inputWrapper: [
'shadow-xl',
'bg-default-100/70',
'dark:bg-default/60',
'backdrop-blur-xl',
'backdrop-saturate-200',
'hover:bg-default-0/70',
'dark:hover:bg-default/70',
'group-data-[focus=true]:bg-default-100/50',
'dark:group-data-[focus=true]:bg-default/60',
'!cursor-text'
]
<form
onSubmit={(e) => {
e.preventDefault()
onSubmit()
}}
isDisabled={isLoading}
label="Token"
placeholder="请输入token"
radius="lg"
size="lg"
startContent={
<IoKeyOutline className="text-black/50 mb-0.5 dark:text-white/90 text-slate-400 pointer-events-none flex-shrink-0" />
}
value={tokenValue}
onChange={(e) => setTokenValue(e.target.value)}
onClear={() => setTokenValue('')}
/>
>
{/* 隐藏的用户名字段,帮助浏览器识别登录表单 */}
<input
type="text"
name="username"
value="napcat-webui"
autoComplete="username"
className="absolute -left-[9999px] opacity-0 pointer-events-none"
readOnly
tabIndex={-1}
aria-label="Username"
/>
<Input
isClearable
type="password"
name="password"
autoComplete="current-password"
classNames={{
label: 'text-black/50 dark:text-white/90',
input: [
'bg-transparent',
'text-black/90 dark:text-white/90',
'placeholder:text-default-700/50 dark:placeholder:text-white/60'
],
innerWrapper: 'bg-transparent',
inputWrapper: [
'shadow-xl',
'bg-default-100/70',
'dark:bg-default/60',
'backdrop-blur-xl',
'backdrop-saturate-200',
'hover:bg-default-0/70',
'dark:hover:bg-default/70',
'group-data-[focus=true]:bg-default-100/50',
'dark:group-data-[focus=true]:bg-default/60',
'!cursor-text'
]
}}
isDisabled={isLoading}
label="Token"
placeholder="请输入token"
radius="lg"
size="lg"
startContent={
<IoKeyOutline className="text-black/50 mb-0.5 dark:text-white/90 text-slate-400 pointer-events-none flex-shrink-0" />
}
value={tokenValue}
onChange={(e) => setTokenValue(e.target.value)}
onClear={() => setTokenValue('')}
/>
</form>
<div className="text-center text-small text-default-600 dark:text-default-400 px-2">
💡 NapCat
</div>
<Button
className="mx-10 mt-10 text-lg py-7"
color="primary"

View File

@ -216,6 +216,7 @@ export async function InitWebUi(logger: LogWrapper, pathWrapper: NapCatPathWrapp
// ------------启动服务------------
server.listen(port, host, async () => {
let searchParams = { token: token };
logger.log(`[NapCat] [WebUi] 🔑 token=${token}`);
logger.log(
`[NapCat] [WebUi] WebUi User Panel Url: ${createUrl('127.0.0.1', port.toString(), '/webui', searchParams)}`
);

View File

@ -13,6 +13,14 @@ import { WebUiConfig, getInitialWebUiToken, webUiPathWrapper } from '@/webui';
const isWindows = os.platform() === 'win32';
// 安全地从查询参数中提取字符串值,防止类型混淆
const getQueryStringParam = (param: any): string => {
if (Array.isArray(param)) {
return String(param[0] || '');
}
return String(param || '');
};
// 获取系统根目录列表Windows返回盘符列表其他系统返回['/']
const getRootDirs = async (): Promise<string[]> => {
if (!isWindows) return ['/'];
@ -34,21 +42,29 @@ const getRootDirs = async (): Promise<string[]> => {
// 规范化路径并进行安全验证
const normalizePath = (inputPath: string): string => {
if (!inputPath) return isWindows ? 'C:\\' : '/';
if (!inputPath) {
// 对于空路径Windows返回用户主目录其他系统返回根目录
return isWindows ? process.env['USERPROFILE'] || 'C:\\' : '/';
}
// 如果是Windows且输入为纯盘符可能带或不带斜杠统一返回 "X:\"
if (isWindows && /^[A-Z]:[\\/]*$/i.test(inputPath)) {
return inputPath.slice(0, 2) + '\\';
}
// 进行路径规范化
const normalized = path.normalize(inputPath);
// 安全验证:检查是否包含危险的路径遍历模式
if (containsPathTraversal(normalized)) {
// 安全验证:检查是否包含危险的路径遍历模式(在规范化之前)
if (containsPathTraversal(inputPath)) {
throw new Error('Invalid path: path traversal detected');
}
// 进行路径规范化
const normalized = path.resolve(inputPath);
// 再次检查规范化后的路径,确保没有绕过安全检查
if (containsPathTraversal(normalized)) {
throw new Error('Invalid path: path traversal detected after normalization');
}
return normalized;
};
@ -125,7 +141,7 @@ export const ListFilesHandler: RequestHandler = async (req, res) => {
return sendError(res, '默认密码禁止使用');
}
try {
const requestPath = (req.query['path'] as string) || (isWindows ? 'C:\\' : '/');
const requestPath = getQueryStringParam(req.query['path']) || (isWindows ? process.env['USERPROFILE'] || 'C:\\' : '/');
let normalizedPath: string;
try {
@ -275,7 +291,7 @@ export const ReadFileHandler: RequestHandler = async (req, res) => {
try {
let filePath: string;
try {
filePath = normalizePath(req.query['path'] as string);
filePath = normalizePath(getQueryStringParam(req.query['path']));
} catch (pathError) {
return sendError(res, '无效的文件路径');
}
@ -425,7 +441,7 @@ export const DownloadHandler: RequestHandler = async (req, res) => {
try {
let filePath: string;
try {
filePath = normalizePath(req.query['path'] as string);
filePath = normalizePath(getQueryStringParam(req.query['path']));
} catch (pathError) {
return sendError(res, '无效的文件路径');
}