diff --git a/packages/service/common/middle/csrf.ts b/packages/service/common/middle/csrf.ts new file mode 100644 index 000000000000..aa65273df60f --- /dev/null +++ b/packages/service/common/middle/csrf.ts @@ -0,0 +1,32 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import { verifyCsrfToken } from '../../support/permission/auth/common'; +import { generateCsrfToken } from '../../../../projects/app/src/web/support/user/api'; + +export const withCSRFCheck = async ( + req: NextApiRequest, + res: NextApiResponse, + isCSRFCheck: boolean = true +) => { + if (!isCSRFCheck) return; + + try { + const csrfToken = await getCsrfTokenFromRequest(req); + verifyCsrfToken(csrfToken); + } catch (error) { + return res.status(403).json({ + code: 403, + message: 'Invalid CSRF token' + }); + } +}; + +async function getCsrfTokenFromRequest(req: NextApiRequest): Promise { + const headerToken = req.headers['x-csrf-token']; + + if (!headerToken || typeof headerToken !== 'string') { + const { csrfToken } = await generateCsrfToken(); + return csrfToken; + } + + return headerToken; +} diff --git a/packages/service/common/middle/entry.ts b/packages/service/common/middle/entry.ts index e2bbb4c53de7..b38a8df1aac0 100644 --- a/packages/service/common/middle/entry.ts +++ b/packages/service/common/middle/entry.ts @@ -3,18 +3,32 @@ import type { NextApiRequest, NextApiResponse } from 'next'; import { withNextCors } from './cors'; import { type ApiRequestProps } from '../../type/next'; import { addLog } from '../system/log'; +import { withCSRFCheck } from './csrf'; export type NextApiHandler = ( req: ApiRequestProps, res: NextApiResponse ) => unknown | Promise; +type NextAPIOptsType = { + isCSRFCheck: boolean; +}; +type Args = [...NextApiHandler[], NextAPIOptsType] | NextApiHandler[]; + export const NextEntry = ({ beforeCallback = [] }: { beforeCallback?: ((req: NextApiRequest, res: NextApiResponse) => Promise)[]; }) => { - return (...args: NextApiHandler[]): NextApiHandler => { + return (...args: Args): NextApiHandler => { + const opts = (() => { + if (typeof args.at(-1) === 'function') { + return { + isCSRFCheck: true + } as NextAPIOptsType; + } + return args.at(-1) as NextAPIOptsType; + })(); return async function api(req: ApiRequestProps, res: NextApiResponse) { const start = Date.now(); addLog.debug(`Request start ${req.url}`); @@ -22,12 +36,13 @@ export const NextEntry = ({ try { await Promise.all([ withNextCors(req, res), + withCSRFCheck(req, res, opts.isCSRFCheck), ...beforeCallback.map((item) => item(req, res)) ]); let response = null; for await (const handler of args) { - response = await handler(req, res); + if (typeof handler === 'function') response = await handler(req, res); if (res.writableFinished) { break; } diff --git a/packages/service/support/permission/auth/common.ts b/packages/service/support/permission/auth/common.ts index 23f40362a406..0bbfed2efde8 100644 --- a/packages/service/support/permission/auth/common.ts +++ b/packages/service/support/permission/auth/common.ts @@ -8,6 +8,7 @@ import { ERROR_ENUM } from '@fastgpt/global/common/error/errorCode'; import { authUserSession } from '../../../support/user/session'; import { authOpenApiKey } from '../../../support/openapi/auth'; import { AuthUserTypeEnum } from '@fastgpt/global/support/permission/constant'; +import jwt from 'jsonwebtoken'; export const authCert = async (props: AuthModeType) => { const result = await parseHeaderCert(props); @@ -171,3 +172,39 @@ export const setCookie = (res: NextApiResponse, token: string) => { export const clearCookie = (res: NextApiResponse) => { res.setHeader('Set-Cookie', `${TokenName}=; Path=/; Max-Age=0`); }; + +/* CSRF token */ +export const CsrfTokenName = 'csrf_token'; + +/* set CSRF cookie */ +export const setCsrfCookie = (res: NextApiResponse, token: string) => { + res.setHeader( + 'Set-Cookie', + `${CsrfTokenName}=${token}; Path=/; HttpOnly; Max-Age=3600; Samesite=Strict;` + ); +}; + +/* clear CSRF cookie */ +export const clearCsrfCookie = (res: NextApiResponse) => { + res.setHeader('Set-Cookie', `${CsrfTokenName}=; Path=/; Max-Age=0`); +}; + +/* verify CSRF JWT token */ +export const verifyCsrfToken = (token: string | null): any => { + if (!token) { + throw new Error('CSRF token is required'); + } + try { + const jwtSecret = process.env.TOKEN_KEY || 'any'; + const decoded = jwt.verify(token, jwtSecret, { algorithms: ['HS256'] }); + + if (typeof decoded === 'object' && decoded.type === 'csrf') { + return decoded; + } + + throw new Error('Invalid CSRF token type'); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + throw new Error(`CSRF token verification failed: ${errorMessage}`); + } +}; diff --git a/projects/app/src/components/Markdown/index.tsx b/projects/app/src/components/Markdown/index.tsx index ce0f1f0822e3..37021466aa8d 100644 --- a/projects/app/src/components/Markdown/index.tsx +++ b/projects/app/src/components/Markdown/index.tsx @@ -183,8 +183,7 @@ const MarkdownRender = ({ 'base', 'form', 'input', - 'button', - 'img' + 'button' ] } ] diff --git a/projects/app/src/pages/api/common/file/upload.ts b/projects/app/src/pages/api/common/file/upload.ts index e0845a2b92b3..69fa31a25d23 100644 --- a/projects/app/src/pages/api/common/file/upload.ts +++ b/projects/app/src/pages/api/common/file/upload.ts @@ -113,7 +113,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { removeFilesByPaths(filePaths); } -export default NextAPI(handler); +export default NextAPI(handler, { isCSRFCheck: false }); export const config = { api: { diff --git a/projects/app/src/pages/api/common/file/uploadImage.ts b/projects/app/src/pages/api/common/file/uploadImage.ts index 1de4fb0dc085..e195dcf881ff 100644 --- a/projects/app/src/pages/api/common/file/uploadImage.ts +++ b/projects/app/src/pages/api/common/file/uploadImage.ts @@ -13,7 +13,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse): Promise, res: NextAp export default NextAPI( useIPFrequencyLimit({ id: 'export-chat-logs', seconds: 60, limit: 1, force: true }), - handler + handler, + { isCSRFCheck: false } ); diff --git a/projects/app/src/pages/api/core/dataset/collection/create/fileId.ts b/projects/app/src/pages/api/core/dataset/collection/create/fileId.ts index ce9d20924789..10bb6b522b29 100644 --- a/projects/app/src/pages/api/core/dataset/collection/create/fileId.ts +++ b/projects/app/src/pages/api/core/dataset/collection/create/fileId.ts @@ -61,4 +61,4 @@ async function handler( }; } -export default NextAPI(handler); +export default NextAPI(handler, { isCSRFCheck: false }); diff --git a/projects/app/src/pages/api/core/dataset/collection/create/images.ts b/projects/app/src/pages/api/core/dataset/collection/create/images.ts index 32fd193dfa87..d43663b664c0 100644 --- a/projects/app/src/pages/api/core/dataset/collection/create/images.ts +++ b/projects/app/src/pages/api/core/dataset/collection/create/images.ts @@ -95,7 +95,7 @@ async function handler( } } -export default NextAPI(handler); +export default NextAPI(handler, { isCSRFCheck: false }); export const config = { api: { diff --git a/projects/app/src/pages/api/core/dataset/collection/create/localFile.ts b/projects/app/src/pages/api/core/dataset/collection/create/localFile.ts index 8a5629e658b2..4c4cec45bfab 100644 --- a/projects/app/src/pages/api/core/dataset/collection/create/localFile.ts +++ b/projects/app/src/pages/api/core/dataset/collection/create/localFile.ts @@ -88,4 +88,4 @@ export const config = { } }; -export default NextAPI(handler); +export default NextAPI(handler, { isCSRFCheck: false }); 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 ccf43e99f390..8429c5adeeed 100644 --- a/projects/app/src/pages/api/core/dataset/collection/export.ts +++ b/projects/app/src/pages/api/core/dataset/collection/export.ts @@ -130,5 +130,6 @@ async function handler(req: ApiRequestProps, res: Next export default NextAPI( useIPFrequencyLimit({ id: 'export-usage', seconds: 60, limit: 1, force: true }), - handler + handler, + { isCSRFCheck: false } ); diff --git a/projects/app/src/pages/api/core/dataset/data/insertImages.ts b/projects/app/src/pages/api/core/dataset/data/insertImages.ts index 898a3f708e9f..a0ebaed781d1 100644 --- a/projects/app/src/pages/api/core/dataset/data/insertImages.ts +++ b/projects/app/src/pages/api/core/dataset/data/insertImages.ts @@ -121,7 +121,7 @@ async function handler( } } -export default NextAPI(handler); +export default NextAPI(handler, { isCSRFCheck: false }); export const config = { api: { diff --git a/projects/app/src/pages/api/support/user/account/generateCsrfToken.ts b/projects/app/src/pages/api/support/user/account/generateCsrfToken.ts new file mode 100644 index 000000000000..23355f8c3b47 --- /dev/null +++ b/projects/app/src/pages/api/support/user/account/generateCsrfToken.ts @@ -0,0 +1,39 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import { NextAPI } from '@/service/middleware/entry'; +import { authCert, setCsrfCookie } from '@fastgpt/service/support/permission/auth/common'; +import jwt from 'jsonwebtoken'; +import type { ApiRequestProps, ApiResponseType } from '@fastgpt/service/type/next'; + +export type GenerateCsrfTokenQuery = {}; +export type GenerateCsrfTokenBody = {}; +export type GenerateCsrfTokenResponse = { + csrfToken: string; + expiresAt: number; +}; + +async function handler( + req: ApiRequestProps, + res: ApiResponseType +): Promise { + const jwtSecret = process.env.TOKEN_KEY || 'any'; + const expiresAt = Math.floor(Date.now() / 1000) + 60 * 60; + const csrfToken = jwt.sign( + { + type: 'csrf', + exp: expiresAt + }, + jwtSecret, + { + algorithm: 'HS256' + } + ); + + setCsrfCookie(res, csrfToken); + + return { + csrfToken, + expiresAt + }; +} + +export default NextAPI(handler, { isCSRFCheck: false }); diff --git a/projects/app/src/pages/api/support/user/account/loginout.ts b/projects/app/src/pages/api/support/user/account/loginout.ts index 5e86d3c66ba1..f3577a049815 100644 --- a/projects/app/src/pages/api/support/user/account/loginout.ts +++ b/projects/app/src/pages/api/support/user/account/loginout.ts @@ -2,6 +2,7 @@ import type { NextApiRequest, NextApiResponse } from 'next'; import { NextAPI } from '@/service/middleware/entry'; import { authCert, clearCookie } from '@fastgpt/service/support/permission/auth/common'; import { delUserAllSession } from '@fastgpt/service/support/user/session'; +import { clearCsrfCookie } from '@fastgpt/service/support/permission/auth/common'; async function handler(req: NextApiRequest, res: NextApiResponse) { try { @@ -9,6 +10,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { await delUserAllSession(userId); } catch (error) {} clearCookie(res); + clearCsrfCookie(res); } export default NextAPI(handler); diff --git a/projects/app/src/web/common/api/fetch.ts b/projects/app/src/web/common/api/fetch.ts index ccbb75010c56..8d049d6d0081 100644 --- a/projects/app/src/web/common/api/fetch.ts +++ b/projects/app/src/web/common/api/fetch.ts @@ -11,6 +11,7 @@ import { useSystemStore } from '../system/useSystemStore'; import { formatTime2YMDHMW } from '@fastgpt/global/common/string/time'; import { getWebReqUrl } from '@fastgpt/web/common/system/utils'; import type { OnOptimizePromptProps } from '@/components/common/PromptEditor/OptimizerPopover'; +import { getCsrfToken } from '../utils/csrfToken'; type StreamFetchProps = { url?: string; @@ -121,11 +122,13 @@ export const streamFetch = ({ // auto complete variables const variables = data?.variables || {}; variables.cTime = formatTime2YMDHMW(); + const csrfToken = await getCsrfToken(); const requestData = { method: 'POST', headers: { - 'Content-Type': 'application/json' + 'Content-Type': 'application/json', + 'x-csrf-token': csrfToken }, signal: abortCtrl.signal, body: JSON.stringify({ diff --git a/projects/app/src/web/common/api/request.ts b/projects/app/src/web/common/api/request.ts index d701cfd9a761..756328bcf7a1 100644 --- a/projects/app/src/web/common/api/request.ts +++ b/projects/app/src/web/common/api/request.ts @@ -11,6 +11,7 @@ import { useSystemStore } from '../system/useSystemStore'; import { getWebReqUrl } from '@fastgpt/web/common/system/utils'; import { i18nT } from '@fastgpt/web/i18n/utils'; import { getNanoid } from '@fastgpt/global/common/string/tools'; +import { getCsrfToken } from '../utils/csrfToken'; interface ConfigType { headers?: { [key: string]: string }; @@ -78,8 +79,19 @@ function requestFinish({ signId, url }: { signId?: string; url: string }) { /** * 请求开始 */ -function startInterceptors(config: InternalAxiosRequestConfig): InternalAxiosRequestConfig { +async function startInterceptors( + config: InternalAxiosRequestConfig +): Promise { if (config.headers) { + // prevent circular requests + const isGenerateCsrfTokenRequest = config.url?.includes( + '/support/user/account/generateCsrfToken' + ); + const csrfToken = isGenerateCsrfTokenRequest ? '' : await getCsrfToken(); + + if (csrfToken) { + config.headers['x-csrf-token'] = csrfToken; + } } return config; diff --git a/projects/app/src/web/common/utils/csrfToken.ts b/projects/app/src/web/common/utils/csrfToken.ts new file mode 100644 index 000000000000..4aa379f70166 --- /dev/null +++ b/projects/app/src/web/common/utils/csrfToken.ts @@ -0,0 +1,56 @@ +import { generateCsrfToken } from '@/web/support/user/api'; + +const CSRF_TOKEN_STORAGE_KEY = 'csrf_token'; +const CSRF_EXPIRES_STORAGE_KEY = 'csrf_expires'; + +interface CsrfTokenData { + token: string; + expiresAt: number; +} + +export const getCsrfToken = async (): Promise => { + const storedToken = getStoredToken(); + + if (storedToken && isTokenValid(storedToken.expiresAt)) { + return storedToken.token; + } + + return fetchNewToken(); +}; + +const getStoredToken = (): CsrfTokenData | null => { + const token = localStorage.getItem(CSRF_TOKEN_STORAGE_KEY); + const expiresAt = localStorage.getItem(CSRF_EXPIRES_STORAGE_KEY); + + if (token && expiresAt) { + return { + token, + expiresAt: parseInt(expiresAt, 10) + }; + } + + return null; +}; + +const isTokenValid = (expiresAt: number): boolean => { + const currentTime = Math.floor(Date.now() / 1000); + const bufferTime = 10 * 60; + + return expiresAt > currentTime + bufferTime; +}; + +const fetchNewToken = async (): Promise => { + const csrfTokenData = await generateCsrfToken(); + + if (csrfTokenData.csrfToken && csrfTokenData.expiresAt) { + localStorage.setItem(CSRF_TOKEN_STORAGE_KEY, csrfTokenData.csrfToken); + localStorage.setItem(CSRF_EXPIRES_STORAGE_KEY, csrfTokenData.expiresAt.toString()); + return csrfTokenData.csrfToken; + } + return ''; +}; + +export const clearCsrfToken = (): void => { + localStorage.removeItem(CSRF_TOKEN_STORAGE_KEY); + localStorage.removeItem(CSRF_EXPIRES_STORAGE_KEY); +}; diff --git a/projects/app/src/web/support/user/api.ts b/projects/app/src/web/support/user/api.ts index 5b8af1df4efe..a8a60995bb8c 100644 --- a/projects/app/src/web/support/user/api.ts +++ b/projects/app/src/web/support/user/api.ts @@ -125,3 +125,6 @@ export const GetSearchUserGroupOrg = ( GET('/proApi/support/user/search', { searchKey, ...options }, { maxQuantity: 1 }); export const ExportMembers = () => GET<{ csv: string }>('/proApi/support/user/team/member/export'); + +export const generateCsrfToken = () => + GET<{ csrfToken: string; expiresAt: number }>('/support/user/account/generateCsrfToken'); diff --git a/projects/app/src/web/support/user/auth.ts b/projects/app/src/web/support/user/auth.ts index be483820a686..edd59c4c0c75 100644 --- a/projects/app/src/web/support/user/auth.ts +++ b/projects/app/src/web/support/user/auth.ts @@ -1,9 +1,12 @@ import { loginOut } from '@/web/support/user/api'; +import { clearCsrfToken } from '@/web/common/utils/csrfToken'; -export const clearToken = () => { +export const clearToken = async () => { try { + clearCsrfToken(); return loginOut(); } catch (error) { + clearCsrfToken(); error; } };