proxy.ts
Rama main · réplica de lectura del repositorio original.
import { NextResponse, type NextRequest } from "next/server";
import { getNeonAuth } from "@/lib/neon/server";
import { browserMutationAllowed, normalizeOrigin, requestOrigin } from "@/lib/origin-policy";
import { auditMcpSecurityEvent } from "@/lib/runtime/observability";
import { configuredCorsOrigins } from "@/lib/cors-origins";
type EdgeBucket = {
count: number;
resetAt: number;
};
type RateLimitDecision = {
allowed: boolean;
remaining: number;
resetAt: number;
};
function auditErrorData(event: ReturnType<typeof auditMcpSecurityEvent>, status: number, reason: string) {
return {
reason,
status,
trace_id: event.trace_id,
};
}
const mcpEdgeBuckets = new Map<string, EdgeBucket>();
function shouldRequireAuth() {
if (process.env.ASTRAIL_REQUIRE_AUTH === "false") return false;
if (process.env.ASTRAIL_REQUIRE_AUTH === "true") return true;
return process.env.NODE_ENV === "production";
}
function numericEnv(name: string, fallback: number) {
const value = Number(process.env[name]);
return Number.isFinite(value) && value > 0 ? value : fallback;
}
function rejectOversizedMcpRequest(request: NextRequest, maxBytes: number) {
if (request.method !== "POST") return null;
const contentLength = Number(request.headers.get("content-length") ?? 0);
if (!Number.isFinite(contentLength) || contentLength <= maxBytes) return null;
const reason = "payload_too_large";
const audit = auditMcpSecurityEvent({
route: "mcp_edge",
path: request.nextUrl.pathname,
reason,
status: 413,
content_length: request.headers.get("content-length"),
});
return withSecurityHeaders(NextResponse.json({
jsonrpc: "2.0",
id: null,
error: {
code: -32013,
message: "MCP JSON-RPC payload is too large.",
data: auditErrorData(audit, 413, reason),
},
}, {
status: 413,
headers: {
"Cache-Control": "no-store",
"Content-Type": "application/json",
},
}));
}
function clientIp(request: NextRequest) {
const vercelForwarded = request.headers.get("x-vercel-forwarded-for");
if (vercelForwarded) return vercelForwarded.split(",")[0]?.trim() || "unknown";
const forwarded = request.headers.get("x-forwarded-for");
if (forwarded) return forwarded.split(",")[0]?.trim() || "unknown";
return request.headers.get("cf-connecting-ip")
|| request.headers.get("x-real-ip")
|| "unknown";
}
async function digest(value: string) {
const bytes = new TextEncoder().encode(value);
const hash = await crypto.subtle.digest("SHA-256", bytes);
return Array.from(new Uint8Array(hash))
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
}
function checkEdgeRateLimit(key: string, limit: number, windowMs: number) {
const now = Date.now();
const existing = mcpEdgeBuckets.get(key);
if (!existing || existing.resetAt <= now) {
mcpEdgeBuckets.set(key, { count: 1, resetAt: now + windowMs });
return { allowed: true, remaining: limit - 1, resetAt: now + windowMs };
}
if (existing.count >= limit) {
return { allowed: false, remaining: 0, resetAt: existing.resetAt };
}
existing.count += 1;
return { allowed: true, remaining: limit - existing.count, resetAt: existing.resetAt };
}
function firstNonEmptyEnv(names: string[]) {
for (const name of names) {
const value = process.env[name]?.trim();
if (value) return value;
}
return null;
}
function upstashConfig() {
const url = firstNonEmptyEnv([
"ASTRAIL_RATE_LIMIT_REDIS_REST_URL",
"UPSTASH_REDIS_REST_URL",
]);
const token = firstNonEmptyEnv([
"ASTRAIL_RATE_LIMIT_REDIS_REST_TOKEN",
"UPSTASH_REDIS_REST_TOKEN",
]);
if (!url || !token) return null;
try {
const parsed = new URL(url);
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return null;
} catch {
return null;
}
return {
url: url.replace(/\/+$/, ""),
token,
};
}
async function checkDistributedRateLimit(key: string, limit: number, windowMs: number): Promise<RateLimitDecision | null> {
const config = upstashConfig();
if (!config) return null;
try {
const response = await fetch(`${config.url}/multi-exec`, {
method: "POST",
headers: {
authorization: `Bearer ${config.token}`,
"content-type": "application/json",
},
body: JSON.stringify([
["INCR", key],
["PEXPIRE", key, windowMs],
]),
cache: "no-store",
});
if (!response.ok) return null;
const payload = await response.json();
if (!Array.isArray(payload) || payload[0]?.error) return null;
const count = Number(payload[0]?.result);
if (!Number.isFinite(count)) return null;
return {
allowed: count <= limit,
remaining: Math.max(0, limit - count),
resetAt: Date.now() + windowMs,
};
} catch {
return null;
}
}
async function checkHybridRateLimit(key: string, limit: number, windowMs: number) {
return (await checkDistributedRateLimit(key, limit, windowMs))
?? checkEdgeRateLimit(key, limit, windowMs);
}
function rateLimitedResponse(request: NextRequest, resetAt: number) {
const retryAfter = Math.max(1, Math.ceil((resetAt - Date.now()) / 1000));
const reason = "edge_rate_limited";
const audit = auditMcpSecurityEvent({
route: "mcp_edge",
path: request.nextUrl.pathname,
reason,
status: 429,
retry_after_seconds: retryAfter,
});
const headers = {
"Retry-After": String(retryAfter),
"Cache-Control": "no-store",
"Content-Type": "application/json",
};
if (request.method === "POST") {
return withSecurityHeaders(NextResponse.json({
jsonrpc: "2.0",
id: null,
error: {
code: -32029,
message: "MCP endpoint rate limit exceeded.",
data: auditErrorData(audit, 429, reason),
},
}, { status: 429, headers }));
}
return withSecurityHeaders(NextResponse.json({
error: "MCP endpoint rate limit exceeded.",
retry_after_seconds: retryAfter,
trace_id: audit.trace_id,
}, { status: 429, headers }));
}
function withSecurityHeaders(response: NextResponse) {
response.headers.set("X-Content-Type-Options", "nosniff");
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
response.headers.set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()");
response.headers.set("X-Frame-Options", "DENY");
response.headers.set("Content-Security-Policy", "frame-ancestors 'none'");
if (process.env.NODE_ENV === "production") {
response.headers.set("Strict-Transport-Security", "max-age=63072000; includeSubDomains; preload");
}
return response;
}
function apiBrowserMutationRejection(request: NextRequest) {
if (!request.nextUrl.pathname.startsWith("/api/")) return null;
if (request.nextUrl.pathname.startsWith("/api/mcp/")) return null;
const decision = browserMutationAllowed(request.method, request.headers, request.url);
if (decision.allowed) return null;
return withSecurityHeaders(NextResponse.json({
ok: false,
error: "Request origin is not allowed.",
code: decision.reason,
}, {
status: 403,
headers: {
"Cache-Control": "no-store",
"Content-Type": "application/json",
"Vary": "Origin, Sec-Fetch-Site",
},
}));
}
function mcpOriginRejection(request: NextRequest) {
if (!request.nextUrl.pathname.startsWith("/api/mcp/")) return null;
const origin = request.headers.get("origin");
if (!origin) return null;
const normalizedOrigin = normalizeOrigin(origin);
const sameOrigin = requestOrigin(request.headers, request.url);
if (normalizedOrigin && sameOrigin && normalizedOrigin === sameOrigin) return null;
const configured = configuredCorsOrigins();
const allowed = configured.filter((value) => value !== "*");
const auth = request.headers.get("authorization");
const strictOriginRequired = process.env.NODE_ENV === "production"
|| process.env.ASTRAIL_MCP_STRICT_ORIGIN === "true"
|| Boolean(auth);
if (allowed.includes(origin)) return null;
if (!strictOriginRequired && allowed.length === 0) return null;
const reason = "origin_not_allowed";
const audit = auditMcpSecurityEvent({
route: "mcp_edge",
path: request.nextUrl.pathname,
reason,
status: 403,
});
return withSecurityHeaders(NextResponse.json({
jsonrpc: "2.0",
id: null,
error: {
code: -32003,
message: allowed.length === 0
? "MCP CORS origin allowlist is not configured."
: "MCP CORS origin is not allowed.",
data: auditErrorData(audit, 403, reason),
},
}, {
status: 403,
headers: {
"Cache-Control": "no-store",
"Content-Type": "application/json",
"Vary": "Origin",
},
}));
}
async function checkMcpEdgeAbuseGuard(request: NextRequest) {
if (process.env.ASTRAIL_MCP_EDGE_RATE_LIMIT_DISABLED === "true") return null;
if (!request.nextUrl.pathname.startsWith("/api/mcp/")) return null;
const windowMs = numericEnv("ASTRAIL_MCP_EDGE_RATE_LIMIT_WINDOW_MS", 60_000);
const ipLimit = numericEnv("ASTRAIL_MCP_EDGE_RATE_LIMIT_MAX", 300);
const globalIpLimit = numericEnv("ASTRAIL_MCP_EDGE_GLOBAL_RATE_LIMIT_MAX", Math.max(ipLimit * 3, ipLimit));
const bearerLimit = numericEnv("ASTRAIL_MCP_EDGE_BEARER_RATE_LIMIT_MAX", 600);
const globalBearerLimit = numericEnv("ASTRAIL_MCP_EDGE_GLOBAL_BEARER_RATE_LIMIT_MAX", Math.max(bearerLimit * 3, bearerLimit));
const maxBodyBytes = numericEnv("ASTRAIL_MCP_EDGE_MAX_BODY_BYTES", 256_000);
const oversized = rejectOversizedMcpRequest(request, maxBodyBytes);
if (oversized) return oversized;
const routeKey = `${request.method}:${request.nextUrl.pathname.replace(/\/+$/, "")}`;
const routeHash = await digest(routeKey);
const ipHash = await digest(clientIp(request));
const globalIpBucket = await checkHybridRateLimit(`mcp:ip:${ipHash}:all`, globalIpLimit, windowMs);
if (!globalIpBucket.allowed) return rateLimitedResponse(request, globalIpBucket.resetAt);
const ipBucket = await checkHybridRateLimit(`mcp:ip:${ipHash}:${routeHash}`, ipLimit, windowMs);
if (!ipBucket.allowed) return rateLimitedResponse(request, ipBucket.resetAt);
const auth = request.headers.get("authorization");
if (auth?.startsWith("Bearer ")) {
const bearerHash = await digest(auth.slice("Bearer ".length));
const globalBearerBucket = await checkHybridRateLimit(`mcp:bearer:${bearerHash}:all`, globalBearerLimit, windowMs);
if (!globalBearerBucket.allowed) return rateLimitedResponse(request, globalBearerBucket.resetAt);
const bearerBucket = await checkHybridRateLimit(`mcp:bearer:${bearerHash}:${routeHash}`, bearerLimit, windowMs);
if (!bearerBucket.allowed) return rateLimitedResponse(request, bearerBucket.resetAt);
}
return null;
}
export async function proxy(request: NextRequest) {
const originRejection = mcpOriginRejection(request);
if (originRejection) return originRejection;
const mcpGuard = await checkMcpEdgeAbuseGuard(request);
if (mcpGuard) return mcpGuard;
const apiOriginRejection = apiBrowserMutationRejection(request);
if (apiOriginRejection) return apiOriginRejection;
let response = NextResponse.next({ request });
const host = request.headers.get("host") ?? "";
if (host.split(":")[0] === "status.astrail.dev" && request.nextUrl.pathname === "/") {
const rewriteUrl = request.nextUrl.clone();
rewriteUrl.pathname = "/status";
return withSecurityHeaders(NextResponse.rewrite(rewriteUrl));
}
if (request.nextUrl.pathname.startsWith("/api/")) {
return withSecurityHeaders(response);
}
const dashboardRequest = request.nextUrl.pathname.startsWith("/dashboard");
const authCompletionRequest = request.nextUrl.pathname === "/auth/complete"
&& request.nextUrl.searchParams.has("neon_auth_session_verifier");
if (!dashboardRequest && !authCompletionRequest) {
return withSecurityHeaders(response);
}
const authUrl = process.env.NEON_AUTH_BASE_URL ?? process.env.NEXT_PUBLIC_NEON_AUTH_URL;
const cookieSecret = process.env.NEON_AUTH_COOKIE_SECRET;
if (!authUrl || !cookieSecret) {
if (dashboardRequest && shouldRequireAuth()) {
const redirectUrl = request.nextUrl.clone();
redirectUrl.pathname = "/login";
redirectUrl.searchParams.set("redirect", `${request.nextUrl.pathname}${request.nextUrl.search}`);
redirectUrl.searchParams.set("error", "Production sign-in is not configured yet. Finish workspace auth setup before opening the dashboard.");
return withSecurityHeaders(NextResponse.redirect(redirectUrl));
}
return withSecurityHeaders(response);
}
if (!shouldRequireAuth() && !authCompletionRequest) {
return withSecurityHeaders(response);
}
try {
const authResponse = await getNeonAuth().middleware({ loginUrl: "/login" })(request);
return withSecurityHeaders(authResponse);
} catch {
const redirectUrl = request.nextUrl.clone();
redirectUrl.pathname = "/login";
redirectUrl.searchParams.set("redirect", `${request.nextUrl.pathname}${request.nextUrl.search}`);
return withSecurityHeaders(NextResponse.redirect(redirectUrl));
}
}
export const config = {
matcher: ["/", "/auth/complete", "/dashboard/:path*", "/api/:path*"],
};