Skip to content

Commit c624c4e

Browse files
committed
Merge branch 'main' of github.com:devforth/adminforth
AdminForth/1731/security-audit AdminForth/1731/security-audit
2 parents 822cdb2 + 9aad834 commit c624c4e

7 files changed

Lines changed: 110 additions & 13 deletions

File tree

adminforth/commands/createApp/templates/index.ts.hbs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export const admin = new AdminForth({
1616
usernameField: 'email',
1717
passwordHashField: 'password_hash',
1818
rememberMeDuration: '30d',
19+
rateLimit: ['500/5m', '5000/1h', '10000/1d'],
1920
loginBackgroundImage: 'https://images.unsplash.com/photo-1534239697798-120952b76f2b?q=80&w=3389&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D',
2021
loginBackgroundPosition: '1/2',
2122
loginPromptHTML: async () => {

adminforth/documentation/docs/tutorial/03-Customization/12-security.md

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,31 @@ new AdminForth({
3030
In this case users who will check "Remember me" checkbox will be logged in for 7 days instead of 24 hours.
3131
3232
33+
## Login rate limits
34+
35+
AdminForth rate-limits login attempts by client IP using `auth.rateLimit`. Password login, OAuth login, and passkey login use the same configured limits, but each login method has its own independent rate-limit bucket.
36+
37+
By default AdminForth uses:
38+
39+
```ts
40+
['500/5m', '5000/1h', '10000/1d']
41+
```
42+
43+
You can override it in the app config:
44+
45+
```ts ./index.ts
46+
new AdminForth({
47+
...
48+
auth: {
49+
rateLimit: ['10/5m', '100/1h', '500/1d']
50+
}
51+
})
52+
```
53+
54+
The format is `requests/period`, where period can use `s`, `m`, `h`, or `d`.
55+
Because rate limits are keyed by client IP, configure `auth.clientIpHeader` when AdminForth runs behind a trusted CDN or reverse proxy. See [Trusting client IP addresses](#trusting-client-ip-addresses).
56+
57+
3358
## Password strength
3459
3560
AdminForth allows to set validation RegExp based rules for any field. This can be reused for password strength validation.
@@ -284,4 +309,4 @@ This means that a user is allowed to make up to 20 requests within one day, and
284309
- h → hours (1h)
285310
- d → days (1d)
286311
287-
> ☝ Сonsume(key) is used to check whether a specific key such as a userId, IP address, or any other identifier has exceeded its allowed request limit. If the limit has not been reached, it returns true, meaning the request is allowed to proceed.
312+
> ☝ Сonsume(key) is used to check whether a specific key such as a userId, IP address, or any other identifier has exceeded its allowed request limit. If the limit has not been reached, it returns true, meaning the request is allowed to proceed.

adminforth/modules/configValidator.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,12 @@ import {
1414
ShowInInput,
1515
ShowInLegacyInput,
1616
ShowInModernInput,
17+
RateLimitString,
1718
} from "../types/Back.js";
1819

1920
import fs from 'fs';
2021
import path from 'path';
21-
import { guessLabelFromName, md5hash, suggestIfTypo, slugifyString } from './utils.js';
22+
import { guessLabelFromName, md5hash, RateLimiter, suggestIfTypo, slugifyString } from './utils.js';
2223
import {
2324
AdminForthSortDirections,
2425
type AdminForthComponentDeclarationFull,
@@ -34,6 +35,8 @@ import { afLogger } from "./logger.js";
3435
import {cascadeChildrenDelete} from './utils.js'
3536

3637
const DEBOUNCE_TIME_MS = 300;
38+
const DEFAULT_AUTH_RATE_LIMIT: RateLimitString[] = ['500/5m', '5000/1h', '10000/1d'];
39+
3740
export default class ConfigValidator implements IConfigValidator {
3841

3942
customComponentsDir: string | undefined;
@@ -1213,6 +1216,19 @@ export default class ConfigValidator implements IConfigValidator {
12131216
}
12141217
}
12151218

1219+
newConfig.auth.rateLimit = newConfig.auth.rateLimit || [...DEFAULT_AUTH_RATE_LIMIT];
1220+
if (!Array.isArray(newConfig.auth.rateLimit)) {
1221+
errors.push(`auth.rateLimit must be an array of strings in format "500/5m"`);
1222+
} else {
1223+
for (const rateLimit of newConfig.auth.rateLimit) {
1224+
try {
1225+
RateLimiter.parseRate(rateLimit);
1226+
} catch (e) {
1227+
errors.push(`auth.rateLimit ${e.message}`);
1228+
}
1229+
}
1230+
}
1231+
12161232
// normalize beforeLoginConfirmation hooks
12171233
const blc = this.inputConfig.auth.beforeLoginConfirmation;
12181234
if (!Array.isArray(blc)) {

adminforth/modules/restApi.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {cascadeChildrenDelete} from './utils.js'
2020

2121
import { afLogger } from "./logger.js";
2222

23-
import { ADMINFORTH_VERSION, listify, md5hash, getLoginPromptHTML, hookResponseError, parseLooseJson } from './utils.js';
23+
import { ADMINFORTH_VERSION, listify, md5hash, getLoginPromptHTML, hookResponseError, parseLooseJson, RateLimiter } from './utils.js';
2424

2525
import AdminForthAuth from "../auth.js";
2626
import { ActionCheckSource, AdminForthActionFront, AdminForthConfigMenuItem, AdminForthDataTypes, AdminForthFilterOperators, AdminForthResourceColumnInputCommon, AdminForthResourceFrontend, AdminForthResourcePages,
@@ -665,6 +665,7 @@ export async function interpretResource(
665665
export default class AdminForthRestAPI implements IAdminForthRestAPI {
666666

667667
adminforth: IAdminForth;
668+
loginRateLimiters: RateLimiter[] = [];
668669

669670
constructor(adminforth: IAdminForth) {
670671
this.adminforth = adminforth;
@@ -718,13 +719,22 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI {
718719
}
719720

720721
registerEndpoints(server: IHttpServer) {
722+
this.loginRateLimiters = this.adminforth.config.auth.rateLimit.map((rate) => new RateLimiter(rate));
723+
721724
server.endpoint({
722725
noAuth: true,
723726
method: 'POST',
724727
path: '/login',
725728
handler: async ({ body, response, headers, query, cookies, requestUrl, tr }) => {
726729

727730
const INVALID_MESSAGE = await tr('Invalid username or password', 'errors');
731+
const loginRateLimitKey = this.adminforth.auth.getClientIp(headers) || 'unknown';
732+
const rateLimitResults = await Promise.all(this.loginRateLimiters.map((limiter) => limiter.consume(loginRateLimitKey)));
733+
if (!rateLimitResults.every(Boolean)) {
734+
response.setStatus(429);
735+
return { error: await tr('Too many login attempts, please try again later', 'errors') };
736+
}
737+
728738
const { username, password, rememberMe } = body;
729739
let adminUser: AdminUser;
730740
let toReturn: { redirectTo?: string, allowedLogin:boolean, error?: string } = { allowedLogin: true };

adminforth/modules/utils.ts

Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import Fuse from 'fuse.js';
55
import crypto from 'crypto';
66
import { AdminForthConfig, AdminForthResource, AdminForthResourceColumnInputCommon,Filters, IAdminForth, Predicate } from '../index.js';
77
import { RateLimiterMemory, RateLimiterAbstract } from "rate-limiter-flexible";
8+
import { PERIOD_UNITS, type PeriodString, type PeriodUnit } from '../types/Back.js';
89
// @ts-ignore-next-line
910

1011

@@ -400,9 +401,26 @@ export function md5hash(str:string) {
400401
return crypto.createHash('md5').update(str).digest('hex');
401402
}
402403

403-
export function convertPeriodToSeconds(period: string): number {
404+
function isPeriodUnit(unit: string): unit is PeriodUnit {
405+
return (PERIOD_UNITS as readonly string[]).includes(unit);
406+
}
407+
408+
function parsePositiveInteger(value: string, fieldName: string, source: string): number {
409+
const parsed = Number(value);
410+
if (!Number.isInteger(parsed) || parsed <= 0) {
411+
throw new Error(`${fieldName} must be a positive integer, got: "${source}"`);
412+
}
413+
return parsed;
414+
}
415+
416+
export function convertPeriodToSeconds(period: PeriodString): number {
417+
const value = period.slice(0, -1);
404418
const periodChar = period.slice(-1);
405-
const duration = parseInt(period.slice(0, -1));
419+
if (!isPeriodUnit(periodChar)) {
420+
throw new Error(`Invalid period: ${period}`);
421+
}
422+
423+
const duration = parsePositiveInteger(value, 'Period', period);
406424
if (periodChar === 's') {
407425
return duration;
408426
} else if (periodChar === 'm') {
@@ -420,23 +438,35 @@ export class RateLimiter {
420438

421439
rateLimiter: RateLimiterAbstract;
422440

441+
static parseRate(rate: unknown): { points: number, duration: number } {
442+
if (typeof rate !== 'string') {
443+
throw new Error('Rate limit must be a string in format "500/5m"');
444+
}
423445

424-
durStringToSeconds(rate: string): number {
425-
if (!rate) {
426-
throw new Error('Rate duration is required');
446+
const parts = rate.split('/');
447+
if (parts.length !== 2) {
448+
throw new Error(`Rate limit must be in format "500/5m", got: "${rate}"`);
427449
}
428450

451+
const [pointsPart, period] = parts;
452+
const periodValue = period.slice(0, -1);
453+
const periodUnit = period.slice(-1);
454+
if (!periodValue || !isPeriodUnit(periodUnit)) {
455+
throw new Error(`Rate limit must be in format "500/5m", got: "${rate}"`);
456+
}
429457

430-
return convertPeriodToSeconds(rate);
458+
return {
459+
points: parsePositiveInteger(pointsPart, 'Rate limit points', rate),
460+
duration: convertPeriodToSeconds(period as PeriodString),
461+
};
431462
}
432463

433464

434465
constructor(rate: string) {
435-
const [points, duration] = rate.split('/');
436-
const durationSeconds = this.durStringToSeconds(duration);
466+
const { points, duration } = RateLimiter.parseRate(rate);
437467
const opts = {
438-
points: parseInt(points),
439-
duration: durationSeconds, // Per second
468+
points,
469+
duration, // Per second
440470
};
441471
this.rateLimiter = new RateLimiterMemory(opts);
442472
}

adminforth/types/Back.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ import { ActionCheckSource, AdminForthFilterOperators, AdminForthSortDirections,
1818
type ColumnMinMaxValue,
1919
} from './Common.js';
2020

21+
export const PERIOD_UNITS = ['s', 'm', 'h', 'd'] as const;
22+
export type PeriodUnit = typeof PERIOD_UNITS[number];
23+
export type PeriodString = `${bigint}${PeriodUnit}`;
24+
export type RateLimitString = `${bigint}/${PeriodString}`;
25+
2126
export interface ICodeInjector {
2227
srcFoldersToSync: Object;
2328
allComponentNames: Object;
@@ -1719,6 +1724,13 @@ export interface AdminForthInputConfig {
17191724
*/
17201725
rememberMeDays?: number,
17211726

1727+
/**
1728+
* Rate limits for login attempts.
1729+
* Format: "requests/period", where period is "1s", "1m", "1h", or "1d".
1730+
* Default: ['500/5m', '5000/1h', '10000/1d']
1731+
*/
1732+
rateLimit?: RateLimitString[],
1733+
17221734

17231735
/**
17241736
* Can be used to limit user access when subscribing from frontend to websocket topics.

dev-demo/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import express from 'express';
22
import AdminForth, { AdminUser, Filters, IAdminForth } from '../adminforth/index.js';
33
import * as z from 'zod';
44
import usersResource from "./resources/adminuser.js";
5+
import externalIdentitiesResource from "./resources/external_identities.js";
56
import { fileURLToPath } from 'url';
67
import path from 'path';
78
import { Decimal } from 'decimal.js';
@@ -39,6 +40,7 @@ const ADMIN_BASE_URL = '';
3940
export const admin = new AdminForth({
4041
baseUrl: ADMIN_BASE_URL,
4142
auth: {
43+
rateLimit: ['5/5m'],
4244
usersResourceId: 'adminuser',
4345
usernameField: 'email',
4446
passwordHashField: 'password_hash',
@@ -126,6 +128,7 @@ export const admin = new AdminForth({
126128
],
127129
resources: [
128130
usersResource,
131+
externalIdentitiesResource,
129132
auditLogsResource,
130133
cars_SQLITE_resource,
131134
cars_MyS_resource,

0 commit comments

Comments
 (0)