diff --git a/docs/architecture.md b/docs/architecture.md index d277eb1..760aa5e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -57,10 +57,14 @@ Proxy матчит сервисы по `Host` заголовку. Если PC on - `POST /api/auth/login` — возвращает `{ token }` (UUID v4) - `POST /api/auth/logout` — удаляет сессию - `GET /api/auth/check` — проверяет, нужна ли auth и валиден ли токен -- Сессии in-memory (`Map`), теряются при рестарте +- Сессии in-memory (`Map`), TTL 24 часа, теряются при рестарте +- Rate limiting: 5 попыток / 15 мин на IP для `/api/auth/login` - Auth middleware защищает `/api/*` кроме `/api/auth/*` - WebSocket: токен через query `?token=...` - Сервис-прокси (по Host) — без auth +- CORS: только same-origin + localhost (для dev) +- SSRF защита: service target не может указывать на localhost, metadata endpoints +- XSS защита: HTML-экранирование в waking page ## Деплой diff --git a/packages/proxy/src/api/middleware.ts b/packages/proxy/src/api/middleware.ts index 302c420..2700cdb 100644 --- a/packages/proxy/src/api/middleware.ts +++ b/packages/proxy/src/api/middleware.ts @@ -1,7 +1,28 @@ -import { cors } from 'hono/cors'; +import type { MiddlewareHandler } from 'hono'; -export const corsMiddleware = cors({ - origin: '*', - allowMethods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'], - allowHeaders: ['Content-Type', 'Authorization'], -}); +// Minimal CORS: only allow same-origin. +// Dashboard is served from the same domain so it doesn't need CORS. +// For local dev (SvelteKit on a different port), localhost origins are allowed. +export const corsMiddleware: MiddlewareHandler = async (c, next) => { + const origin = c.req.header('origin'); + + if (origin) { + try { + const url = new URL(origin); + if (url.hostname === 'localhost' || url.hostname === '127.0.0.1') { + c.header('Access-Control-Allow-Origin', origin); + c.header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS'); + c.header('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + } + // For any other origin — no CORS headers → browser blocks the request + } catch { + // invalid origin, ignore + } + } + + if (c.req.method === 'OPTIONS') { + return c.body(null, 204); + } + + return next(); +}; diff --git a/packages/proxy/src/auth/routes.ts b/packages/proxy/src/auth/routes.ts index 9525ae6..de3a02b 100644 --- a/packages/proxy/src/auth/routes.ts +++ b/packages/proxy/src/auth/routes.ts @@ -10,18 +10,56 @@ function safeEqual(a: string, b: string): boolean { return timingSafeEqual(Buffer.from(a), Buffer.from(b)); } +// Rate limiting: max 5 attempts per 15 minutes per IP +const MAX_ATTEMPTS = 5; +const WINDOW_MS = 15 * 60 * 1000; +const loginAttempts = new Map(); + +function isRateLimited(ip: string): boolean { + const now = Date.now(); + const entry = loginAttempts.get(ip); + if (!entry) return false; + if (now - entry.firstAttempt > WINDOW_MS) { + loginAttempts.delete(ip); + return false; + } + return entry.count >= MAX_ATTEMPTS; +} + +function recordAttempt(ip: string): void { + const now = Date.now(); + const entry = loginAttempts.get(ip); + if (!entry || now - entry.firstAttempt > WINDOW_MS) { + loginAttempts.set(ip, { count: 1, firstAttempt: now }); + } else { + entry.count++; + } +} + authRoutes.post('/login', async (c) => { if (!isAuthEnabled()) { return c.json({ error: 'Auth is not enabled' }, 400); } - const body = await c.req.json<{ username: string; password: string }>(); + const ip = c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown'; + if (isRateLimited(ip)) { + return c.json({ error: 'Too many login attempts. Try again later.' }, 429); + } + + let body: { username?: string; password?: string }; + try { + body = await c.req.json<{ username: string; password: string }>(); + } catch { + return c.json({ error: 'Invalid request body' }, 400); + } const { username, password } = body; - if (!username || !password) { + if (typeof username !== 'string' || typeof password !== 'string' || !username || !password) { return c.json({ error: 'Username and password required' }, 400); } + recordAttempt(ip); + const validUser = safeEqual(username, config.auth.username); const validPass = safeEqual(password, config.auth.password); diff --git a/packages/proxy/src/auth/sessionStore.ts b/packages/proxy/src/auth/sessionStore.ts index bf78551..650875f 100644 --- a/packages/proxy/src/auth/sessionStore.ts +++ b/packages/proxy/src/auth/sessionStore.ts @@ -1,5 +1,8 @@ import { randomUUID } from 'node:crypto'; +const SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours +const CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour + interface Session { username: string; createdAt: number; @@ -7,6 +10,16 @@ interface Session { const sessions = new Map(); +// Periodic cleanup of expired sessions +setInterval(() => { + const now = Date.now(); + for (const [token, session] of sessions) { + if (now - session.createdAt > SESSION_TTL_MS) { + sessions.delete(token); + } + } +}, CLEANUP_INTERVAL_MS); + export function createSession(username: string): string { const token = randomUUID(); sessions.set(token, { username, createdAt: Date.now() }); @@ -14,7 +27,13 @@ export function createSession(username: string): string { } export function validateSession(token: string): Session | null { - return sessions.get(token) ?? null; + const session = sessions.get(token); + if (!session) return null; + if (Date.now() - session.createdAt > SESSION_TTL_MS) { + sessions.delete(token); + return null; + } + return session; } export function removeSession(token: string): void { diff --git a/packages/proxy/src/server/wakingPage.ts b/packages/proxy/src/server/wakingPage.ts index dc7dd64..405cd64 100644 --- a/packages/proxy/src/server/wakingPage.ts +++ b/packages/proxy/src/server/wakingPage.ts @@ -1,10 +1,15 @@ +function escapeHtml(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + export function getWakingPageHtml(serviceName: string): string { + const safe = escapeHtml(serviceName); return ` - Waking up — ${serviceName} + Waking up — ${safe}