Initial hop. implementation and UGREEN NAS deployment guide
This commit is contained in:
84
apps/server/src/security.ts
Normal file
84
apps/server/src/security.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user