Reviewed-on: #8 Co-authored-by: Patrick Mueller <patrick@mueller-patrick.tech> Co-committed-by: Patrick Mueller <patrick@mueller-patrick.tech>
This commit was merged in pull request #8.
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
import * as crypto from 'crypto';
|
||||
import * as dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const RATE_LIMIT_WINDOW_MIN = parseInt(process.env.TICKETS_RATE_LIMIT_WINDOW_MIN || '10', 10);
|
||||
const RATE_LIMIT_WINDOW_MS = RATE_LIMIT_WINDOW_MIN * 60 * 1000;
|
||||
|
||||
// Salted per-process (not persisted/configured) - these limiters are
|
||||
// in-memory-only with no DB backstop, so the salt only needs to survive
|
||||
// for the current process's lifetime, unlike Feedback's FEEDBACK_IP_SALT
|
||||
// which also salts a persisted ip_hash column.
|
||||
const IP_SALT = crypto.randomBytes(32).toString('hex');
|
||||
|
||||
export const hashIp = (ip: string): string => {
|
||||
return crypto.createHash('sha256').update(IP_SALT + ip).digest('hex');
|
||||
};
|
||||
|
||||
/**
|
||||
* Two independent budgets, not one shared counter: validating a code (GET)
|
||||
* is a cheap, repeatable lookup a guest's own browser triggers on every
|
||||
* page load/reload/back-navigation of their redemption link - a shared
|
||||
* budget with redeem meant a guest could exhaust it just by reloading the
|
||||
* page a few times before ever submitting. Redeeming (POST) is the
|
||||
* sensitive, code-consuming action and stays tightly limited; validating
|
||||
* is limited too (it's still the enumeration vector for guessing codes),
|
||||
* just with a much larger allowance headroomed for normal page-reload
|
||||
* behaviour.
|
||||
*/
|
||||
const createLimiter = (max: number) => {
|
||||
const recentRequests = new Map<string, number[]>();
|
||||
|
||||
const pruneOld = (timestamps: number[], now: number): number[] => {
|
||||
return timestamps.filter(t => now - t < RATE_LIMIT_WINDOW_MS);
|
||||
};
|
||||
|
||||
const sweepInterval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [ipHash, timestamps] of recentRequests) {
|
||||
if (pruneOld(timestamps, now).length === 0) {
|
||||
recentRequests.delete(ipHash);
|
||||
}
|
||||
}
|
||||
}, RATE_LIMIT_WINDOW_MS);
|
||||
sweepInterval.unref();
|
||||
|
||||
return {
|
||||
isRateLimited: (ipHash: string): boolean => {
|
||||
const now = Date.now();
|
||||
const timestamps = pruneOld(recentRequests.get(ipHash) || [], now);
|
||||
if (timestamps.length > 0) {
|
||||
recentRequests.set(ipHash, timestamps);
|
||||
} else {
|
||||
recentRequests.delete(ipHash);
|
||||
}
|
||||
return timestamps.length >= max;
|
||||
},
|
||||
recordRequest: (ipHash: string): void => {
|
||||
const now = Date.now();
|
||||
const timestamps = pruneOld(recentRequests.get(ipHash) || [], now);
|
||||
timestamps.push(now);
|
||||
recentRequests.set(ipHash, timestamps);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const validateLimiter = createLimiter(parseInt(process.env.TICKETS_VALIDATE_RATE_LIMIT_MAX || '30', 10));
|
||||
export const redeemLimiter = createLimiter(parseInt(process.env.TICKETS_REDEEM_RATE_LIMIT_MAX || '10', 10));
|
||||
Reference in New Issue
Block a user