security: throttle sensitive admin routes (#111)
This commit is contained in:
@@ -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 { mkdtempSync } from 'node:fs';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
|
import { resetRequestRateLimitStore } from '../../middleware/requestRateLimit.js';
|
||||||
|
|
||||||
const loginMock = vi.fn();
|
const loginMock = vi.fn();
|
||||||
|
|
||||||
@@ -36,6 +37,7 @@ describe('accounts login shield detection', () => {
|
|||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
loginMock.mockReset();
|
loginMock.mockReset();
|
||||||
|
resetRequestRateLimitStore();
|
||||||
|
|
||||||
await db.delete(schema.proxyLogs).run();
|
await db.delete(schema.proxyLogs).run();
|
||||||
await db.delete(schema.checkinLogs).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 || '').toLowerCase()).toContain('shield');
|
||||||
expect(body.message || '').not.toContain('Unexpected token');
|
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: '请求过于频繁,请稍后再试',
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
} from '../../services/accountHealthService.js';
|
} from '../../services/accountHealthService.js';
|
||||||
import { appendSessionTokenRebindHint } from '../../services/alertRules.js';
|
import { appendSessionTokenRebindHint } from '../../services/alertRules.js';
|
||||||
import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
|
import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
|
||||||
|
import { createRateLimitGuard } from '../../middleware/requestRateLimit.js';
|
||||||
|
|
||||||
type AccountWithSiteRow = {
|
type AccountWithSiteRow = {
|
||||||
accounts: typeof schema.accounts.$inferSelect;
|
accounts: typeof schema.accounts.$inferSelect;
|
||||||
@@ -49,6 +50,18 @@ type AccountCapabilities = {
|
|||||||
|
|
||||||
type VerifyFailureReason = 'needs-user-id' | 'invalid-user-id' | 'shield-blocked' | null;
|
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 {
|
function hasSessionTokenValue(value: string | null | undefined): boolean {
|
||||||
return typeof value === 'string' && value.trim().length > 0;
|
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
|
// 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;
|
const { siteId, username, password } = request.body;
|
||||||
|
|
||||||
// Get site info
|
// Get site info
|
||||||
@@ -529,10 +545,14 @@ export async function accountsRoutes(app: FastifyInstance) {
|
|||||||
tokenCount: apiTokens.length,
|
tokenCount: apiTokens.length,
|
||||||
reusedAccount: !!existing,
|
reusedAccount: !!existing,
|
||||||
};
|
};
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Verify credentials against a site.
|
// 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 { siteId, platformUserId } = request.body;
|
||||||
const accessToken = (request.body.accessToken || '').trim();
|
const accessToken = (request.body.accessToken || '').trim();
|
||||||
const credentialMode = resolveRequestedCredentialMode(request.body.credentialMode);
|
const credentialMode = resolveRequestedCredentialMode(request.body.credentialMode);
|
||||||
@@ -831,7 +851,8 @@ export async function accountsRoutes(app: FastifyInstance) {
|
|||||||
? 'Session Token 验证失败'
|
? 'Session Token 验证失败'
|
||||||
: 'Token invalid: cannot use it as session cookie or API key',
|
: '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 } }>(
|
app.post<{ Params: { id: string }; Body: { accessToken: string; platformUserId?: number; refreshToken?: string; tokenExpiresAt?: number | string } }>(
|
||||||
'/api/accounts/:id/rebind-session',
|
'/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 { mkdtempSync } from 'node:fs';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
|
import { resetRequestRateLimitStore } from '../../middleware/requestRateLimit.js';
|
||||||
|
|
||||||
const verifyTokenMock = vi.fn();
|
const verifyTokenMock = vi.fn();
|
||||||
const undiciFetchMock = vi.fn();
|
const undiciFetchMock = vi.fn();
|
||||||
@@ -45,6 +46,7 @@ describe('accounts verify-token shield detection', () => {
|
|||||||
verifyTokenMock.mockReset();
|
verifyTokenMock.mockReset();
|
||||||
undiciFetchMock.mockReset();
|
undiciFetchMock.mockReset();
|
||||||
adapterPlatformName = 'new-api';
|
adapterPlatformName = 'new-api';
|
||||||
|
resetRequestRateLimitStore();
|
||||||
|
|
||||||
await db.delete(schema.proxyLogs).run();
|
await db.delete(schema.proxyLogs).run();
|
||||||
await db.delete(schema.checkinLogs).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 () => {
|
it('avoids raw shieldBlocked misclassification for new-api when verifyToken returned tokenType unknown', async () => {
|
||||||
verifyTokenMock.mockResolvedValueOnce({ tokenType: 'unknown' });
|
verifyTokenMock.mockResolvedValueOnce({ tokenType: 'unknown' });
|
||||||
undiciFetchMock.mockResolvedValue({
|
undiciFetchMock.mockResolvedValue({
|
||||||
|
|||||||
@@ -3,10 +3,20 @@ import { db, schema } from '../../db/index.js';
|
|||||||
import { config } from '../../config.js';
|
import { config } from '../../config.js';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
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) {
|
export async function authRoutes(app: FastifyInstance) {
|
||||||
// Change admin auth token (requires old token verification)
|
// 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;
|
const { oldToken, newToken } = request.body;
|
||||||
|
|
||||||
if (!oldToken || !newToken) {
|
if (!oldToken || !newToken) {
|
||||||
@@ -45,7 +55,8 @@ export async function authRoutes(app: FastifyInstance) {
|
|||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
return { success: true, message: 'Token 已更新' };
|
return { success: true, message: 'Token 已更新' };
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Get masked current token (for display)
|
// Get masked current token (for display)
|
||||||
app.get('/api/settings/auth/info', async () => {
|
app.get('/api/settings/auth/info', async () => {
|
||||||
|
|||||||
@@ -3,11 +3,36 @@ import { db, schema } from '../../db/index.js';
|
|||||||
import { upsertSetting } from '../../db/upsertSetting.js';
|
import { upsertSetting } from '../../db/upsertSetting.js';
|
||||||
import { config } from '../../config.js';
|
import { config } from '../../config.js';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { createRateLimitGuard } from '../../middleware/requestRateLimit.js';
|
||||||
|
|
||||||
const MONITOR_AUTH_COOKIE = 'meta_monitor_auth';
|
const MONITOR_AUTH_COOKIE = 'meta_monitor_auth';
|
||||||
const LDOH_BASE_URL = 'https://ldoh.105117.xyz';
|
const LDOH_BASE_URL = 'https://ldoh.105117.xyz';
|
||||||
const LDOH_COOKIE_SETTING_KEY = 'monitor_ldoh_cookie';
|
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> {
|
async function getSettingString(key: string): Promise<string> {
|
||||||
@@ -103,7 +128,7 @@ function resolveLdohProxyPath(request: FastifyRequest): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function monitorRoutes(app: FastifyInstance) {
|
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);
|
const ldohCookie = await getSettingString(LDOH_COOKIE_SETTING_KEY);
|
||||||
return {
|
return {
|
||||||
ldohCookieConfigured: !!ldohCookie,
|
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();
|
const raw = String(request.body?.ldohCookie || '').trim();
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
await upsertSetting(LDOH_COOKIE_SETTING_KEY, '');
|
await upsertSetting(LDOH_COOKIE_SETTING_KEY, '');
|
||||||
@@ -130,9 +158,10 @@ export async function monitorRoutes(app: FastifyInstance) {
|
|||||||
ldohCookieConfigured: true,
|
ldohCookieConfigured: true,
|
||||||
ldohCookieMasked: maskCookieValue(normalized),
|
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.
|
// HttpOnly cookie for iframe proxy auth within current origin.
|
||||||
reply.header(
|
reply.header(
|
||||||
'Set-Cookie',
|
'Set-Cookie',
|
||||||
@@ -202,7 +231,7 @@ export async function monitorRoutes(app: FastifyInstance) {
|
|||||||
return reply.send(buffer);
|
return reply.send(buffer);
|
||||||
};
|
};
|
||||||
|
|
||||||
app.all('/monitor-proxy/ldoh', handleLdohProxy);
|
app.all('/monitor-proxy/ldoh', { preHandler: [limitMonitorProxy] }, handleLdohProxy);
|
||||||
app.all('/monitor-proxy/ldoh/', handleLdohProxy);
|
app.all('/monitor-proxy/ldoh/', { preHandler: [limitMonitorProxy] }, handleLdohProxy);
|
||||||
app.all('/monitor-proxy/ldoh/*', handleLdohProxy);
|
app.all('/monitor-proxy/ldoh/*', { preHandler: [limitMonitorProxy] }, handleLdohProxy);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { mkdtempSync } from 'node:fs';
|
|||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { resetRequestRateLimitStore } from '../../middleware/requestRateLimit.js';
|
||||||
|
|
||||||
type DbModule = typeof import('../../db/index.js');
|
type DbModule = typeof import('../../db/index.js');
|
||||||
type ConfigModule = typeof import('../../config.js');
|
type ConfigModule = typeof import('../../config.js');
|
||||||
@@ -35,6 +36,7 @@ describe('settings and auth events', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
resetRequestRateLimitStore();
|
||||||
await db.delete(schema.events).run();
|
await db.delete(schema.events).run();
|
||||||
await db.delete(schema.settings).run();
|
await db.delete(schema.settings).run();
|
||||||
|
|
||||||
@@ -433,4 +435,36 @@ describe('settings and auth events', () => {
|
|||||||
relatedType: 'settings',
|
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: '请求过于频繁,请稍后再试',
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user