148 lines
4.7 KiB
TypeScript
148 lines
4.7 KiB
TypeScript
import { NextResponse } from "next/server";
|
|
import { z } from "zod";
|
|
import {
|
|
type AccessLogSeverity,
|
|
recordAccessIpEvent,
|
|
} from "@/lib/security/access-log";
|
|
|
|
export const runtime = "nodejs";
|
|
export const maxDuration = 10;
|
|
|
|
const payloadSchema = z.object({
|
|
eventType: z.string().trim().max(32).optional(),
|
|
ip: z.string().trim().max(128).optional().nullable(),
|
|
userId: z.string().trim().uuid().optional().nullable(),
|
|
userEmail: z.string().trim().max(128).optional().nullable(),
|
|
userRole: z.string().trim().max(32).optional().nullable(),
|
|
method: z.string().trim().max(12).optional().nullable(),
|
|
path: z.string().trim().max(256).optional().nullable(),
|
|
routeGroup: z.string().trim().max(64).optional().nullable(),
|
|
statusCode: z.coerce.number().int().min(100).max(599).optional().nullable(),
|
|
outcome: z.string().trim().max(32).optional().nullable(),
|
|
severity: z.enum(["info", "watch", "high", "critical"]).optional(),
|
|
reasons: z.array(z.string().trim().max(80)).max(12).optional(),
|
|
userAgent: z.string().trim().max(512).optional().nullable(),
|
|
referer: z.string().trim().max(512).optional().nullable(),
|
|
metadata: z.record(z.unknown()).optional().nullable(),
|
|
occurredAt: z.union([z.string(), z.number(), z.date()]).optional(),
|
|
});
|
|
|
|
const suspiciousPathPatterns = [
|
|
/\.env/i,
|
|
/\/\.git/i,
|
|
/\/wp-admin/i,
|
|
/\/phpmyadmin/i,
|
|
/\/actuator/i,
|
|
/\/vendor\/phpunit/i,
|
|
/\/eval-stdin\.php/i,
|
|
/\/containers\/json/i,
|
|
/\/index\.php/i,
|
|
];
|
|
|
|
const suspiciousUserAgentPatterns = [
|
|
/libredtail|masscan|zgrab|sqlmap|nikto|acunetix|nessus/i,
|
|
/wget|curl|python-requests|go-http-client/i,
|
|
/nuclei|crawlergo|httpx/i,
|
|
];
|
|
|
|
function pushReason(reasons: string[], reason: string) {
|
|
if (!reasons.includes(reason)) reasons.push(reason);
|
|
}
|
|
|
|
function rankSeverity(value: AccessLogSeverity) {
|
|
return { info: 0, watch: 1, high: 2, critical: 3 }[value];
|
|
}
|
|
|
|
function maxSeverity(
|
|
current: AccessLogSeverity,
|
|
next: AccessLogSeverity
|
|
): AccessLogSeverity {
|
|
return rankSeverity(next) > rankSeverity(current) ? next : current;
|
|
}
|
|
|
|
function classifyPayload(
|
|
payload: z.infer<typeof payloadSchema>
|
|
): { severity: AccessLogSeverity; reasons: string[] } {
|
|
let severity: AccessLogSeverity = payload.severity || "info";
|
|
const reasons = [...(payload.reasons || [])];
|
|
const path = payload.path || "";
|
|
const userAgent = payload.userAgent || "";
|
|
const statusCode = payload.statusCode || 0;
|
|
const outcome = payload.outcome || "allowed";
|
|
|
|
if (outcome === "rate_limited") {
|
|
severity = maxSeverity(severity, "high");
|
|
pushReason(reasons, "触发访问频率限制");
|
|
}
|
|
|
|
if (outcome === "unauthorized" || statusCode === 401 || statusCode === 403) {
|
|
severity = maxSeverity(severity, "watch");
|
|
pushReason(reasons, "出现未授权访问");
|
|
}
|
|
|
|
if (statusCode === 429) {
|
|
severity = maxSeverity(severity, "high");
|
|
pushReason(reasons, "返回 429 限流");
|
|
} else if (statusCode >= 500) {
|
|
severity = maxSeverity(severity, "high");
|
|
pushReason(reasons, "触发服务端错误");
|
|
} else if (statusCode >= 400) {
|
|
severity = maxSeverity(severity, "watch");
|
|
pushReason(reasons, "返回 4xx 异常状态");
|
|
}
|
|
|
|
if (path.startsWith("/api/auth") && statusCode >= 400) {
|
|
severity = maxSeverity(severity, "high");
|
|
pushReason(reasons, "认证接口失败");
|
|
}
|
|
|
|
if (suspiciousPathPatterns.some((pattern) => pattern.test(path))) {
|
|
severity = maxSeverity(severity, "high");
|
|
pushReason(reasons, "命中敏感探测路径");
|
|
}
|
|
|
|
if (suspiciousUserAgentPatterns.some((pattern) => pattern.test(userAgent))) {
|
|
severity = maxSeverity(severity, "watch");
|
|
pushReason(reasons, "User-Agent 异常");
|
|
}
|
|
|
|
if (reasons.includes("静态黑名单命中但未封禁")) {
|
|
severity = maxSeverity(severity, "high");
|
|
}
|
|
|
|
return {
|
|
severity,
|
|
reasons: Array.from(new Set(reasons)).slice(0, 8),
|
|
};
|
|
}
|
|
|
|
function getExpectedSecret() {
|
|
return process.env.ACCESS_LOG_INTERNAL_TOKEN || process.env.AUTH_SECRET || "";
|
|
}
|
|
|
|
export async function POST(request: Request) {
|
|
const expectedSecret = getExpectedSecret();
|
|
const providedSecret = request.headers.get("x-access-log-internal-secret") || "";
|
|
|
|
if (!expectedSecret || providedSecret !== expectedSecret) {
|
|
return new NextResponse("Not found", { status: 404 });
|
|
}
|
|
|
|
const raw = await request.json().catch(() => null);
|
|
const parsed = payloadSchema.safeParse(raw);
|
|
if (!parsed.success) {
|
|
return NextResponse.json({ ok: true });
|
|
}
|
|
|
|
const classification = classifyPayload(parsed.data);
|
|
await recordAccessIpEvent({
|
|
...parsed.data,
|
|
severity: classification.severity,
|
|
reasons: classification.reasons,
|
|
}).catch((error) => {
|
|
console.warn("[AccessIpEvent] failed to record access event", error);
|
|
});
|
|
|
|
return NextResponse.json({ ok: true });
|
|
}
|