Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions document/content/self-host/upgrading/4-15/4150.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ description: 'FastGPT V4.15.0 更新说明'
3. 非管理员/访客,触发余额不足时候,提示优化。
4. 无创建权限时,隐藏模板功能。
5. 加强第三方知识库请求的 SSRF 防护。
6. 加强 IP 检测,避免伪造绕过。

## 🐛 修复

Expand Down
2 changes: 1 addition & 1 deletion document/data/doc-last-modified.json
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,7 @@
"content/self-host/upgrading/4-14/41481.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/4-14/4149.en.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/4-14/4149.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/4-15/4150.mdx": "2026-04-28T21:35:13+08:00",
"content/self-host/upgrading/4-15/4150.mdx": "2026-04-28T22:44:51+08:00",
"content/self-host/upgrading/outdated/40.en.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/outdated/40.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/outdated/41.en.mdx": "2026-04-26T21:08:47+08:00",
Expand Down
4 changes: 2 additions & 2 deletions packages/service/common/geo/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ import type { LocationName } from './type';
import { extractLocationData } from './utils';
import type { NextApiRequest } from 'next';
export type { NextApiRequest } from 'next';
import { getClientIp } from 'request-ip';
import { getLogger } from '../logger';
import type { localeType } from '@fastgpt/global/common/i18n/type';
import { formatI18nLocationToZhEn } from '@fastgpt/global/common/i18n/utils';
import { getClientIpFromRequest } from '../security/clientIp';

const logger = getLogger(['GEO']);

Expand Down Expand Up @@ -109,7 +109,7 @@ export function initGeo() {
}

