proxy.ts
Rama main · réplica de lectura del repositorio original.
// proxy.ts — Next.js Proxy:JWT 驗證 + in-memory 速率限制
import { NextResponse, NextRequest } from 'next/server';
import { getClientIpFromHeaders } from './lib/requestIp';
import { checkRateLimit as checkRateLimitSafe } from './lib/rateLimit';
// ── 公開端點(不需驗證)──
const PUBLIC_PATHS = new Set([
'/login',
'/login/recovery',
'/setup',
'/mcp',
'/privacy',
'/terms',
'/api/auth/login',
'/api/auth/logout',
'/api/auth/register',
'/api/auth/google',
'/api/auth/google/state',
'/api/auth/line',
'/api/auth/line/authorize',
'/api/auth/line/state',
'/api/app/auth-ticket/exchange',
'/api/line/webhook',
'/api/config',
'/api/health',
'/api/i18n/locale',
'/api/mcp',
'/offline',
]);
// passkey / MCP OAuth 端點前綴
const PUBLIC_PREFIXES = ['/api/auth/passkey/', '/api/auth/recovery/', '/api/setup/', '/api/oauth/'];
// API Token(第三方自動化整合,issue #258)端點前綴:
// 這組路由不使用 authToken Cookie,改以 `Authorization: Bearer ap_api_…` 驗證,
// 因此必須先放行 proxy 的 cookie 檢查,再由路由本身完成權杖與權限範圍驗證。
const API_TOKEN_PREFIXES = ['/api/v1/'];
// Next.js 內部路由 + 靜態資源
const SKIP_PREFIXES = ['/_next/', '/favicon.', '/logo.'];
// PWA 靜態資源:Service Worker 與 manifest 必須能在未登入時取得,
// 否則瀏覽器無法完成 SW 註冊與 PWA 安裝(見 007-pwa-offline-entry)。圖示同理,
// 安裝提示與離線頁都需要它們,且這些檔案不含任何使用者資料。
const PWA_STATIC_PATHS = new Set(['/sw.js', '/manifest.webmanifest']);
const PWA_STATIC_PREFIXES = ['/icons/'];
// ── in-memory 速率限制(Edge runtime 可用)──
const RATE_LIMIT_WINDOW_MS = 15 * 60 * 1000; // 15 分鐘
const RATE_LIMIT_MAX = 600; // 全域 API:每 IP 每窗口最多 600 次
const AUTH_RATE_LIMIT_MAX = 20; // 認證端點:每 IP 每窗口最多 20 次
const OAUTH_RATE_LIMIT_MAX = 100; // OAuth authorize/token/revoke:每 IP 每窗口最多 100 次
const OAUTH_AUTHORIZE_PAGE_RATE_LIMIT_MAX = 30; // 授權頁可能需要擷取 CIMD,限制匿名 outbound metadata 查詢
const OAUTH_REGISTRATION_WINDOW_MS = 60 * 60 * 1000;
const OAUTH_REGISTRATION_RATE_LIMIT_MAX = 20; // DCR:每 IP 每小時最多 20 次
const AUTH_RATE_LIMITED_PATHS = new Set([
'/api/auth/recovery/verify',
'/api/auth/recovery/complete',
'/api/setup/passkey/challenge',
'/api/setup/passkey/register',
'/api/auth/login',
'/api/auth/register',
'/api/auth/google',
'/api/auth/line',
'/api/auth/line/authorize',
'/api/auth/passkey/challenge',
'/api/auth/passkey/login',
]);
const OAUTH_RATE_LIMITED_PATHS = new Set([
'/api/oauth/authorize',
'/api/oauth/token',
'/api/oauth/revoke',
]);
const CSRF_SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
interface RateLimitEntry {
count: number;
resetAt: number;
}
/** Map<ip, { count: number, resetAt: number }> */
const globalRateLimitMap = new Map<string, RateLimitEntry>();
const authRateLimitMap = new Map<string, RateLimitEntry>();
const oauthRateLimitMap = new Map<string, RateLimitEntry>();
const oauthAuthorizePageRateLimitMap = new Map<string, RateLimitEntry>();
const oauthRegistrationRateLimitMap = new Map<string, RateLimitEntry>();
// 委派給 lib/rateLimit.ts(含 Map 容量上限保護)與 lib/requestIp.ts
// (信任代理策略見該檔頭註解;預設取 XFF 最後一段,需搭配會覆寫 XFF 的反向
// 代理才能防止客戶端偽造來源 IP 繞過下方速率限制 — 見安全報告 AUTH-VULN-06)。
function checkRateLimit(
map: Map<string, RateLimitEntry>,
ip: string,
max: number,
windowMs = RATE_LIMIT_WINDOW_MS
): boolean {
return checkRateLimitSafe(map, ip, max, windowMs);
}
function getClientIp(request: NextRequest): string {
return getClientIpFromHeaders(request.headers);
}
function getRequestOriginCandidates(request: NextRequest): Set<string> {
const candidates = new Set<string>([request.nextUrl.origin]);
const host = request.headers.get('x-forwarded-host') || request.headers.get('host') || '';
if (host) {
const proto = request.headers.get('x-forwarded-proto') || (process.env.NODE_ENV === 'production' ? 'https' : 'http');
candidates.add(`${proto}://${host}`);
}
return candidates;
}
function normalizeOrigin(originValue: string): string {
const u = new URL(originValue);
return `${u.protocol}//${u.host}`;
}
function isOriginAllowed(originValue: string, request: NextRequest): boolean {
if (!originValue) return false;
const allowedOrigins = (process.env.ALLOWED_ORIGINS || '')
.split(',')
.map(s => s.trim())
.filter(Boolean);
try {
const normalized = normalizeOrigin(originValue);
if (allowedOrigins.length === 0) {
return getRequestOriginCandidates(request).has(normalized);
}
return allowedOrigins.includes(normalized) || allowedOrigins.includes(originValue);
} catch {
return false;
}
}
export function proxy(request: NextRequest): NextResponse {
const { pathname } = request.nextUrl;
const isPublicPath = PUBLIC_PATHS.has(pathname) || PUBLIC_PREFIXES.some(p => pathname.startsWith(p))
|| API_TOKEN_PREFIXES.some(p => pathname.startsWith(p));
// 略過 Next.js 內部路由與靜態資源
if (SKIP_PREFIXES.some(p => pathname.startsWith(p))) {
return NextResponse.next();
}
// PWA 靜態資源:不套用速率限制、不做登入導向(見上方常數說明)。
if (PWA_STATIC_PATHS.has(pathname) || PWA_STATIC_PREFIXES.some(p => pathname.startsWith(p))) {
return NextResponse.next();
}
const ip = getClientIp(request);
// 認證端點速率限制(較嚴格)
if (AUTH_RATE_LIMITED_PATHS.has(pathname)) {
if (!checkRateLimit(authRateLimitMap, ip, AUTH_RATE_LIMIT_MAX)) {
return NextResponse.json(
{ error: '登入嘗試次數過多,請 15 分鐘後再試' },
{ status: 429 }
);
}
}
if (OAUTH_RATE_LIMITED_PATHS.has(pathname)) {
if (!checkRateLimit(oauthRateLimitMap, ip, OAUTH_RATE_LIMIT_MAX)) {
return NextResponse.json(
{ error: 'too_many_requests', error_description: 'OAuth 請求過於頻繁,請稍後再試' },
{ status: 429 }
);
}
}
if (pathname === '/oauth/authorize') {
if (!checkRateLimit(oauthAuthorizePageRateLimitMap, ip, OAUTH_AUTHORIZE_PAGE_RATE_LIMIT_MAX)) {
return NextResponse.json(
{ error: 'too_many_requests', error_description: 'OAuth 授權請求過於頻繁,請稍後再試' },
{ status: 429 }
);
}
}
if (pathname === '/api/oauth/register') {
if (!checkRateLimit(
oauthRegistrationRateLimitMap,
ip,
OAUTH_REGISTRATION_RATE_LIMIT_MAX,
OAUTH_REGISTRATION_WINDOW_MS
)) {
return NextResponse.json(
{ error: 'too_many_requests', error_description: 'MCP client 註冊請求過於頻繁,請稍後再試' },
{ status: 429 }
);
}
}
// 全域 API 速率限制
if (pathname.startsWith('/api/')) {
if (!checkRateLimit(globalRateLimitMap, ip, RATE_LIMIT_MAX)) {
return NextResponse.json(
{ error: '請求過於頻繁,請稍後再試' },
{ status: 429 }
);
}
const method = request.method.toUpperCase();
const authHeader = request.headers.get('authorization') || '';
const usesCookieAuth = !authHeader.startsWith('Bearer ') && !!request.cookies.get('authToken')?.value;
if (!isPublicPath && usesCookieAuth && !CSRF_SAFE_METHODS.has(method)) {
const origin = request.headers.get('origin') || request.headers.get('referer') || '';
if (!isOriginAllowed(origin, request)) {
return NextResponse.json({ error: '請求來源不被允許(CSRF 防護)' }, { status: 403 });
}
}
}
// 公開端點不需 JWT 驗證
if (isPublicPath) return NextResponse.next();
// 不需保護的非 API 路徑(靜態頁面等)——只保護已知的 app 路徑 + API
const PROTECTED_PREFIXES = [
'/dashboard', '/accounts', '/transactions', '/categories',
'/budgets', '/reports', '/stocks', '/settings', '/admin',
'/api/',
];
const needsAuth = PROTECTED_PREFIXES.some(p => pathname === p || pathname.startsWith(p + '/') || pathname.startsWith(p));
if (!needsAuth) return NextResponse.next();
// 讀取 authToken Cookie
const token = request.cookies.get('authToken')?.value;
if (!token) {
// API 路由返回 401;頁面路由 redirect 至 /login
if (pathname.startsWith('/api/')) {
return NextResponse.json({ error: '請先登入' }, { status: 401 });
}
const loginUrl = new URL('/login', request.url);
if (pathname === '/settings/ledgers') {
loginUrl.searchParams.set('returnTo', `${pathname}${request.nextUrl.search}`);
}
return NextResponse.redirect(loginUrl);
}
// Edge runtime 無法用 jsonwebtoken(Node.js only);
// 僅做基本存在性檢查,完整驗證由各 API Route Handler 的 authMiddleware 負責。
// proxy 主要職責:防止未帶 cookie 的裸請求直達受保護頁面。
return NextResponse.next();
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon\\.ico).*)',
],
};