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,9 +92,28 @@ export default function WebLoginPage() {
</CardHeader> </CardHeader>
<CardBody className="flex gap-5 py-5 px-5 md:px-10"> <CardBody className="flex gap-5 py-5 px-5 md:px-10">
<form
onSubmit={(e) => {
e.preventDefault()
onSubmit()
}}
>
{/* 隐藏的用户名字段,帮助浏览器识别登录表单 */}
<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 <Input
isClearable isClearable
type="password" type="password"
name="password"
autoComplete="current-password"
classNames={{ classNames={{
label: 'text-black/50 dark:text-white/90', label: 'text-black/50 dark:text-white/90',
input: [ input: [
@ -128,6 +147,10 @@ export default function WebLoginPage() {
onChange={(e) => setTokenValue(e.target.value)} onChange={(e) => setTokenValue(e.target.value)}
onClear={() => setTokenValue('')} onClear={() => setTokenValue('')}
/> />
</form>
<div className="text-center text-small text-default-600 dark:text-default-400 px-2">
💡 NapCat
</div>
<Button <Button
className="mx-10 mt-10 text-lg py-7" className="mx-10 mt-10 text-lg py-7"
color="primary" color="primary"

View File

@ -216,6 +216,7 @@ export async function InitWebUi(logger: LogWrapper, pathWrapper: NapCatPathWrapp
// ------------启动服务------------ // ------------启动服务------------
server.listen(port, host, async () => { server.listen(port, host, async () => {
let searchParams = { token: token }; let searchParams = { token: token };
logger.log(`[NapCat] [WebUi] 🔑 token=${token}`);
logger.log( logger.log(
`[NapCat] [WebUi] WebUi User Panel Url: ${createUrl('127.0.0.1', port.toString(), '/webui', searchParams)}` `[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 isWindows = os.platform() === 'win32';
// 安全地从查询参数中提取字符串值,防止类型混淆
const getQueryStringParam = (param: any): string => {
if (Array.isArray(param)) {
return String(param[0] || '');
}
return String(param || '');
};
// 获取系统根目录列表Windows返回盘符列表其他系统返回['/'] // 获取系统根目录列表Windows返回盘符列表其他系统返回['/']
const getRootDirs = async (): Promise<string[]> => { const getRootDirs = async (): Promise<string[]> => {
if (!isWindows) return ['/']; if (!isWindows) return ['/'];
@ -34,21 +42,29 @@ const getRootDirs = async (): Promise<string[]> => {
// 规范化路径并进行安全验证 // 规范化路径并进行安全验证
const normalizePath = (inputPath: string): string => { const normalizePath = (inputPath: string): string => {
if (!inputPath) return isWindows ? 'C:\\' : '/'; if (!inputPath) {
// 对于空路径Windows返回用户主目录其他系统返回根目录
return isWindows ? process.env['USERPROFILE'] || 'C:\\' : '/';
}
// 如果是Windows且输入为纯盘符可能带或不带斜杠统一返回 "X:\" // 如果是Windows且输入为纯盘符可能带或不带斜杠统一返回 "X:\"
if (isWindows && /^[A-Z]:[\\/]*$/i.test(inputPath)) { if (isWindows && /^[A-Z]:[\\/]*$/i.test(inputPath)) {
return inputPath.slice(0, 2) + '\\'; return inputPath.slice(0, 2) + '\\';
} }
// 进行路径规范化 // 安全验证:检查是否包含危险的路径遍历模式(在规范化之前)
const normalized = path.normalize(inputPath); if (containsPathTraversal(inputPath)) {
// 安全验证:检查是否包含危险的路径遍历模式
if (containsPathTraversal(normalized)) {
throw new Error('Invalid path: path traversal detected'); 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; return normalized;
}; };
@ -125,7 +141,7 @@ export const ListFilesHandler: RequestHandler = async (req, res) => {
return sendError(res, '默认密码禁止使用'); return sendError(res, '默认密码禁止使用');
} }
try { try {
const requestPath = (req.query['path'] as string) || (isWindows ? 'C:\\' : '/'); const requestPath = getQueryStringParam(req.query['path']) || (isWindows ? process.env['USERPROFILE'] || 'C:\\' : '/');
let normalizedPath: string; let normalizedPath: string;
try { try {
@ -275,7 +291,7 @@ export const ReadFileHandler: RequestHandler = async (req, res) => {
try { try {
let filePath: string; let filePath: string;
try { try {
filePath = normalizePath(req.query['path'] as string); filePath = normalizePath(getQueryStringParam(req.query['path']));
} catch (pathError) { } catch (pathError) {
return sendError(res, '无效的文件路径'); return sendError(res, '无效的文件路径');
} }
@ -425,7 +441,7 @@ export const DownloadHandler: RequestHandler = async (req, res) => {
try { try {
let filePath: string; let filePath: string;
try { try {
filePath = normalizePath(req.query['path'] as string); filePath = normalizePath(getQueryStringParam(req.query['path']));
} catch (pathError) { } catch (pathError) {
return sendError(res, '无效的文件路径'); return sendError(res, '无效的文件路径');
} }