diff --git a/src/server/middleware/requestRateLimit.ts b/src/server/middleware/requestRateLimit.ts new file mode 100644 index 0000000..e87b2b9 --- /dev/null +++ b/src/server/middleware/requestRateLimit.ts @@ -0,0 +1,85 @@ +import type { FastifyReply, FastifyRequest } from 'fastify'; + +type RateLimitOptions = { + bucket: string; + max: number; + windowMs: number; + message?: string; +}; + +type RateLimitEntry = { + count: number; + resetAt: number; +}; + +const DEFAULT_MESSAGE = '请求过于频繁,请稍后再试'; +const rateLimitStore = new Map(); + +function normalizeIp(rawIp: string | null | undefined): string { + const ip = (rawIp || '').trim(); + if (!ip) return 'unknown'; + if (ip.startsWith('::ffff:')) return ip.slice('::ffff:'.length).trim() || 'unknown'; + if (ip === '::1') return '127.0.0.1'; + return ip; +} + +function extractClientIp(request: FastifyRequest): string { + const forwarded = request.headers['x-forwarded-for']; + if (Array.isArray(forwarded)) { + const first = forwarded.find((value) => typeof value === 'string' && value.trim().length > 0); + if (first) return normalizeIp(first.split(',')[0]); + } + + if (typeof forwarded === 'string' && forwarded.trim().length > 0) { + return normalizeIp(forwarded.split(',')[0]); + } + + return normalizeIp(request.ip); +} + +function getRateLimitKey(bucket: string, request: FastifyRequest): string { + return `${bucket}:${extractClientIp(request)}`; +} + +function pruneExpiredEntries(nowMs: number): void { + for (const [key, entry] of rateLimitStore.entries()) { + if (entry.resetAt > nowMs) continue; + rateLimitStore.delete(key); + } +} + +export function resetRequestRateLimitStore(): void { + rateLimitStore.clear(); +} + +export function createRateLimitGuard(options: RateLimitOptions) { + const message = options.message || DEFAULT_MESSAGE; + + return async function rateLimitGuard(request: FastifyRequest, reply: FastifyReply) { + const nowMs = Date.now(); + pruneExpiredEntries(nowMs); + + const key = getRateLimitKey(options.bucket, request); + const current = rateLimitStore.get(key); + + if (!current || current.resetAt <= nowMs) { + rateLimitStore.set(key, { + count: 1, + resetAt: nowMs + options.windowMs, + }); + return; + } + + if (current.count >= options.max) { + const retryAfterSec = Math.max(1, Math.ceil((current.resetAt - nowMs) / 1000)); + reply + .code(429) + .header('retry-after', String(retryAfterSec)) + .send({ success: false, message }); + return; + } + + current.count += 1; + rateLimitStore.set(key, current); + }; +} diff --git a/src/server/routes/api/accounts.login-shield.test.ts b/src/server/routes/api/accounts.login-shield.test.ts index 428c79b..3633efe 100644 --- a/src/server/routes/api/accounts.login-shield.test.ts +++ b/src/server/routes/api/accounts.login-shield.test.ts @@ -3,6 +3,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vites import { mkdtempSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { resetRequestRateLimitStore } from '../../middleware/requestRateLimit.js'; const loginMock = vi.fn(); @@ -36,6 +37,7 @@ describe('accounts login shield detection', () => { beforeEach(async () => { loginMock.mockReset(); + resetRequestRateLimitStore(); await db.delete(schema.proxyLogs).run(); await db.delete(schema.checkinLogs).run(); @@ -82,4 +84,48 @@ describe('accounts login shield detection', () => { expect((body.message || '').toLowerCase()).toContain('shield'); expect(body.message || '').not.toContain('Unexpected token'); }); + + it('rate limits repeated login attempts from the same client ip', async () => { + loginMock.mockResolvedValue({ + success: false, + message: 'invalid credentials', + }); + + const site = await db.insert(schema.sites).values({ + name: 'AnyRouter', + url: 'https://anyrouter.example.com', + platform: 'new-api', + }).returning().get(); + + for (let attempt = 0; attempt < 5; attempt += 1) { + const response = await app.inject({ + method: 'POST', + url: '/api/accounts/login', + remoteAddress: '198.51.100.10', + payload: { + siteId: site.id, + username: 'demo-user', + password: 'demo-password', + }, + }); + expect(response.statusCode).toBe(200); + } + + const limited = await app.inject({ + method: 'POST', + url: '/api/accounts/login', + remoteAddress: '198.51.100.10', + payload: { + siteId: site.id, + username: 'demo-user', + password: 'demo-password', + }, + }); + + expect(limited.statusCode).toBe(429); + expect(limited.json()).toMatchObject({ + success: false, + message: '请求过于频繁,请稍后再试', + }); + }); }); diff --git a/src/server/routes/api/accounts.ts b/src/server/routes/api/accounts.ts index 75705e9..c298ec8 100644 --- a/src/server/routes/api/accounts.ts +++ b/src/server/routes/api/accounts.ts @@ -26,6 +26,7 @@ import { } from '../../services/accountHealthService.js'; import { appendSessionTokenRebindHint } from '../../services/alertRules.js'; import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; +import { createRateLimitGuard } from '../../middleware/requestRateLimit.js'; type AccountWithSiteRow = { accounts: typeof schema.accounts.$inferSelect; @@ -49,6 +50,18 @@ type AccountCapabilities = { type VerifyFailureReason = 'needs-user-id' | 'invalid-user-id' | 'shield-blocked' | null; +const limitAccountLogin = createRateLimitGuard({ + bucket: 'accounts-login', + max: 5, + windowMs: 60_000, +}); + +const limitAccountVerifyToken = createRateLimitGuard({ + bucket: 'accounts-verify-token', + max: 5, + windowMs: 60_000, +}); + function hasSessionTokenValue(value: string | null | undefined): boolean { return typeof value === 'string' && value.trim().length > 0; } @@ -421,7 +434,10 @@ export async function accountsRoutes(app: FastifyInstance) { }); // Login to a site and auto-create account - app.post<{ Body: { siteId: number; username: string; password: string } }>('/api/accounts/login', async (request) => { + app.post<{ Body: { siteId: number; username: string; password: string } }>( + '/api/accounts/login', + { preHandler: [limitAccountLogin] }, + async (request) => { const { siteId, username, password } = request.body; // Get site info @@ -529,10 +545,14 @@ export async function accountsRoutes(app: FastifyInstance) { tokenCount: apiTokens.length, reusedAccount: !!existing, }; - }); + }, + ); // Verify credentials against a site. - app.post<{ Body: { siteId: number; accessToken: string; platformUserId?: number; credentialMode?: AccountCredentialMode } }>('/api/accounts/verify-token', async (request) => { + app.post<{ Body: { siteId: number; accessToken: string; platformUserId?: number; credentialMode?: AccountCredentialMode } }>( + '/api/accounts/verify-token', + { preHandler: [limitAccountVerifyToken] }, + async (request) => { const { siteId, platformUserId } = request.body; const accessToken = (request.body.accessToken || '').trim(); const credentialMode = resolveRequestedCredentialMode(request.body.credentialMode); @@ -831,7 +851,8 @@ export async function accountsRoutes(app: FastifyInstance) { ? 'Session Token 验证失败' : 'Token invalid: cannot use it as session cookie or API key', }; - }); + }, + ); app.post<{ Params: { id: string }; Body: { accessToken: string; platformUserId?: number; refreshToken?: string; tokenExpiresAt?: number | string } }>( '/api/accounts/:id/rebind-session', diff --git a/src/server/routes/api/accounts.verifyTokenShield.test.ts b/src/server/routes/api/accounts.verifyTokenShield.test.ts index 68ddc0c..ec9286b 100644 --- a/src/server/routes/api/accounts.verifyTokenShield.test.ts +++ b/src/server/routes/api/accounts.verifyTokenShield.test.ts @@ -3,6 +3,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vites import { mkdtempSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { resetRequestRateLimitStore } from '../../middleware/requestRateLimit.js'; const verifyTokenMock = vi.fn(); const undiciFetchMock = vi.fn(); @@ -45,6 +46,7 @@ describe('accounts verify-token shield detection', () => { verifyTokenMock.mockReset(); undiciFetchMock.mockReset(); adapterPlatformName = 'new-api'; + resetRequestRateLimitStore(); await db.delete(schema.proxyLogs).run(); await db.delete(schema.checkinLogs).run(); @@ -87,6 +89,45 @@ describe('accounts verify-token shield detection', () => { }); }); + it('rate limits repeated verify-token attempts from the same client ip', async () => { + verifyTokenMock.mockRejectedValue(new Error('invalid access token')); + + const site = await db.insert(schema.sites).values({ + name: 'AnyRouter', + url: 'https://anyrouter.example.com', + platform: 'new-api', + }).returning().get(); + + for (let attempt = 0; attempt < 5; attempt += 1) { + const response = await app.inject({ + method: 'POST', + url: '/api/accounts/verify-token', + remoteAddress: '198.51.100.11', + payload: { + siteId: site.id, + accessToken: 'session-or-cookie-token', + }, + }); + expect(response.statusCode).toBe(200); + } + + const limited = await app.inject({ + method: 'POST', + url: '/api/accounts/verify-token', + remoteAddress: '198.51.100.11', + payload: { + siteId: site.id, + accessToken: 'session-or-cookie-token', + }, + }); + + expect(limited.statusCode).toBe(429); + expect(limited.json()).toMatchObject({ + success: false, + message: '请求过于频繁,请稍后再试', + }); + }); + it('avoids raw shieldBlocked misclassification for new-api when verifyToken returned tokenType unknown', async () => { verifyTokenMock.mockResolvedValueOnce({ tokenType: 'unknown' }); undiciFetchMock.mockResolvedValue({ diff --git a/src/server/routes/api/auth.ts b/src/server/routes/api/auth.ts index 2652456..a5528cd 100644 --- a/src/server/routes/api/auth.ts +++ b/src/server/routes/api/auth.ts @@ -3,10 +3,20 @@ import { db, schema } from '../../db/index.js'; import { config } from '../../config.js'; import { eq } from 'drizzle-orm'; import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; +import { createRateLimitGuard } from '../../middleware/requestRateLimit.js'; + +const limitAdminTokenChange = createRateLimitGuard({ + bucket: 'auth-change', + max: 3, + windowMs: 60_000, +}); export async function authRoutes(app: FastifyInstance) { // Change admin auth token (requires old token verification) - app.post<{ Body: { oldToken: string; newToken: string } }>('/api/settings/auth/change', async (request, reply) => { + app.post<{ Body: { oldToken: string; newToken: string } }>( + '/api/settings/auth/change', + { preHandler: [limitAdminTokenChange] }, + async (request, reply) => { const { oldToken, newToken } = request.body; if (!oldToken || !newToken) { @@ -45,7 +55,8 @@ export async function authRoutes(app: FastifyInstance) { } catch {} return { success: true, message: 'Token 已更新' }; - }); + }, + ); // Get masked current token (for display) app.get('/api/settings/auth/info', async () => { diff --git a/src/server/routes/api/monitor.ts b/src/server/routes/api/monitor.ts index 845b122..7dd375b 100644 --- a/src/server/routes/api/monitor.ts +++ b/src/server/routes/api/monitor.ts @@ -3,11 +3,36 @@ import { db, schema } from '../../db/index.js'; import { upsertSetting } from '../../db/upsertSetting.js'; import { config } from '../../config.js'; import { eq } from 'drizzle-orm'; +import { createRateLimitGuard } from '../../middleware/requestRateLimit.js'; const MONITOR_AUTH_COOKIE = 'meta_monitor_auth'; const LDOH_BASE_URL = 'https://ldoh.105117.xyz'; const LDOH_COOKIE_SETTING_KEY = 'monitor_ldoh_cookie'; +const limitMonitorConfigRead = createRateLimitGuard({ + bucket: 'monitor-config-read', + max: 30, + windowMs: 60_000, +}); + +const limitMonitorConfigWrite = createRateLimitGuard({ + bucket: 'monitor-config-write', + max: 10, + windowMs: 60_000, +}); + +const limitMonitorSession = createRateLimitGuard({ + bucket: 'monitor-session', + max: 10, + windowMs: 60_000, +}); + +const limitMonitorProxy = createRateLimitGuard({ + bucket: 'monitor-proxy', + max: 60, + windowMs: 60_000, +}); + async function getSettingString(key: string): Promise { @@ -103,7 +128,7 @@ function resolveLdohProxyPath(request: FastifyRequest): string { } export async function monitorRoutes(app: FastifyInstance) { - app.get('/api/monitor/config', async () => { + app.get('/api/monitor/config', { preHandler: [limitMonitorConfigRead] }, async () => { const ldohCookie = await getSettingString(LDOH_COOKIE_SETTING_KEY); return { ldohCookieConfigured: !!ldohCookie, @@ -111,7 +136,10 @@ export async function monitorRoutes(app: FastifyInstance) { }; }); - app.put<{ Body: { ldohCookie?: string | null } }>('/api/monitor/config', async (request, reply) => { + app.put<{ Body: { ldohCookie?: string | null } }>( + '/api/monitor/config', + { preHandler: [limitMonitorConfigWrite] }, + async (request, reply) => { const raw = String(request.body?.ldohCookie || '').trim(); if (!raw) { await upsertSetting(LDOH_COOKIE_SETTING_KEY, ''); @@ -130,9 +158,10 @@ export async function monitorRoutes(app: FastifyInstance) { ldohCookieConfigured: true, ldohCookieMasked: maskCookieValue(normalized), }; - }); + }, + ); - app.post('/api/monitor/session', async (_, reply) => { + app.post('/api/monitor/session', { preHandler: [limitMonitorSession] }, async (_, reply) => { // HttpOnly cookie for iframe proxy auth within current origin. reply.header( 'Set-Cookie', @@ -202,7 +231,7 @@ export async function monitorRoutes(app: FastifyInstance) { return reply.send(buffer); }; - app.all('/monitor-proxy/ldoh', handleLdohProxy); - app.all('/monitor-proxy/ldoh/', handleLdohProxy); - app.all('/monitor-proxy/ldoh/*', handleLdohProxy); + app.all('/monitor-proxy/ldoh', { preHandler: [limitMonitorProxy] }, handleLdohProxy); + app.all('/monitor-proxy/ldoh/', { preHandler: [limitMonitorProxy] }, handleLdohProxy); + app.all('/monitor-proxy/ldoh/*', { preHandler: [limitMonitorProxy] }, handleLdohProxy); } diff --git a/src/server/routes/api/settings.events.test.ts b/src/server/routes/api/settings.events.test.ts index 32ba6ca..2b1198c 100644 --- a/src/server/routes/api/settings.events.test.ts +++ b/src/server/routes/api/settings.events.test.ts @@ -4,6 +4,7 @@ import { mkdtempSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { eq } from 'drizzle-orm'; +import { resetRequestRateLimitStore } from '../../middleware/requestRateLimit.js'; type DbModule = typeof import('../../db/index.js'); type ConfigModule = typeof import('../../config.js'); @@ -35,6 +36,7 @@ describe('settings and auth events', () => { }); beforeEach(async () => { + resetRequestRateLimitStore(); await db.delete(schema.events).run(); await db.delete(schema.settings).run(); @@ -433,4 +435,36 @@ describe('settings and auth events', () => { relatedType: 'settings', }); }); + + it('rate limits repeated admin auth token changes from the same client ip', async () => { + for (let attempt = 0; attempt < 3; attempt += 1) { + const response = await app.inject({ + method: 'POST', + url: '/api/settings/auth/change', + remoteAddress: '198.51.100.12', + payload: { + oldToken: config.authToken, + newToken: `new-admin-token-${attempt}-456`, + }, + }); + + expect(response.statusCode).toBe(200); + } + + const limited = await app.inject({ + method: 'POST', + url: '/api/settings/auth/change', + remoteAddress: '198.51.100.12', + payload: { + oldToken: config.authToken, + newToken: 'new-admin-token-rate-limit', + }, + }); + + expect(limited.statusCode).toBe(429); + expect(limited.json()).toMatchObject({ + success: false, + message: '请求过于频繁,请稍后再试', + }); + }); });