fix(proxy): harden security and add UpSnap debug logging

- XSS: escape serviceName in waking page HTML
- Session TTL: 24h expiration with periodic cleanup
- Rate limit: 5 login attempts / 15 min per IP
- CORS: restrict to same-origin + localhost
- SSRF: block localhost/metadata in service targets
- UpSnap: log response bodies on auth/wake failures

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Vadim Sobinin
2026-03-15 00:57:38 +03:00
parent f38c944690
commit 719afa8533
8 changed files with 143 additions and 34 deletions

View File

@@ -57,10 +57,14 @@ Proxy матчит сервисы по `Host` заголовку. Если PC on
- `POST /api/auth/login` — возвращает `{ token }` (UUID v4) - `POST /api/auth/login` — возвращает `{ token }` (UUID v4)
- `POST /api/auth/logout` — удаляет сессию - `POST /api/auth/logout` — удаляет сессию
- `GET /api/auth/check` — проверяет, нужна ли auth и валиден ли токен - `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/*` - Auth middleware защищает `/api/*` кроме `/api/auth/*`
- WebSocket: токен через query `?token=...` - WebSocket: токен через query `?token=...`
- Сервис-прокси (по Host) — без auth - Сервис-прокси (по Host) — без auth
- CORS: только same-origin + localhost (для dev)
- SSRF защита: service target не может указывать на localhost, metadata endpoints
- XSS защита: HTML-экранирование в waking page
## Деплой ## Деплой

View File

