|
|
import {
|
|
|
IP_BAN_TTL_SECONDS,
|
|
|
IP_RATE_LIMIT,
|
|
|
IP_RATE_WINDOW_SECONDS,
|
|
|
} from "@/lib/constants/cache";
|
|
|
import { withBoundedRedis } from "@/lib/redis";
|
|
|
import { writeAlert } from "@/modules/alert/service";
|
|
|
|
|
|
export type IpRateLimitResult =
|
|
|
| { allowed: true }
|
|
|
| { allowed: false; errorCode: "RATE_LIMITED"; banned: boolean };
|
|
|
|
|
|
function ipRateKey(ip: string): string {
|
|
|
return `ratelimit:ip:${ip}`;
|
|
|
}
|
|
|
|
|
|
function ipBanKey(ip: string): string {
|
|
|
return `ban:ip:${ip}`;
|
|
|
}
|
|
|
|
|
|
/** IP 级限流:1000 次/分钟,超限封禁 10 分钟 */
|
|
|
export async function checkIpRateLimit(
|
|
|
ip: string,
|
|
|
): Promise<IpRateLimitResult> {
|
|
|
const result = await withBoundedRedis(async (redis) => {
|
|
|
const banned = await redis.exists(ipBanKey(ip));
|
|
|
if (banned === 1) {
|
|
|
return { allowed: false as const, errorCode: "RATE_LIMITED" as const, banned: true };
|
|
|
}
|
|
|
|
|
|
const key = ipRateKey(ip);
|
|
|
const count = await redis.incr(key);
|
|
|
|
|
|
if (count === 1) {
|
|
|
await redis.expire(key, IP_RATE_WINDOW_SECONDS);
|
|
|
}
|
|
|
|
|
|
if (count > IP_RATE_LIMIT) {
|
|
|
await redis.set(ipBanKey(ip), "1", "EX", IP_BAN_TTL_SECONDS);
|
|
|
void writeAlert("SECURITY", {
|
|
|
detail: {
|
|
|
reason: "ip_rate_limit_ban",
|
|
|
ip,
|
|
|
count,
|
|
|
window_seconds: IP_RATE_WINDOW_SECONDS,
|
|
|
},
|
|
|
});
|
|
|
return { allowed: false as const, errorCode: "RATE_LIMITED" as const, banned: true };
|
|
|
}
|
|
|
|
|
|
return { allowed: true as const };
|
|
|
});
|
|
|
|
|
|
if (!result) {
|
|
|
return { allowed: true };
|
|
|
}
|
|
|
return result;
|
|
|
}
|