Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions firebase-rules-template.json
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,10 @@
".read": false,
".write": false
},
"staticAuthRateLimits": {
".read": false,
".write": false
},
"webauthnCredentials": {
".write": false,
"$uid": {
Expand Down
18 changes: 18 additions & 0 deletions functions/auth/cleanupExpiredSignInCodes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
);
22 changes: 21 additions & 1 deletion functions/auth/cleanupExpiredSignInCodes.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ describe('functions', () => {
let capturedOptions;
let mockCodesRef;
let mockRateLimitsRef;
let mockStaticRateLimitsRef;

const now = Date.now();

Expand All @@ -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)
})
};

Expand Down Expand Up @@ -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');
});
Expand Down
63 changes: 61 additions & 2 deletions functions/auth/modes/static/index.js
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -18,14 +31,60 @@ 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');

if (!staticCredentials) {
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);
};
80 changes: 80 additions & 0 deletions functions/auth/modes/static/index.spec.js
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -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' } };
Expand Down Expand Up @@ -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);
});
});
});
});
Expand Down
3 changes: 2 additions & 1 deletion functions/auth/util/requestHelper.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
Loading