diff --git a/packages/service/common/file/csv.ts b/packages/service/common/file/csv.ts index eaf53cc4f..86a18f698 100644 --- a/packages/service/common/file/csv.ts +++ b/packages/service/common/file/csv.ts @@ -1,4 +1,33 @@ +// Function to escape CSV fields to prevent injection attacks +export const sanitizeCsvField = (field: String): string => { + if (field == null) return ''; + + let fieldStr = String(field); + + // Check for dangerous starting characters that could cause CSV injection + if (fieldStr.match(/^[\=\+\-\@\|]/)) { + // Add prefix to neutralize potential formula injection + fieldStr = `'${fieldStr}`; + } + + // Handle special characters that need escaping in CSV + if ( + fieldStr.includes(',') || + fieldStr.includes('"') || + fieldStr.includes('\n') || + fieldStr.includes('\r') + ) { + // Escape quotes and wrap field in quotes + fieldStr = `"${fieldStr.replace(/"/g, '""')}"`; + } + + return fieldStr; +}; + export const generateCsv = (headers: string[], data: string[][]) => { - const csv = [headers.join(','), ...data.map((row) => row.join(','))].join('\n'); + const sanitizedHeaders = headers.map((header) => sanitizeCsvField(header)); + const sanitizedData = data.map((row) => row.map((cell) => sanitizeCsvField(cell))); + + const csv = [sanitizedHeaders.join(','), ...sanitizedData.map((row) => row.join(','))].join('\n'); return csv; }; diff --git a/packages/service/support/permission/controller.ts b/packages/service/support/permission/controller.ts index 88abacb83..103db69ad 100644 --- a/packages/service/support/permission/controller.ts +++ b/packages/service/support/permission/controller.ts @@ -1,7 +1,7 @@ import Cookie from 'cookie'; import { ERROR_ENUM } from '@fastgpt/global/common/error/errorCode'; import jwt from 'jsonwebtoken'; -import { type NextApiResponse } from 'next'; +import { type NextApiResponse, type NextApiRequest } from 'next'; import type { AuthModeType, ReqHeaderAuthType } from './type.d'; import type { PerResourceTypeEnum } from '@fastgpt/global/support/permission/constant'; import { AuthUserTypeEnum } from '@fastgpt/global/support/permission/constant'; @@ -231,7 +231,7 @@ export async function parseHeaderCert({ return Promise.reject(ERROR_ENUM.unAuthorization); } - return authUserSession(cookieToken); + return { ...(await authUserSession(cookieToken)), sessionId: cookieToken }; } // from authorization get apikey async function parseAuthorization(authorization?: string) { @@ -283,7 +283,7 @@ export async function parseHeaderCert({ const { cookie, token, rootkey, authorization } = (req.headers || {}) as ReqHeaderAuthType; - const { uid, teamId, tmbId, appId, openApiKey, authType, isRoot, sourceName } = + const { uid, teamId, tmbId, appId, openApiKey, authType, isRoot, sourceName, sessionId } = await (async () => { if (authApiKey && authorization) { // apikey from authorization @@ -309,7 +309,8 @@ export async function parseHeaderCert({ appId: '', openApiKey: '', authType: AuthUserTypeEnum.token, - isRoot: res.isRoot + isRoot: res.isRoot, + sessionId: res.sessionId }; } if (authRoot && rootkey) { @@ -341,7 +342,8 @@ export async function parseHeaderCert({ authType, sourceName, apikey: openApiKey, - isRoot: !!isRoot + isRoot: !!isRoot, + sessionId }; } @@ -353,6 +355,7 @@ export const setCookie = (res: NextApiResponse, token: string) => { `${TokenName}=${token}; Path=/; HttpOnly; Max-Age=604800; Samesite=Strict;` ); }; + /* clear cookie */ export const clearCookie = (res: NextApiResponse) => { res.setHeader('Set-Cookie', `${TokenName}=; Path=/; Max-Age=0`); diff --git a/packages/service/support/user/session.ts b/packages/service/support/user/session.ts index d30572560..8507e56ec 100644 --- a/packages/service/support/user/session.ts +++ b/packages/service/support/user/session.ts @@ -83,12 +83,11 @@ const getSession = async (key: string): Promise => { return Promise.reject(ERROR_ENUM.unAuthorization); } }; - -export const delUserAllSession = async (userId: string, whileList?: string[]) => { - const formatWhileList = whileList?.map((item) => getSessionKey(item)); +export const delUserAllSession = async (userId: string, whiteList?: (string | undefined)[]) => { + const formatWhiteList = whiteList?.map((item) => item && getSessionKey(item)); const redis = getGlobalRedisConnection(); const keys = (await getAllKeysByPrefix(`${redisPrefix}${userId}`)).filter( - (item) => !formatWhileList?.includes(item) + (item) => !formatWhiteList?.includes(item) ); if (keys.length > 0) { diff --git a/projects/app/next.config.js b/projects/app/next.config.js index 558cfff7f..bf45c195e 100644 --- a/projects/app/next.config.js +++ b/projects/app/next.config.js @@ -11,6 +11,35 @@ const nextConfig = { output: 'standalone', reactStrictMode: isDev ? false : true, compress: true, + async headers() { + return [ + { + source: '/((?!chat/share$).*)', + headers: [ + { + key: 'X-Frame-Options', + value: 'DENY' + }, + { + key: 'X-Content-Type-Options', + value: 'nosniff' + }, + { + key: 'X-XSS-Protection', + value: '1; mode=block' + }, + { + key: 'Referrer-Policy', + value: 'strict-origin-when-cross-origin' + }, + { + key: 'Permissions-Policy', + value: 'geolocation=(self), microphone=(self), camera=(self)' + } + ] + } + ]; + }, webpack(config, { isServer, nextRuntime }) { Object.assign(config.resolve.alias, { '@mongodb-js/zstd': false, @@ -85,7 +114,7 @@ const nextConfig = { 'pg', 'bullmq', '@zilliz/milvus2-sdk-node', - "tiktoken", + 'tiktoken' ], outputFileTracingRoot: path.join(__dirname, '../../'), instrumentationHook: true diff --git a/projects/app/src/pages/api/core/app/exportChatLogs.ts b/projects/app/src/pages/api/core/app/exportChatLogs.ts index a19f81cf8..360769ed1 100644 --- a/projects/app/src/pages/api/core/app/exportChatLogs.ts +++ b/projects/app/src/pages/api/core/app/exportChatLogs.ts @@ -18,9 +18,12 @@ import { MongoTeamMember } from '@fastgpt/service/support/user/team/teamMemberSc import type { ChatSourceEnum } from '@fastgpt/global/core/chat/constants'; import { ChatItemValueTypeEnum } from '@fastgpt/global/core/chat/constants'; import { type AIChatItemValueItemType } from '@fastgpt/global/core/chat/type'; +import { sanitizeCsvField } from '@fastgpt/service/common/file/csv'; const formatJsonString = (data: any) => { - return JSON.stringify(data).replace(/"/g, '""').replace(/\n/g, '\\n'); + if (data == null) return ''; + const jsonStr = JSON.stringify(data).replace(/"/g, '""').replace(/\n/g, '\\n'); + return sanitizeCsvField(jsonStr); }; export type ExportChatLogsBody = GetAppChatLogsProps & { @@ -258,7 +261,14 @@ async function handler(req: ApiRequestProps, res: NextAp const markItemsStr = formatJsonString(markItems); const chatDetailsStr = formatJsonString(chatDetails); - const res = `\n"${time}","${source}","${tmbName}","${tmbContact}","${title}","${messageCount}","${userGoodFeedbackItemsStr}","${userBadFeedbackItemsStr}","${customFeedbackItemsStr}","${markItemsStr}","${chatDetailsStr}"`; + const sanitizedTime = sanitizeCsvField(time); + const sanitizedSource = sanitizeCsvField(source); + const sanitizedTmbName = sanitizeCsvField(tmbName); + const sanitizedTmbContact = sanitizeCsvField(tmbContact); + const sanitizedTitle = sanitizeCsvField(title); + const sanitizedMessageCount = sanitizeCsvField(messageCount); + + const res = `\n${sanitizedTime},${sanitizedSource},${sanitizedTmbName},${sanitizedTmbContact},${sanitizedTitle},${sanitizedMessageCount},${userGoodFeedbackItemsStr},${userBadFeedbackItemsStr},${customFeedbackItemsStr},${markItemsStr},${chatDetailsStr}`; write(res); }); diff --git a/projects/app/src/pages/api/core/dataset/collection/export.ts b/projects/app/src/pages/api/core/dataset/collection/export.ts index 2c7770118..ccf43e99f 100644 --- a/projects/app/src/pages/api/core/dataset/collection/export.ts +++ b/projects/app/src/pages/api/core/dataset/collection/export.ts @@ -12,6 +12,7 @@ import { MongoDatasetData } from '@fastgpt/service/core/dataset/data/schema'; import { authDatasetCollection } from '@fastgpt/service/support/permission/dataset/auth'; import { type ApiRequestProps } from '@fastgpt/service/type/next'; import { type NextApiResponse } from 'next'; +import { sanitizeCsvField } from '@fastgpt/service/common/file/csv'; export type ExportCollectionBody = { collectionId: string; @@ -109,10 +110,10 @@ async function handler(req: ApiRequestProps, res: Next write(`\uFEFFindex,content`); cursor.on('data', (doc) => { - const q = doc.q.replace(/"/g, '""') || ''; - const a = doc.a.replace(/"/g, '""') || ''; + const sanitizedQ = sanitizeCsvField(doc.q || ''); + const sanitizedA = sanitizeCsvField(doc.a || ''); - write(`\n"${q}","${a}"`); + write(`\n${sanitizedQ},${sanitizedA}`); }); cursor.on('end', () => { diff --git a/projects/app/src/pages/api/core/dataset/exportAll.ts b/projects/app/src/pages/api/core/dataset/exportAll.ts index 847a9e829..3849bdc82 100644 --- a/projects/app/src/pages/api/core/dataset/exportAll.ts +++ b/projects/app/src/pages/api/core/dataset/exportAll.ts @@ -13,6 +13,7 @@ import { WritePermissionVal } from '@fastgpt/global/support/permission/constant' import { CommonErrEnum } from '@fastgpt/global/common/error/code/common'; import { readFromSecondary } from '@fastgpt/service/common/mongo/utils'; import type { DatasetDataSchemaType } from '@fastgpt/global/core/dataset/type'; +import { sanitizeCsvField } from '@fastgpt/service/common/file/csv'; type DataItemType = { _id: string; @@ -76,11 +77,11 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { write(`\uFEFFq,a,indexes`); cursor.on('data', (doc: DataItemType) => { - const q = doc.q.replace(/"/g, '""') || ''; - const a = doc.a.replace(/"/g, '""') || ''; - const indexes = doc.indexes.map((i) => `"${i.text.replace(/"/g, '""')}"`).join(','); + const sanitizedQ = sanitizeCsvField(doc.q || ''); + const sanitizedA = sanitizeCsvField(doc.a || ''); + const sanitizedIndexes = doc.indexes.map((i) => sanitizeCsvField(i.text || '')).join(','); - write(`\n"${q}","${a}",${indexes}`); + write(`\n${sanitizedQ},${sanitizedA},${sanitizedIndexes}`); }); cursor.on('end', () => { diff --git a/projects/app/src/pages/api/support/user/account/resetExpiredPsw.ts b/projects/app/src/pages/api/support/user/account/resetExpiredPsw.ts index ea4fb4502..2dcf78185 100644 --- a/projects/app/src/pages/api/support/user/account/resetExpiredPsw.ts +++ b/projects/app/src/pages/api/support/user/account/resetExpiredPsw.ts @@ -4,6 +4,7 @@ import { MongoUser } from '@fastgpt/service/support/user/schema'; import { NextAPI } from '@/service/middleware/entry'; import { i18nT } from '@fastgpt/web/i18n/utils'; import { checkPswExpired } from '@/service/support/user/account/password'; +import { delUserAllSession } from '@fastgpt/service/support/user/session'; export type resetExpiredPswQuery = {}; @@ -18,7 +19,7 @@ async function resetExpiredPswHandler( res: ApiResponseType ): Promise { const newPsw = req.body.newPsw; - const { userId } = await authCert({ req, authToken: true }); + const { userId, sessionId } = await authCert({ req, authToken: true }); const user = await MongoUser.findById(userId, 'passwordUpdateTime').lean(); if (!user) { @@ -43,6 +44,8 @@ async function resetExpiredPswHandler( } ); + await delUserAllSession(userId, [sessionId]); + return {}; } diff --git a/projects/app/src/pages/api/support/user/account/updatePasswordByOld.ts b/projects/app/src/pages/api/support/user/account/updatePasswordByOld.ts index a881872fb..19052a302 100644 --- a/projects/app/src/pages/api/support/user/account/updatePasswordByOld.ts +++ b/projects/app/src/pages/api/support/user/account/updatePasswordByOld.ts @@ -7,6 +7,8 @@ import { i18nT } from '@fastgpt/web/i18n/utils'; import { NextAPI } from '@/service/middleware/entry'; import { addAuditLog } from '@fastgpt/service/support/user/audit/util'; import { AuditEventEnum } from '@fastgpt/global/support/user/audit/constants'; +import { delUserAllSession } from '@fastgpt/service/support/user/session'; +import { parseHeaderCert } from '@fastgpt/service/support/permission/controller'; async function handler(req: NextApiRequest, res: NextApiResponse) { const { oldPsw, newPsw } = req.body as { oldPsw: string; newPsw: string }; @@ -14,7 +16,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { return Promise.reject('Params is missing'); } - const { tmbId, teamId } = await authCert({ req, authToken: true }); + const { tmbId, teamId, sessionId } = await authCert({ req, authToken: true }); const tmb = await MongoTeamMember.findById(tmbId); if (!tmb) { return Promise.reject('can not find it'); @@ -40,6 +42,8 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { passwordUpdateTime: new Date() }); + await delUserAllSession(userId, [sessionId]); + (async () => { addAuditLog({ tmbId,