@@ -1,7 +1,28 @@
import { cors } from 'hono/cors'; import type { MiddlewareHandler } from 'hono';
export const corsMiddleware = cors({ // Minimal CORS: only allow same-origin.
origin: '*', // Dashboard is served from the same domain so it doesn't need CORS.
allowMethods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'], // For local dev (SvelteKit on a different port), localhost origins are allowed.
allowHeaders: ['Content-Type', 'Authorization'], 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();
};

View File

@@ -10,18 +10,56 @@ function safeEqual(a: string, b: string): boolean {
return timingSafeEqual(Buffer.from(a), Buffer.from(b)); 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<string, { count: number; firstAttempt: number }>();
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) => { authRoutes.post('/login', async (c) => {
if (!isAuthEnabled()) { if (!isAuthEnabled()) {
return c.json({ error: 'Auth is not enabled' }, 400); 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; 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); return c.json({ error: 'Username and password required' }, 400);
} }
recordAttempt(ip);
const validUser = safeEqual(username, config.auth.username); const validUser = safeEqual(username, config.auth.username);
const validPass = safeEqual(password, config.auth.password); const validPass = safeEqual(password, config.auth.password);

View File

@@ -1,5 +1,8 @@
import { randomUUID } from 'node:crypto'; 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 { interface Session {
username: string; username: string;
createdAt: number; createdAt: number;
@@ -7,6 +10,16 @@ interface Session {
const sessions = new Map<string, Session>(); const sessions = new Map<string, Session>();
// 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 { export function createSession(username: string): string {
const token = randomUUID(); const token = randomUUID();
sessions.set(token, { username, createdAt: Date.now() }); sessions.set(token, { username, createdAt: Date.now() });
@@ -14,7 +27,13 @@ export function createSession(username: string): string {
} }
export function validateSession(token: string): Session | null { 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 { export function removeSession(token: string): void {

View File

@@ -1,10 +1,15 @@
function escapeHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
export function getWakingPageHtml(serviceName: string): string { export function getWakingPageHtml(serviceName: string): string {
const safe = escapeHtml(serviceName);
return `<!DOCTYPE html> return `<!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Waking up — ${serviceName}</title> <title>Waking up — ${safe}</title>
<style> <style>
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
body { body {
@@ -38,7 +43,7 @@ export function getWakingPageHtml(serviceName: string): string {
<body> <body>
<div class="container"> <div class="container">
<div class="spinner" id="spinner"></div> <div class="spinner" id="spinner"></div>
<h1>Waking up ${serviceName}...</h1> <h1>Waking up ${safe}...</h1>
<p>The server is starting. This page will reload automatically.</p> <p>The server is starting. This page will reload automatically.</p>
<p class="status" id="status">Waiting for response...</p> <p class="status" id="status">Waiting for response...</p>
</div> </div>

View File

@@ -64,7 +64,7 @@ async function doWake(): Promise<void> {
await wakeDevice(); await wakeDevice();
log('info', 'WoL packet sent via UpSnap'); log('info', 'WoL packet sent via UpSnap');
} catch (err) { } catch (err) {
log('error', 'Failed to wake device', String(err)); log('error', 'Failed to wake device', err instanceof Error ? err.message : String(err));
} }
// Start polling health // Start polling health

View File

@@ -80,11 +80,29 @@ class ServiceManager {
if (!service.target?.trim()) { if (!service.target?.trim()) {
throw new Error('Service target is required'); throw new Error('Service target is required');
} }
let url: URL;
try { try {
new URL(service.target); url = new URL(service.target);
} catch { } catch {
throw new Error(`Invalid target URL: ${service.target}`); throw new Error(`Invalid target URL: ${service.target}`);
} }
// Only allow http/https targets
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new Error('Service target must use http or https protocol');
}
// Block cloud metadata endpoints
const blockedHosts = ['169.254.169.254', 'metadata.google.internal'];
if (blockedHosts.includes(url.hostname)) {
throw new Error('Service target host is not allowed');
}
// Block localhost (proxy itself)
const hostname = url.hostname.toLowerCase();
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname === '0.0.0.0') {
throw new Error('Service target cannot point to localhost');
}
} }
private async persist(): Promise<void> { private async persist(): Promise<void> {

View File

@@ -8,23 +8,24 @@ async function authenticate(): Promise<string> {
const collections = ['_superusers', 'users']; const collections = ['_superusers', 'users'];
for (const collection of collections) { for (const collection of collections) {
const res = await fetch( const url = `${config.upsnap.url}/api/collections/${collection}/auth-with-password`;
`${config.upsnap.url}/api/collections/${collection}/auth-with-password`, const res = await fetch(url, {
{ method: 'POST',
method: 'POST', headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({
body: JSON.stringify({ identity: config.upsnap.username,
identity: config.upsnap.username, password: config.upsnap.password,
password: config.upsnap.password, }),
}), });
}
);
if (res.ok) { if (res.ok) {
const data = (await res.json()) as UpSnapAuthResponse; const data = (await res.json()) as UpSnapAuthResponse;
token = data.token; token = data.token;
return token; return token;
} }
const errBody = await res.text().catch(() => '');
console.error(`[UpSnap] Auth attempt ${collection} failed (${res.status}): ${errBody}`);
} }
throw new Error('UpSnap auth failed: could not authenticate as superuser or user'); throw new Error('UpSnap auth failed: could not authenticate as superuser or user');
@@ -67,15 +68,18 @@ export async function wakeDevice(): Promise<void> {
{ method: 'PATCH', body: JSON.stringify({ status: 'on' }) } { method: 'PATCH', body: JSON.stringify({ status: 'on' }) }
); );
// UpSnap may also have a dedicated wake endpoint — try both approaches if (res.ok) return;
if (!res.ok) {
// Fallback: POST to wake endpoint const body1 = await res.text().catch(() => '');
const res2 = await upSnapFetch(`/api/upsnap/wake/${config.upsnap.deviceId}`, { console.error(`[UpSnap] PATCH wake failed (${res.status}): ${body1}`);
method: 'GET',
}); // Fallback: GET wake endpoint
if (!res2.ok) { const res2 = await upSnapFetch(`/api/upsnap/wake/${config.upsnap.deviceId}`, {
throw new Error(`UpSnap wake failed: ${res2.status}`); method: 'GET',
} });
if (!res2.ok) {
const body2 = await res2.text().catch(() => '');
throw new Error(`UpSnap wake failed: ${res2.status}${body2}`);
} }
} }