Files
hop.noname.company/apps/server/src/security.ts

85 lines
2.6 KiB
TypeScript

import { createHash, createHmac, randomBytes } from 'node:crypto';
import ipaddr from 'ipaddr.js';
import type { IncomingHttpHeaders } from 'node:http';
export class ApiError extends Error {
constructor(
public status: number,
public code: string,
message: string,
) {
super(message);
}
}
export const fail = (status: number, code: string, message: string): never => {
throw new ApiError(status, code, message);
};
export const randomToken = () => randomBytes(32).toString('base64url');
export const hash = (value: string) => createHash('sha256').update(value).digest('hex');
export const hmac = (secret: string, value: string) =>
createHmac('sha256', secret).update(value).digest('hex');
export function normalizeIp(value: string): string | null {
try {
return ipaddr.process(value).toString();
} catch {
return null;
}
}
export function clientIp(
remote: string | undefined,
headers: IncomingHttpHeaders,
trusted: string[],
): string | null {
const first = remote ? normalizeIp(remote) : null;
if (!first) return null;
const trusts = (v: string) =>
trusted.some((rule) => {
try {
const a = ipaddr.process(v);
const cidr = rule.includes('/')
? ipaddr.parseCIDR(rule)
: ([ipaddr.process(rule), a.kind() === 'ipv4' ? 32 : 128] as [
ipaddr.IPv4 | ipaddr.IPv6,
number,
]);
return a.kind() === cidr[0].kind() && a.match(cidr);
} catch {
return false;
}
});
if (!trusts(first)) return first;
const raw = headers['x-forwarded-for'];
if (typeof raw !== 'string') return null;
const chain = raw.split(',').map((v) => normalizeIp(v.trim()));
if (chain.some((v) => !v)) return null;
let current = first;
for (const next of chain.reverse()) {
if (!trusts(current)) break;
current = next!;
}
return current;
}
export function publicIp(ip: string | null): boolean {
if (!ip) return false;
try {
return ipaddr.process(ip).range() === 'unicast';
} catch {
return false;
}
}
export class Limiter {
private windows = new Map<string, { count: number; until: number }>();
hit(key: string, max: number, windowMs = 60000) {
const now = Date.now();
let w = this.windows.get(key);
if (!w || w.until <= now) {
w = { count: 0, until: now + windowMs };
this.windows.set(key, w);
}
if (++w.count > max) fail(429, 'RATE_LIMIT', '요청이 많습니다. 잠시 후 다시 시도해 주세요.');
}
prune() {
const now = Date.now();
for (const [key, w] of this.windows) if (w.until <= now) this.windows.delete(key);
}
}