diff --git a/firebase-rules-template.json b/firebase-rules-template.json index ab23c8b4..5e6e3d28 100644 --- a/firebase-rules-template.json +++ b/firebase-rules-template.json @@ -470,6 +470,10 @@ ".read": false, ".write": false }, + "staticAuthRateLimits": { + ".read": false, + ".write": false + }, "webauthnCredentials": { ".write": false, "$uid": { diff --git a/functions/auth/cleanupExpiredSignInCodes.js b/functions/auth/cleanupExpiredSignInCodes.js index f73269eb..e5c3066c 100644 --- a/functions/auth/cleanupExpiredSignInCodes.js +++ b/functions/auth/cleanupExpiredSignInCodes.js @@ -43,5 +43,23 @@ exports.cleanupExpiredSignInCodes = onSchedule( await rateLimitsRef.update(updates); } } + + // Static-login per-IP throttle entries share the same windowed shape; prune + // those whose window has passed so the node cannot grow unbounded. + const staticRateLimitsRef = db.ref('/staticAuthRateLimits'); + const staticRateLimitsSnapshot = await staticRateLimitsRef.once('value'); + + if (staticRateLimitsSnapshot.exists()) { + const updates = {}; + staticRateLimitsSnapshot.forEach(child => { + const val = child.val(); + if (now - val.windowStart > RATE_WINDOW_MS) { + updates[child.key] = null; + } + }); + if (Object.keys(updates).length > 0) { + await staticRateLimitsRef.update(updates); + } + } } ); diff --git a/functions/auth/cleanupExpiredSignInCodes.spec.js b/functions/auth/cleanupExpiredSignInCodes.spec.js index e1406fd2..44e2609c 100644 --- a/functions/auth/cleanupExpiredSignInCodes.spec.js +++ b/functions/auth/cleanupExpiredSignInCodes.spec.js @@ -5,6 +5,7 @@ describe('functions', () => { let capturedOptions; let mockCodesRef; let mockRateLimitsRef; + let mockStaticRateLimitsRef; const now = Date.now(); @@ -23,10 +24,18 @@ describe('functions', () => { once: jest.fn().mockResolvedValue(emptySnapshot), update: jest.fn().mockResolvedValue(undefined), }; + mockStaticRateLimitsRef = { + once: jest.fn().mockResolvedValue(emptySnapshot), + update: jest.fn().mockResolvedValue(undefined), + }; + const refs = { + '/signInRateLimits': mockRateLimitsRef, + '/staticAuthRateLimits': mockStaticRateLimitsRef, + }; mockAdmin = { database: jest.fn().mockReturnValue({ - ref: jest.fn(path => path === '/signInRateLimits' ? mockRateLimitsRef : mockCodesRef) + ref: jest.fn(path => refs[path] || mockCodesRef) }) }; @@ -147,6 +156,17 @@ describe('functions', () => { expect(mockRateLimitsRef.update).not.toHaveBeenCalled(); }); + it('prunes static-login throttle entries older than the window', async () => { + mockStaticRateLimitsRef.once.mockResolvedValue(makeSnapshot([ + { key: 's1', val: { windowStart: now - (61 * 60 * 1000), count: 10 } }, // stale + { key: 's2', val: { windowStart: now - (5 * 60 * 1000), count: 3 } }, // recent + ])); + + await capturedHandler(); + + expect(mockStaticRateLimitsRef.update).toHaveBeenCalledWith({ s1: null }); + }); + it('is scheduled to run every 60 minutes', () => { expect(capturedOptions.schedule).toBe('every 60 minutes'); }); diff --git a/functions/auth/modes/static/index.js b/functions/auth/modes/static/index.js index 3c3cfd07..0d40933a 100644 --- a/functions/auth/modes/static/index.js +++ b/functions/auth/modes/static/index.js @@ -1,7 +1,20 @@ 'use strict'; +const crypto = require('crypto'); +const admin = require('firebase-admin'); +const { logger } = require('firebase-functions/v2'); const requestHelper = require('../../util/requestHelper'); +// Per-IP throttle for the static login. Static credentials are a shared secret +// whose strength is the operator's choice, so cap online guessing: after +// MAX_FAILURES failed attempts from one IP within WINDOW_MS, further attempts +// from that IP are rejected until the window elapses; a successful login clears +// the counter. Best-effort — the client IP comes from X-Forwarded-For, so this +// deters opportunistic guessing rather than a determined attacker; the durable +// defenses are a strong credential and moving off static auth. +const MAX_FAILURES = 10; +const WINDOW_MS = 15 * 60 * 1000; + const parseStaticCredentials = () => { const raw = process.env.AUTH_STATIC_CREDENTIALS; if (raw) { @@ -18,7 +31,54 @@ const parseStaticCredentials = () => { const staticCredentials = parseStaticCredentials(); +// RTDB keys cannot contain '.'/':' (IPv4/IPv6), so key the limiter by a hash. +const ipKey = (ip) => crypto.createHash('sha256').update(ip).digest('hex'); + +const authenticate = async (req, username, password) => { + const ip = requestHelper.getIp(req); + const limiterRef = ip + ? admin.database().ref('/staticAuthRateLimits/' + ipKey(ip)) + : null; + const now = Date.now(); + + // Reject while the IP is in a blocked window, before (and instead of) the + // credential check so the block holds even for a correct guess. Return the + // same null as a wrong password — no "rate limited" oracle. + if (limiterRef) { + const current = (await limiterRef.once('value')).val(); + if (current && now - current.windowStart < WINDOW_MS && current.count >= MAX_FAILURES) { + logger.warn(`Static login throttled: ${current.count} failed attempts within the window`); + return null; + } + } + + const match = staticCredentials.find( + login => login.username === username && login.password === password + ); + + if (match) { + if (limiterRef) { + await limiterRef.remove(); // success clears the failure counter + } + return username; + } + + // Failed attempt: increment the windowed failure counter for this IP. + if (limiterRef) { + await limiterRef.transaction(current => { + if (!current || now - current.windowStart >= WINDOW_MS) { + return { windowStart: now, count: 1 }; + } + return { windowStart: current.windowStart, count: current.count + 1 }; + }); + } + return null; +}; + module.exports = req => { + // Validate synchronously so a missing property throws a ClientError the + // dispatcher's try/catch can turn into a 400 (an async throw would reject + // instead). const username = requestHelper.requireBodyProperty(req, 'username'); const password = requestHelper.requireBodyProperty(req, 'password'); @@ -26,6 +86,5 @@ module.exports = req => { return Promise.resolve(null); } - const match = staticCredentials.find(login => login.username === username && login.password === password); - return Promise.resolve(match ? username : null); + return authenticate(req, username, password); }; diff --git a/functions/auth/modes/static/index.spec.js b/functions/auth/modes/static/index.spec.js index 8d55dfe2..99cad180 100644 --- a/functions/auth/modes/static/index.spec.js +++ b/functions/auth/modes/static/index.spec.js @@ -1,5 +1,31 @@ 'use strict'; +const crypto = require('crypto'); + +// In-memory RTDB stub shared by all requires of the module under test. +let mockStore = {}; +const mockDatabase = { + ref: (path) => ({ + once: () => Promise.resolve({ val: () => (path in mockStore ? mockStore[path] : null) }), + remove: () => { delete mockStore[path]; return Promise.resolve(); }, + transaction: (fn) => { + const next = fn(path in mockStore ? mockStore[path] : null); + if (next !== undefined) { + mockStore[path] = next; + } + return Promise.resolve({ committed: next !== undefined }); + }, + }), +}; + +const mockLogger = { warn: jest.fn(), info: jest.fn(), error: jest.fn() }; + +jest.mock('firebase-admin', () => ({ database: () => mockDatabase })); +jest.mock('firebase-functions/v2', () => ({ logger: mockLogger })); + +const WINDOW_MS = 15 * 60 * 1000; +const keyPath = (ip) => '/staticAuthRateLimits/' + crypto.createHash('sha256').update(ip).digest('hex'); + describe('functions', () => { describe('auth', () => { describe('modes', () => { @@ -14,10 +40,22 @@ describe('functions', () => { return require('.'); }; + const reqWithIp = (username, password, ip = '203.0.113.7') => ({ + body: { username, password }, + headers: { 'x-forwarded-for': ip }, + }); + + beforeEach(() => { + mockStore = {}; + jest.clearAllMocks(); + }); + afterEach(() => { delete process.env.AUTH_STATIC_CREDENTIALS; }); + // --- existing behaviour (no IP -> throttle skipped) --- + it('should throw a ClientError if `username` is missing in request body', () => { const staticMode = loadStatic('alice:pw1'); const request = { body: { password: 'pw1' } }; @@ -59,6 +97,48 @@ describe('functions', () => { const request = { body: { username: 'alice', password: 'pw1' } }; return expect(staticMode(request)).resolves.toBeNull(); }); + + // --- per-IP throttle --- + + it('blocks further attempts from an IP after MAX_FAILURES failures', async () => { + const staticMode = loadStatic('alice:pw1'); + for (let i = 0; i < 10; i++) { + expect(await staticMode(reqWithIp('alice', 'wrong'))).toBeNull(); + } + // 11th attempt is rejected even with the CORRECT password. + expect(await staticMode(reqWithIp('alice', 'pw1'))).toBeNull(); + expect(mockLogger.warn).toHaveBeenCalled(); + }); + + it('does not block a different IP', async () => { + const staticMode = loadStatic('alice:pw1'); + for (let i = 0; i < 10; i++) { + await staticMode(reqWithIp('alice', 'wrong', '203.0.113.7')); + } + expect(await staticMode(reqWithIp('alice', 'pw1', '198.51.100.9'))).toEqual('alice'); + }); + + it('a successful login clears the failure counter', async () => { + const staticMode = loadStatic('alice:pw1'); + for (let i = 0; i < 3; i++) { + await staticMode(reqWithIp('alice', 'wrong')); + } + expect(await staticMode(reqWithIp('alice', 'pw1'))).toEqual('alice'); + expect(mockStore[keyPath('203.0.113.7')]).toBeUndefined(); + }); + + it('auto-recovers once the window has elapsed', async () => { + const staticMode = loadStatic('alice:pw1'); + // Seed a maxed-out counter whose window started more than WINDOW_MS ago. + mockStore[keyPath('203.0.113.7')] = { windowStart: Date.now() - (WINDOW_MS + 1000), count: 10 }; + expect(await staticMode(reqWithIp('alice', 'pw1'))).toEqual('alice'); + }); + + it('does not touch the limiter when the request has no IP', async () => { + const staticMode = loadStatic('alice:pw1'); + expect(await staticMode({ body: { username: 'alice', password: 'pw1' } })).toEqual('alice'); + expect(Object.keys(mockStore)).toHaveLength(0); + }); }); }); }); diff --git a/functions/auth/util/requestHelper.js b/functions/auth/util/requestHelper.js index 213a07e5..888b46e4 100644 --- a/functions/auth/util/requestHelper.js +++ b/functions/auth/util/requestHelper.js @@ -11,7 +11,8 @@ const requireBodyProperty = (req, property) => { }; const getIp = req => { - const ip = req.headers['x-forwarded-for'] || req.connection.remoteAddress; + const ip = (req.headers && req.headers['x-forwarded-for']) || + (req.connection && req.connection.remoteAddress); if (ip) { return ip.split(',')[0].trim(); }