security: throttle sensitive admin routes (#111)

This commit is contained in:
Cita
2026-03-15 17:28:34 +08:00
committed by GitHub
parent 20480c4964
commit 67ad62c0d5
7 changed files with 280 additions and 13 deletions
+85
View File
@@ -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<string, RateLimitEntry>();
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);
};
}
@@ -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: '请求过于频繁,请稍后再试',
});
});
});
+25 -4
View File
@@ -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',
@@ -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({
+13 -2
View File
@@ -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 () => {
+36 -7
View File
@@ -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<string> {
@@ -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);
}
@@ -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: '请求过于频繁,请稍后再试',
});
});
});