From 29428cee08416ecfa1aa387b8a12761b5661b696 Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Tue, 29 Sep 2026 13:00:57 +0700 Subject: [PATCH] fix: rate limit standalone login attempts --- src/portal.ts | 57 +++++++++++++++++++++++++++++++- tests/integration/portal.test.ts | 27 +++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/src/portal.ts b/src/portal.ts index 052625f..f22dc6d 100644 --- a/src/portal.ts +++ b/src/portal.ts @@ -87,6 +87,13 @@ interface SessionBody { const sessionCookieName = '__Host-krona_staff_session'; const sessionMaxAgeSeconds = 8 * 60 * 60; +const loginRateLimitWindowMs = 60_000; +const loginRateLimitMaxFailures = 10; + +interface LoginRateLimitEntry { + failures: number; + resetAt: number; +} export async function registerPortalRoutes( api: FastifyInstance, @@ -98,6 +105,7 @@ export async function registerPortalRoutes( ? new PlatformIdentityAdapter() : new StandaloneIdentityAdapter(config); const identity = withStandaloneEmployeeAccess(baseIdentity, config, database); + const loginRateLimit = new Map(); const vapidKeys = resolveVapidKeys(config); const pushDispatcher = new PushDispatcher({ @@ -243,15 +251,33 @@ export async function registerPortalRoutes( if (config.STAFF_RUNTIME_MODE === 'standalone' && !config.STANDALONE_MANAGER_TOKEN) { return reply.serviceUnavailable('Вход администратора ещё не настроен.'); } - const context = await resolveToken(identity, request.body.access_token, request); + const rateLimitKey = request.ip; + const retryAfter = getLoginRetryAfter(loginRateLimit, rateLimitKey); + if (retryAfter > 0) { + return reply.code(429).header('retry-after', retryAfter).send({ + statusCode: 429, + error: 'Too Many Requests', + message: 'Слишком много неудачных попыток входа. Повторите позже.', + }); + } + + let context: RequestContext; + try { + context = await resolveToken(identity, request.body.access_token, request); + } catch (error) { + recordLoginFailure(loginRateLimit, rateLimitKey); + throw error; + } const role = context.permissions.has('staff:attendance:manage') ? 'manager' : context.permissions.has('staff:attendance:read_own') ? 'employee' : null; if (!role) { + recordLoginFailure(loginRateLimit, rateLimitKey); return reply.forbidden('Ключ не подходит для входа в кабинет.'); } + loginRateLimit.delete(rateLimitKey); reply .header('cache-control', 'no-store') .header( @@ -1249,6 +1275,35 @@ export async function registerPortalRoutes( }); } +function getLoginRetryAfter( + entries: Map, + key: string, + now = Date.now(), +): number { + const entry = entries.get(key); + if (!entry) return 0; + if (entry.resetAt <= now) { + entries.delete(key); + return 0; + } + return entry.failures >= loginRateLimitMaxFailures + ? Math.max(1, Math.ceil((entry.resetAt - now) / 1000)) + : 0; +} + +function recordLoginFailure( + entries: Map, + key: string, + now = Date.now(), +) { + const entry = entries.get(key); + if (!entry || entry.resetAt <= now) { + entries.set(key, { failures: 1, resetAt: now + loginRateLimitWindowMs }); + return; + } + entry.failures += 1; +} + function withStandaloneEmployeeAccess( baseIdentity: IdentityPort, config: AppConfig, diff --git a/tests/integration/portal.test.ts b/tests/integration/portal.test.ts index dda8db2..23419bf 100644 --- a/tests/integration/portal.test.ts +++ b/tests/integration/portal.test.ts @@ -98,6 +98,33 @@ describe('attendance portal', () => { await app.close(); }); + it('rate-limits repeated failed browser logins', async () => { + const { database } = fakeDatabase([]); + const app = await buildApp({ + config: loadConfig({ ...base, ...tokenConfig, STANDALONE_ACCESS: 'manager' }), + database, + }); + + let response; + for (let attempt = 0; attempt < 10; attempt += 1) { + response = await app.inject({ + method: 'POST', + url: '/api/v1/staff/session', + payload: { access_token: 'x'.repeat(64) }, + }); + expect(response.statusCode).toBe(401); + } + + response = await app.inject({ + method: 'POST', + url: '/api/v1/staff/session', + payload: { access_token: 'x'.repeat(64) }, + }); + expect(response.statusCode).toBe(429); + expect(response.headers['retry-after']).toBeDefined(); + await app.close(); + }); + it('scopes an employee portal to the linked Core user', async () => { const { database, calls } = fakeDatabase([[], [], []]); const app = await buildApp({