[RateLimiter] Remove ratelimiter to stop service from being unavailable

This commit is contained in:
Aaro Koivukangas
2026-10-11 12:11:01 +03:00
parent 584f5213e6
commit 42f274b394
8 changed files with 6 additions and 173 deletions
+1 -34
View File
@@ -1,12 +1,11 @@
import express from 'express';
import fs from 'node:fs';
import { isData, type Data } from '../shared/types';
import { createSessionToken, hashPassword, RateLimiter, verifyPassword, verifySessionToken } from './auth';
import { createSessionToken, hashPassword, verifyPassword, verifySessionToken } from './auth';
import * as config from './config';
import { UserStore, type User } from './store';
const COOKIE = 'workh_session';
const HOUR_MS = 3_600_000;
export const USERNAME_RULE = /^[a-z0-9][a-z0-9._-]{2,31}$/;
export const MIN_PASSWORD = 12;
@@ -31,9 +30,6 @@ export interface AppOptions {
sessionTtlMs: number;
registrationCode: string;
maxUsers: number;
registrationsPerIpPerHour: number;
loginMaxFailures: number;
loginWindowMs: number;
}
export function createApp(overrides: Partial<AppOptions> = {}) {
@@ -45,18 +41,12 @@ export function createApp(overrides: Partial<AppOptions> = {}) {
sessionTtlMs: config.SESSION_TTL_MS,
registrationCode: config.REGISTRATION_CODE,
maxUsers: config.MAX_USERS,
registrationsPerIpPerHour: config.REGISTRATIONS_PER_IP_PER_HOUR,
loginMaxFailures: config.LOGIN_MAX_FAILURES,
loginWindowMs: config.LOGIN_WINDOW_MS,
...overrides,
};
const store = new UserStore(o.dataDir);
const app = express();
app.disable('x-powered-by');
app.set('trust proxy', config.TRUST_PROXY);
const loginLimiter = new RateLimiter(o.loginMaxFailures, o.loginWindowMs);
const signupLimiter = new RateLimiter(o.registrationsPerIpPerHour, HOUR_MS);
// Logins for unknown usernames still run scrypt against this, so they take as long as real ones.
const dummyHash = hashPassword('dummy password for timing');
@@ -116,16 +106,6 @@ export function createApp(overrides: Partial<AppOptions> = {}) {
return { username: username.trim().toLowerCase(), password };
};
const tooMany = (res: express.Response, limiter: RateLimiter, key: string, what: string) => {
const wait = limiter.retryAfter(key);
if (wait === 0) return false;
res
.set('Retry-After', String(wait))
.status(429)
.json({ error: `Too many ${what}. Try again later.` });
return true;
};
// ---------- Registration ----------
app.get('/api/registration', (_req, res) => {
@@ -133,9 +113,6 @@ export function createApp(overrides: Partial<AppOptions> = {}) {
});
app.post('/api/register', async (req, res) => {
const ip = req.ip ?? 'unknown';
if (tooMany(res, signupLimiter, ip, 'new accounts from your address')) return;
const creds = credentials(req.body);
if (!creds) {
res.status(400).json({ error: 'username and password are required' });
@@ -153,7 +130,6 @@ export function createApp(overrides: Partial<AppOptions> = {}) {
}
const { inviteCode } = (req.body ?? {}) as { inviteCode?: unknown };
if (o.registrationCode !== '' && inviteCode !== o.registrationCode) {
signupLimiter.hit(ip); // guessing invite codes counts against the sign-up limit
res.status(403).json({ error: 'Invalid invite code' });
return;
}
@@ -182,7 +158,6 @@ export function createApp(overrides: Partial<AppOptions> = {}) {
fs.renameSync(o.legacyDataFile, `${o.legacyDataFile}.migrated`);
console.log(`WorkH: moved ${o.legacyDataFile} into the new account "${user.username}"`);
}
signupLimiter.hit(ip);
startSession(res, user);
res.status(201).json({ username: user.username });
});
@@ -190,9 +165,6 @@ export function createApp(overrides: Partial<AppOptions> = {}) {
// ---------- Sessions ----------
app.post('/api/login', async (req, res) => {
const ip = req.ip ?? 'unknown';
if (tooMany(res, loginLimiter, ip, 'failed logins')) return;
const creds = credentials(req.body);
if (!creds) {
res.status(400).json({ error: 'username and password are required' });
@@ -201,11 +173,9 @@ export function createApp(overrides: Partial<AppOptions> = {}) {
const user = store.findByName(creds.username);
const ok = await verifyPassword(creds.password, user?.passwordHash ?? (await dummyHash));
if (!user || !ok) {
loginLimiter.hit(ip);
res.status(401).json({ error: 'Wrong username or password' });
return;
}
loginLimiter.reset(ip);
startSession(res, user);
res.json({ username: user.username });
});
@@ -232,11 +202,8 @@ export function createApp(overrides: Partial<AppOptions> = {}) {
app.delete('/api/account', requireAuth, async (req, res) => {
const { user } = req as AuthedRequest;
const ip = req.ip ?? 'unknown';
if (tooMany(res, loginLimiter, ip, 'failed attempts')) return;
const { password } = (req.body ?? {}) as { password?: unknown };
if (typeof password !== 'string' || !(await verifyPassword(password, user.passwordHash))) {
loginLimiter.hit(ip);
res.status(403).json({ error: 'Wrong password' });
return;
}
-43
View File
@@ -71,46 +71,3 @@ export function verifySessionToken(token: string, secret: string, now = Date.now
return null;
}
}
// ---------- Rate limiting ----------
/**
* Counts events (failed logins, sign-ups) per key (client IP) in a sliding window. After
* `maxHits` events the key is blocked until its oldest event leaves the window.
*/
export class RateLimiter {
private hits = new Map<string, number[]>();
constructor(
private maxHits: number,
private windowMs: number,
private now: () => number = Date.now,
) {}
private recent(key: string): number[] {
const cutoff = this.now() - this.windowMs;
const list = (this.hits.get(key) ?? []).filter((t) => t > cutoff);
if (list.length) this.hits.set(key, list);
else this.hits.delete(key);
return list;
}
/** Seconds until the key may try again; 0 if it is not blocked. */
retryAfter(key: string): number {
const list = this.recent(key);
if (list.length < this.maxHits) return 0;
return Math.max(1, Math.ceil((list[0] + this.windowMs - this.now()) / 1000));
}
hit(key: string): void {
this.hits.set(key, [...this.recent(key), this.now()]);
// Keep memory bounded if someone sprays many source IPs.
if (this.hits.size > 10_000) {
for (const k of this.hits.keys()) this.recent(k);
}
}
reset(key: string): void {
this.hits.delete(key);
}
}
-5
View File
@@ -21,8 +21,3 @@ export const DIST_DIR = path.join(ROOT, 'dist');
export const LEGACY_DATA_FILE = path.join(ROOT, 'data.json');
export const SESSION_TTL_MS = 30 * 86_400_000;
export const MAX_USERS = 100;
export const REGISTRATIONS_PER_IP_PER_HOUR = 5;
export const LOGIN_MAX_FAILURES = 10;
export const LOGIN_WINDOW_MS = 15 * 60_000;
/** Trust X-Forwarded-For only from private-network peers, i.e. the ingress proxy. */
export const TRUST_PROXY = 'loopback, linklocal, uniquelocal';