export function getIpFromRequest(request: NextApiRequest): string {
const ip = getClientIp(request);
const ip = getClientIpFromRequest(request);
if (!ip || ip === '::1') {
return '127.0.0.1';
}
Expand Down
3 changes: 2 additions & 1 deletion packages/service/common/middle/entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { getLogger, LogCategories, withContext } from '../logger';
import { setSpanError, withActiveSpan } from '../tracing';
import { ZodError } from 'zod';
import { randomUUID } from 'crypto';
import { getClientIpFromRequest } from '../security/clientIp';

export type NextApiHandler<T = any> = (
req: ApiRequestProps,
Expand Down Expand Up @@ -61,7 +62,7 @@ export const NextEntry = ({
const url = req.url || '';
const route = getRequestRoute(url);
const method = req.method?.toUpperCase() || '';
const ip = req.headers['x-forwarded-for'] || req.socket?.remoteAddress;
const ip = getClientIpFromRequest(req);
const userAgent = req.headers['user-agent'];
const contentLength = req.headers['content-length'];
const requestBodySize = parseHeaderNumber(contentLength);
Expand Down
8 changes: 5 additions & 3 deletions packages/service/common/middle/reqFrequencyLimit.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { type ApiRequestProps } from '../../type/next';
import requestIp from 'request-ip';
import { authFrequencyLimit } from '../system/frequencyLimit/utils';
import { addSeconds } from 'date-fns';
import { type NextApiResponse } from 'next';
import { jsonRes } from '../response';
import { getClientIpFromRequest } from '../security/clientIp';
import { env } from '../../env';

// unit: times/s
// how to use?
Expand All @@ -20,10 +21,11 @@ export function useIPFrequencyLimit({
force?: boolean;
}) {
return async (req: ApiRequestProps, res: NextApiResponse) => {
const ip = requestIp.getClientIp(req);
if (!ip || (process.env.USE_IP_LIMIT !== 'true' && !force)) {
if (!env.CHECK_INTERNAL_IP || !force) {
return;
}

const ip = getClientIpFromRequest(req) ?? 'unknown';
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The early-return condition disables IP frequency limiting in almost all cases: if (!env.CHECK_INTERNAL_IP || !force) return; will return whenever force is false (the default), and it also ties rate limiting to CHECK_INTERNAL_IP rather than the IP-limit flag.

This looks like a regression from the previous USE_IP_LIMIT/force semantics. Consider switching back to a guard like “skip only when IP limiting is disabled AND not forced” (using env.USE_IP_LIMIT), so forced call sites (e.g. password login) still work while respecting the runtime toggle.

Copilot uses AI. Check for mistakes.
try {
await authFrequencyLimit({
eventId: `ip-qps-limit-${id}-` + ip,
Expand Down
230 changes: 230 additions & 0 deletions packages/service/common/security/clientIp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
import type { IncomingHttpHeaders, IncomingMessage } from 'http';
import ipaddr from 'ipaddr.js';
import proxyaddr from 'proxy-addr';
import { env } from '../../env';

type IPAddress = ipaddr.IPv4 | ipaddr.IPv6;

type RequestWithClientIp = {
headers?: IncomingHttpHeaders;
socket?: {
remoteAddress?: string | null;
};
connection?: {
remoteAddress?: string | null;
};
};

type TrustProxyFn = (addr: string, i: number) => boolean;

const MAX_FORWARDED_FOR_LENGTH = 2048;
const MAX_FORWARDED_FOR_HOPS = 32;

let cachedTrustedProxyIpEnv: string | undefined;
let cachedNodeEnv: string | undefined;
let cachedTrustProxyFn: TrustProxyFn = proxyaddr.compile([]);
let warnedInvalidTrustedProxyIpEnv: string | undefined;

// 不区分大小写读取 header 值;数组类型(如 set-cookie 风格)合并为逗号分隔字符串。
const getHeaderValue = (headers: IncomingHttpHeaders | undefined, key: string) => {
const value =
headers?.[key] ??
Object.entries(headers ?? {}).find(([headerKey]) => headerKey.toLowerCase() === key)?.[1];
if (Array.isArray(value)) return value.join(',');
return value;
};

// 剥离 IP 字符串外层的引号、IPv6 方括号以及 IPv4/IPv6 末尾的端口,返回纯地址。
const stripIpWrapper = (rawIp: string) => {
const ip = rawIp.trim().replace(/^"(.+)"$/, '$1');
const bracketedIpv6 = ip.match(/^\[([^\]]+)](?::\d+)?$/);
if (bracketedIpv6?.[1]) return bracketedIpv6[1];

const ipv4WithPort = ip.match(/^(\d{1,3}(?:\.\d{1,3}){3})(?::\d+)?$/);
if (ipv4WithPort?.[1]) return ipv4WithPort[1];

return ip;
};

// 将原始字符串解析为 ipaddr.js 的地址对象;非法或为空时返回 null,内部走 ipaddr.process 以归一 IPv4-mapped IPv6。
const parseIpAddress = (rawIp?: string | null): IPAddress | null => {
if (!rawIp) return null;

const ip = stripIpWrapper(rawIp);
if (!ipaddr.isValid(ip)) return null;

try {
return ipaddr.process(ip);
} catch {
return null;
}
};

// 校验单条 TRUSTED_PROXY_IPS 配置项是否为合法的 IP 或 CIDR(校验掩码长度与地址族匹配)。
const isValidTrustedProxyAddress = (rawValue: string) => {
const addressParts = rawValue.trim().split('/');
if (addressParts.length > 2) return false;

const [rawAddress, rawPrefixLength] = addressParts;
const address = parseIpAddress(rawAddress);
if (!address) return false;
if (rawPrefixLength === undefined) return true;

const prefixLength = Number(rawPrefixLength);
const maxLength = address.kind() === 'ipv4' ? 32 : 128;
return Number.isInteger(prefixLength) && prefixLength > 0 && prefixLength <= maxLength;
};

// 按逗号/空白拆分 TRUSTED_PROXY_IPS,过滤非法项并去重打印一次警告;非 test 环境下提示运维。
const parseTrustedProxyIpEnv = (trustedProxyIpEnv?: string) => {
const validAddresses: string[] = [];
const invalidAddresses: string[] = [];

(trustedProxyIpEnv ?? '')
.split(/[,\s]+/)
.filter(Boolean)
.forEach((item) => {
if (isValidTrustedProxyAddress(item)) {
validAddresses.push(item);
} else {
invalidAddresses.push(item);
}
});

Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says TRUSTED_PROXY_IPS entries are “去重” (deduplicated), but validAddresses/invalidAddresses are only pushed into arrays without any deduplication.

Either dedupe the parsed entries (e.g., via a Set) or update the comment to match the current behavior.

Suggested change
const validAddresses: string[] = [];
const invalidAddresses: string[] = [];
(trustedProxyIpEnv ?? '')
.split(/[,\s]+/)
.filter(Boolean)
.forEach((item) => {
if (isValidTrustedProxyAddress(item)) {
validAddresses.push(item);
} else {
invalidAddresses.push(item);
}
});
const validAddressSet = new Set<string>();
const invalidAddressSet = new Set<string>();
(trustedProxyIpEnv ?? '')
.split(/[,\s]+/)
.filter(Boolean)
.forEach((item) => {
if (isValidTrustedProxyAddress(item)) {
validAddressSet.add(item);
} else {
invalidAddressSet.add(item);
}
});
const validAddresses = Array.from(validAddressSet);
const invalidAddresses = Array.from(invalidAddressSet);

Copilot uses AI. Check for mistakes.
if (
invalidAddresses.length > 0 &&
process.env.NODE_ENV !== 'test' &&
warnedInvalidTrustedProxyIpEnv !== trustedProxyIpEnv
) {
warnedInvalidTrustedProxyIpEnv = trustedProxyIpEnv;
console.warn(
`[security:client-ip] Ignored invalid TRUSTED_PROXY_IPS entries: ${invalidAddresses.join(
', '
)}`
);
}

return validAddresses;
};

// 构建并缓存 proxy-addr 的信任判定函数;非生产环境默认信任 loopback,叠加 TRUSTED_PROXY_IPS 配置。
// 仅当环境变量或 NODE_ENV 变化时才重新编译,避免每次请求都重复解析。
const getTrustProxyFn = () => {
const trustedProxyIpEnv = env.TRUSTED_PROXY_IPS;
const nodeEnv = process.env.NODE_ENV;
if (trustedProxyIpEnv === cachedTrustedProxyIpEnv && nodeEnv === cachedNodeEnv) {
return cachedTrustProxyFn;
}
Comment on lines +112 to +117
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getTrustProxyFn can return the default cachedTrustProxyFn compiled with [] without ever compiling the intended default trust list when both process.env.NODE_ENV and env.TRUSTED_PROXY_IPS are undefined on the first call (because the cache keys also start as undefined).

This breaks the documented behavior (“non-production defaults to trusting loopback”) when NODE_ENV isn’t set. Consider initializing the cache keys to a sentinel value (or null) so the first call always compiles, or initializing cachedTrustProxyFn by calling getTrustProxyFn() lazily instead of compiling [] up front.

Copilot uses AI. Check for mistakes.

cachedTrustedProxyIpEnv = trustedProxyIpEnv;
cachedNodeEnv = nodeEnv;

const trustedProxyAddresses = [
...(nodeEnv === 'production' ? [] : (['loopback'] satisfies proxyaddr.Address[])),
...parseTrustedProxyIpEnv(trustedProxyIpEnv)
];
cachedTrustProxyFn = proxyaddr.compile(trustedProxyAddresses);

return cachedTrustProxyFn;
};

// 将地址对象转为小写字符串,统一 IPv6 大小写写法以便比较。
const normalizeIpAddress = (address: IPAddress) => address.toString().toLowerCase();

// 对外:把任意来源的 IP 字符串解析并归一化(去端口/方括号、小写),非法返回 undefined。
export const normalizeClientIp = (rawIp?: string | null) => {
const address = parseIpAddress(rawIp);
if (!address) return;

return normalizeIpAddress(address);
};

// 对外:判断给定 IP 是否在受信代理白名单内,供上游中间件决定是否采纳转发头。
export const isTrustedProxyIp = (rawIp?: string | null) => {
const ip = normalizeClientIp(rawIp);
if (!ip) return false;

return getTrustProxyFn()(ip, 0);
};

// 取 TCP 连接对端地址(socket / 旧版 connection 兜底),作为最可信的回退来源。
const getRemoteIp = (req: RequestWithClientIp) => {
return normalizeClientIp(req.socket?.remoteAddress ?? req.connection?.remoteAddress);
};

// 读取并归一化 X-Real-IP 头;通常由 Nginx 等单层代理设置为最初客户端 IP。
const getClientIpFromRealIp = (req: RequestWithClientIp) => {
const xRealIp = getHeaderValue(req.headers, 'x-real-ip');
return normalizeClientIp(xRealIp);
};

// 读取原始 X-Forwarded-For 头(不解析、不归一),后续校验和 proxy-addr 解析使用。
const getForwardedFor = (req: RequestWithClientIp) => {
return getHeaderValue(req.headers, 'x-forwarded-for');
};

// 在调用 proxy-addr 前对 XFF 做尺寸/跳数/格式预检,防止超长或畸形头造成解析放大攻击。
const isForwardedForSafeToParse = (forwardedFor: string) => {
if (forwardedFor.length > MAX_FORWARDED_FOR_LENGTH) return false;

const hops = forwardedFor.split(',').map((hop) => hop.trim());
return (
hops.length > 0 &&
hops.length <= MAX_FORWARDED_FOR_HOPS &&
hops.every((hop) => Boolean(normalizeClientIp(hop)))
);
};

// 构造一个最小化的 IncomingMessage 形状对象供 proxy-addr 使用:
// 仅保留经过调用方校验的 XFF 头与指定 remoteAddress,避免外部 header 干扰判定。
const createProxyAddrRequest = (
req: RequestWithClientIp,
remoteAddress: string,
forwardedFor: string
) => {
return {
headers: {
...(req.headers ?? {}),
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

createProxyAddrRequest’s comment says it “仅保留…XFF…避免外部 header 干扰判定”, but the implementation spreads all original headers into the new request.

To match the intent, consider only setting the single validated x-forwarded-for header (and omitting the spread), or adjust the comment if keeping other headers is intentional.

Suggested change
...(req.headers ?? {}),

Copilot uses AI. Check for mistakes.
'x-forwarded-for': forwardedFor
},
socket: {
remoteAddress
}
} as unknown as IncomingMessage;
};

// 对外:从请求中解析出最终客户端 IP。
// 策略:
// 1. 取 socket 远端 IP 作为底线;若不可解析直接返回 undefined。
// 2. 远端不在受信代理列表 -> 直接返回远端 IP,忽略一切转发头(防伪造)。
// 3. 远端可信 -> 优先用 X-Forwarded-For(经安全校验后交给 proxy-addr 沿信任链回溯),
// 否则回退 X-Real-IP;校验失败或转发头本身仍是受信代理时退回远端 IP。
export const getClientIpFromRequest = (req: RequestWithClientIp) => {
const remoteIp = getRemoteIp(req);
if (!remoteIp) return;

const trustProxy = getTrustProxyFn();
if (trustProxy(remoteIp, 0)) {
const forwardedFor = getForwardedFor(req);
if (forwardedFor) {
if (!isForwardedForSafeToParse(forwardedFor)) {
return remoteIp;
}

const forwardedIp = normalizeClientIp(
proxyaddr(createProxyAddrRequest(req, remoteIp, forwardedFor), trustProxy)
);

if (forwardedIp && forwardedIp !== remoteIp && !trustProxy(forwardedIp, 0)) {
return forwardedIp;
}

return remoteIp;
}

const realIp = getClientIpFromRealIp(req);
return realIp && realIp !== remoteIp && !trustProxy(realIp, 0) ? realIp : remoteIp;
}

return remoteIp;
};
6 changes: 5 additions & 1 deletion packages/service/common/system/frequencyLimit/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,13 @@ export const authFrequencyLimit = async ({
).lean();
// 因为始终会返回+1的结果,所以这里不能直接等,需要多一个。
if (result.amount > maxAmount) {
return Promise.reject(ERROR_ENUM.uploadFileIntervalLimit);
throw ERROR_ENUM.tooManyRequest;
}
} catch (error) {
if (error === ERROR_ENUM.tooManyRequest) {
throw error;
}

logger.error('Failed to update auth frequency limit', { eventId, error });
}
};
9 changes: 8 additions & 1 deletion packages/service/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,14 @@ export const env = createEnv({
.meta({ description: '并行节点最大并发数' }),

// ===== Security =====
CHECK_INTERNAL_IP: BoolSchema.default(false).meta({ description: '是否启用内网 IP 检查' }),
CHECK_INTERNAL_IP: BoolSchema.default(false).meta({
description: '是否启用内网 IP 检查'
}),
USE_IP_LIMIT: BoolSchema.default(true).meta({ description: '是否启用 IP 限制' }),
TRUSTED_PROXY_IPS: z.string().optional().meta({
description:
'可信反向代理 IP/CIDR 列表,逗号或空白分隔。生产环境默认不信任任何代理;仅显式可信代理传入的 X-Forwarded-For/X-Real-IP 会用于客户端 IP 解析'
}),

/** Redis 流式镜像续期:生成中(秒) */
STREAM_RESUME_TTL_SECONDS: IntSchema.positive().default(5 * 60),
Expand Down
4 changes: 2 additions & 2 deletions packages/service/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,9 @@
"pg": "^8.10.0",
"pino": "^9.7.0",
"pino-opentelemetry-transport": "^1.0.1",
"proxy-addr": "catalog:",
"proxy-agent": "catalog:",
"proxy-from-env": "^1.1.0",
"request-ip": "catalog:",
"tiktoken": "1.0.17",
"tunnel": "^0.0.6",
"turndown": "^7.1.2",
Expand All @@ -89,8 +89,8 @@
"@types/node-cron": "^3.0.11",
"@types/papaparse": "5.3.7",
"@types/pg": "^8.6.6",
"@types/proxy-addr": "catalog:",
"@types/proxy-from-env": "^1.0.4",
"@types/request-ip": "catalog:",
"@types/tunnel": "^0.0.4",
"@types/turndown": "^5.0.4"
}
Expand Down
15 changes: 13 additions & 2 deletions packages/service/test/common/geo/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ describe('getIpFromRequest', () => {
const req = {
headers: { 'x-forwarded-for': '203.0.113.50' },
connection: {},
socket: {}
socket: { remoteAddress: '127.0.0.1' }
} as unknown as NextApiRequest;

const ip = getIpFromRequest(req);
Expand All @@ -188,10 +188,21 @@ describe('getIpFromRequest', () => {
const req = {
headers: { 'x-real-ip': '198.51.100.10' },
connection: {},
socket: {}
socket: { remoteAddress: '127.0.0.1' }
} as unknown as NextApiRequest;

const ip = getIpFromRequest(req);
expect(ip).toBe('198.51.100.10');
});

it('should ignore spoofed IP headers from untrusted direct clients', () => {
const req = {
headers: { 'x-forwarded-for': '203.0.113.50', 'x-real-ip': '198.51.100.10' },
connection: {},
socket: { remoteAddress: '192.0.2.20' }
} as unknown as NextApiRequest;

const ip = getIpFromRequest(req);
expect(ip).toBe('192.0.2.20');
});
});
Loading
Loading