3c4f3331d8
Jenkins Production Deployment
Reviewed-on: #8 Co-authored-by: Patrick Mueller <patrick@mueller-patrick.tech> Co-committed-by: Patrick Mueller <patrick@mueller-patrick.tech>
69 lines
2.5 KiB
TypeScript
69 lines
2.5 KiB
TypeScript
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));
|