206 lines
7.8 KiB
TypeScript
206 lines
7.8 KiB
TypeScript
import { NextResponse } from "next/server";
|
|
import type { NextRequest } from "next/server";
|
|
import { jwtVerify } from "jose";
|
|
import { SESSION_COOKIE } from "./app/api/auth/session";
|
|
|
|
/**
|
|
* Rate limiting for the public API surface.
|
|
*
|
|
* The architecture assigns rate limiting to `api-gateway`, which is not built
|
|
* yet, so until it exists this is where limits for the remaining bounded API
|
|
* routes live. `/api/chat` is intentionally unlimited.
|
|
*
|
|
* It runs here rather than inside the route handlers because the edge
|
|
* middleware rejects an abusive request before any handler work — and because
|
|
* it covers every current and future `/api/*` route by default rather than
|
|
* one endpoint at a time.
|
|
*
|
|
* Known limitations, stated rather than hidden:
|
|
* - Counters are per process and in memory. Production runs a single `web`
|
|
* container, so this is a real limit today; the moment that scales to more
|
|
* than one replica each replica gets its own allowance, and this needs to
|
|
* move to Redis (already reserved for exactly this in `docs/architecture.md`)
|
|
* or to the gateway.
|
|
* - It is keyed by client IP, so it throttles a shared NAT as one caller. That
|
|
* is the correct trade for a cost guard with no authentication; per-user
|
|
* limits need auth, which does not exist yet.
|
|
* - It is a cost and abuse guard, not a security control. It does not
|
|
* authenticate anyone and must not be described as if it does.
|
|
*/
|
|
|
|
interface Bucket {
|
|
hits: number[];
|
|
}
|
|
|
|
interface Rule {
|
|
windowMs: number;
|
|
max: number;
|
|
}
|
|
|
|
// `/api/chat` is intentionally absent, so chat requests pass through without
|
|
// rate limiting. Autocomplete is a local catalog lookup with no model call.
|
|
const RULES: Array<{ prefix: string; rules: Rule[] }> = [
|
|
{
|
|
prefix: "/api/suggest",
|
|
rules: [{ windowMs: 60_000, max: 120 }],
|
|
},
|
|
// `/api/pdf` streams the whole 37MB source PDF from disk on every request,
|
|
// with no range support and no caching headers. It is unauthenticated, so
|
|
// repeated fetches are a bandwidth and memory cost on a single small EC2
|
|
// host. Sized against real UI behaviour rather than guessed: `tra-cuu`
|
|
// uses it as an iframe `src` whose `#page=` fragment changes per citation
|
|
// click, and `CitationCard` links to it, so a clinician working through a
|
|
// long evidence list can legitimately fetch it repeatedly. 30/min is far
|
|
// above that and still bounds an automated puller.
|
|
{
|
|
prefix: "/api/pdf",
|
|
rules: [{ windowMs: 60_000, max: 30 }],
|
|
},
|
|
// `/api/feedback` writes one row per answer (upsert keyed on trace_id), so
|
|
// the ceiling only needs to exceed how fast a human can rate answers. It
|
|
// reaches PostgreSQL on every call, which is why it is bounded at all.
|
|
{
|
|
prefix: "/api/feedback",
|
|
rules: [{ windowMs: 60_000, max: 60 }],
|
|
},
|
|
];
|
|
|
|
const buckets = new Map<string, Bucket>();
|
|
const LONGEST_WINDOW_MS = 3_600_000;
|
|
let lastSweep = 0;
|
|
|
|
/** Drop entries no rule can still be counting, so the map cannot grow without bound. */
|
|
function sweep(now: number) {
|
|
if (now - lastSweep < 60_000) return;
|
|
lastSweep = now;
|
|
for (const [key, bucket] of buckets) {
|
|
const live = bucket.hits.filter((t) => now - t < LONGEST_WINDOW_MS);
|
|
if (live.length === 0) buckets.delete(key);
|
|
else bucket.hits = live;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Caddy sits in front and sets `X-Forwarded-For`; the left-most entry is the
|
|
* original client. Falling back to a shared key rather than to "unlimited"
|
|
* matters: an unknown IP must not become a way to opt out of the limit.
|
|
*/
|
|
function clientKey(request: NextRequest): string {
|
|
const forwarded = request.headers.get("x-forwarded-for");
|
|
if (forwarded) {
|
|
const first = forwarded.split(",")[0]?.trim();
|
|
if (first) return first;
|
|
}
|
|
return request.headers.get("x-real-ip")?.trim() || "unknown";
|
|
}
|
|
|
|
function matchRules(pathname: string) {
|
|
return RULES.find((entry) => pathname.startsWith(entry.prefix))?.rules;
|
|
}
|
|
|
|
/** `/admin/**` (except the login page itself) requires a valid session
|
|
* cookie carrying `role: "admin"`. Verified with the same `JWT_SECRET`
|
|
* auth-service signs with — Edge middleware can't call out to auth-service
|
|
* per request without adding real latency to every admin page load, and
|
|
* `jose` (unlike `jsonwebtoken`) works in the Edge runtime this file runs
|
|
* under, so local verification is both correct and the only option here. */
|
|
async function guardAdmin(request: NextRequest): Promise<NextResponse | null> {
|
|
const { pathname } = request.nextUrl;
|
|
if (!pathname.startsWith("/admin") || pathname === "/admin/login") return null;
|
|
|
|
const token = request.cookies.get(SESSION_COOKIE)?.value;
|
|
const secret = process.env.JWT_SECRET;
|
|
if (!token || !secret) {
|
|
return NextResponse.redirect(new URL("/admin/login", request.url));
|
|
}
|
|
try {
|
|
const { payload } = await jwtVerify(token, new TextEncoder().encode(secret));
|
|
if (payload.role !== "admin") {
|
|
return NextResponse.redirect(new URL("/admin/login", request.url));
|
|
}
|
|
return null;
|
|
} catch {
|
|
return NextResponse.redirect(new URL("/admin/login", request.url));
|
|
}
|
|
}
|
|
|
|
/** `/profile` requires any valid session (no specific role) — it renders
|
|
* unconditionally otherwise (`profile/page.tsx` has no server component
|
|
* data fetch to fail on), so an anonymous visitor would see the full form
|
|
* shell with every save silently 401ing instead of being sent to log in. */
|
|
async function guardProfile(request: NextRequest): Promise<NextResponse | null> {
|
|
const { pathname } = request.nextUrl;
|
|
if (pathname !== "/profile") return null;
|
|
|
|
const token = request.cookies.get(SESSION_COOKIE)?.value;
|
|
const secret = process.env.JWT_SECRET;
|
|
if (!token || !secret) {
|
|
return NextResponse.redirect(new URL("/login", request.url));
|
|
}
|
|
try {
|
|
await jwtVerify(token, new TextEncoder().encode(secret));
|
|
return null;
|
|
} catch {
|
|
return NextResponse.redirect(new URL("/login", request.url));
|
|
}
|
|
}
|
|
|
|
export async function middleware(request: NextRequest) {
|
|
const adminRedirect = await guardAdmin(request);
|
|
if (adminRedirect) return adminRedirect;
|
|
|
|
const profileRedirect = await guardProfile(request);
|
|
if (profileRedirect) return profileRedirect;
|
|
|
|
const rules = matchRules(request.nextUrl.pathname);
|
|
if (!rules) return NextResponse.next();
|
|
|
|
const now = Date.now();
|
|
sweep(now);
|
|
|
|
const key = `${clientKey(request)}:${request.nextUrl.pathname}`;
|
|
const bucket = buckets.get(key) ?? { hits: [] };
|
|
bucket.hits = bucket.hits.filter((t) => now - t < LONGEST_WINDOW_MS);
|
|
|
|
for (const rule of rules) {
|
|
const inWindow = bucket.hits.filter((t) => now - t < rule.windowMs);
|
|
if (inWindow.length >= rule.max) {
|
|
const oldest = Math.min(...inWindow);
|
|
const retryAfterSec = Math.max(1, Math.ceil((rule.windowMs - (now - oldest)) / 1000));
|
|
// Record nothing for a rejected request: a client hammering the endpoint
|
|
// should not keep pushing its own window forward and lock itself out for
|
|
// longer than the rule says.
|
|
buckets.set(key, bucket);
|
|
return NextResponse.json(
|
|
{
|
|
error: "rate_limited",
|
|
message:
|
|
"Bạn đang gửi quá nhiều yêu cầu trong thời gian ngắn. Vui lòng đợi một lát rồi thử lại.",
|
|
},
|
|
{
|
|
status: 429,
|
|
headers: {
|
|
"Retry-After": String(retryAfterSec),
|
|
"X-RateLimit-Limit": String(rule.max),
|
|
"X-RateLimit-Remaining": "0",
|
|
},
|
|
}
|
|
);
|
|
}
|
|
}
|
|
|
|
bucket.hits.push(now);
|
|
buckets.set(key, bucket);
|
|
|
|
const tightest = rules[0];
|
|
const used = bucket.hits.filter((t) => now - t < tightest.windowMs).length;
|
|
const response = NextResponse.next();
|
|
response.headers.set("X-RateLimit-Limit", String(tightest.max));
|
|
response.headers.set("X-RateLimit-Remaining", String(Math.max(0, tightest.max - used)));
|
|
return response;
|
|
}
|
|
|
|
export const config = {
|
|
matcher: ["/api/:path*", "/admin/:path*", "/profile"],
|
|
};
|