forked from boos/BoosAPI
security: throttle sensitive admin routes (#111)
This commit is contained in:
@@ -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: '请求过于频繁,请稍后再试',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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: '请求过于频繁,请稍后再试',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user