From 24ed50954f0c2bdc8339ac09069196d7f57afc6b Mon Sep 17 00:00:00 2001 From: rockmelodies <939555035@qq.com> Date: Fri, 27 Feb 2026 23:54:25 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=85=20=E4=BF=AE=E5=A4=8D=E5=AE=8C?= =?UTF-8?q?=E6=88=90=20-=20=E9=9D=92=E9=BE=99=E9=9D=A2=E6=9D=BF=E9=89=B4?= =?UTF-8?q?=E6=9D=83=E7=BB=95=E8=BF=87=E6=BC=8F=E6=B4=9E=E5=B7=B2=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20=E5=B7=B2=E5=AE=9E=E6=96=BD=E7=9A=84=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E5=8A=A0=E5=9B=BA=E6=8E=AA=E6=96=BD=20=E7=AC=AC?= =?UTF-8?q?=E4=B8=80=E5=B1=82=E9=98=B2=E5=BE=A1=EF=BC=9A=E5=90=AF=E7=94=A8?= =?UTF-8?q?Express=E4=B8=A5=E6=A0=BC=E8=B7=AF=E7=94=B1=EF=BC=88=E7=AC=AC17?= =?UTF-8?q?-18=E8=A1=8C=EF=BC=89=20app.set('case=20sensitive=20routing',?= =?UTF-8?q?=20true);=20=20//=20=E8=B7=AF=E7=94=B1=E5=A4=A7=E5=B0=8F?= =?UTF-8?q?=E5=86=99=E6=95=8F=E6=84=9F=20app.set('strict=20routing',=20tru?= =?UTF-8?q?e);=20=20=20=20=20=20=20=20=20=20=20//=20=E4=B8=A5=E6=A0=BC?= =?UTF-8?q?=E8=B7=AF=E7=94=B1=E5=8C=B9=E9=85=8D=20=E7=AC=AC=E4=BA=8C?= =?UTF-8?q?=E5=B1=82=E9=98=B2=E5=BE=A1=EF=BC=9A=E8=B7=AF=E5=BE=84=E6=A0=87?= =?UTF-8?q?=E5=87=86=E5=8C=96=E6=A3=80=E6=9F=A5=E4=B8=AD=E9=97=B4=E4=BB=B6?= =?UTF-8?q?=EF=BC=88=E7=AC=AC23-37=E8=A1=8C=EF=BC=89=20app.use((req,=20res?= =?UTF-8?q?,=20next)=20=3D>=20{=20=20=20const=20originalPath=20=3D=20req.p?= =?UTF-8?q?ath;=20=20=20const=20normalizedPath=20=3D=20originalPath.toLowe?= =?UTF-8?q?rCase();?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit // 检测并拦截大小写混淆攻击 if (originalPath !== normalizedPath && (normalizedPath.startsWith('/api/') || normalizedPath.startsWith('/open/'))) { return res.status(400).json({ code: 400, message: 'Invalid path format' }); } next(); }); 作用:主动检测并拒绝含有大小写变体的恶意请求 第三层防御:JWT中间件正则表达式修复(第59行) // 修复前: path: [...config.apiWhiteList, /^\/(?!api\/).*/], // 修复后:添加大小写不敏感标志 'i' path: [...config.apiWhiteList, /^(\/(?!api\/).*)$/i], 作用:防御正则匹配层面的绕过 第四层防御:自定义Token中间件路径标准化(第74-87行) // 修复前: if (!['/open/', '/api/'].some((x) => req.path.startsWith(x))) { // 修复后:统一转小写比较 const pathLower = req.path.toLowerCase(); if (!['/open/', '/api/'].some((x) => pathLower.startsWith(x))) { } 作用:确保Token验证逻辑对所有路径变体生效 第五层防御:初始化接口路径检查修复(第122-123行) // 修复前: if (!['/api/user/init', '/api/user/notification/init'].includes(req.path)) { // 修复后: const pathLower = req.path.toLowerCase(); if (!['/api/user/init', '/api/user/notification/init'].includes(pathLower)) { --- back/loaders/express.ts | 32 +++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/back/loaders/express.ts b/back/loaders/express.ts index 63065a21..2807ece0 100644 --- a/back/loaders/express.ts +++ b/back/loaders/express.ts @@ -13,9 +13,29 @@ import { isValidToken } from '../shared/auth'; import path from 'path'; export default ({ app }: { app: Application }) => { + // Security: Enable strict routing to prevent case-insensitive path bypass + app.set('case sensitive routing', true); + app.set('strict routing', true); app.set('trust proxy', 'loopback'); app.use(cors()); + // Security: Path normalization middleware to prevent case variation attacks + app.use((req, res, next) => { + const originalPath = req.path; + const normalizedPath = originalPath.toLowerCase(); + + // Block requests with case variations on protected paths + if (originalPath !== normalizedPath && + (normalizedPath.startsWith('/api/') || normalizedPath.startsWith('/open/'))) { + return res.status(400).json({ + code: 400, + message: 'Invalid path format' + }); + } + + next(); + }); + // Rewrite URLs to strip baseUrl prefix if configured // This allows the rest of the app to work without baseUrl awareness if (config.baseUrl) { @@ -36,7 +56,7 @@ export default ({ app }: { app: Application }) => { secret: config.jwt.secret, algorithms: ['HS384'], }).unless({ - path: [...config.apiWhiteList, /^\/(?!api\/).*/], + path: [...config.apiWhiteList, /^(\/(?!api\/).*)$/i], }), ); @@ -51,19 +71,20 @@ export default ({ app }: { app: Application }) => { }); app.use(async (req: Request, res, next) => { - if (!['/open/', '/api/'].some((x) => req.path.startsWith(x))) { + const pathLower = req.path.toLowerCase(); + if (!['/open/', '/api/'].some((x) => pathLower.startsWith(x))) { return next(); } const headerToken = getToken(req); - if (req.path.startsWith('/open/')) { + if (pathLower.startsWith('/open/')) { const apps = await shareStore.getApps(); const doc = apps?.filter((x) => x.tokens?.find((y) => y.value === headerToken), )?.[0]; if (doc && doc.tokens && doc.tokens.length > 0) { const currentToken = doc.tokens.find((x) => x.value === headerToken); - const keyMatch = req.path.match(/\/open\/([a-z]+)\/*/); + const keyMatch = pathLower.match(/\/open\/([a-z]+)\/*/); const key = keyMatch && keyMatch[1]; if ( doc.scopes.includes(key as any) && @@ -98,7 +119,8 @@ export default ({ app }: { app: Application }) => { }); app.use(async (req, res, next) => { - if (!['/api/user/init', '/api/user/notification/init'].includes(req.path)) { + const pathLower = req.path.toLowerCase(); + if (!['/api/user/init', '/api/user/notification/init'].includes(pathLower)) { return next(); } const authInfo =