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:
@@ -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
|
||||
|
||||
## Деплой
|
||||
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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<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) => {
|
||||
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);
|
||||
|
||||
|
||||
@@ -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<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 {
|
||||
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 {
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
export function getWakingPageHtml(serviceName: string): string {
|
||||
const safe = escapeHtml(serviceName);
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Waking up — ${serviceName}</title>
|
||||
<title>Waking up — ${safe}</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
@@ -38,7 +43,7 @@ export function getWakingPageHtml(serviceName: string): string {
|
||||
<body>
|
||||
<div class="container">
|
||||
<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 class="status" id="status">Waiting for response...</p>
|
||||
</div>
|
||||
|
||||
@@ -64,7 +64,7 @@ async function doWake(): Promise<void> {
|
||||
await wakeDevice();
|
||||
log('info', 'WoL packet sent via UpSnap');
|
||||
} 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
|
||||
|
||||
@@ -80,11 +80,29 @@ class ServiceManager {
|
||||
if (!service.target?.trim()) {
|
||||
throw new Error('Service target is required');
|
||||
}
|
||||
let url: URL;
|
||||
try {
|
||||
new URL(service.target);
|
||||
url = new URL(service.target);
|
||||
} catch {
|
||||
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> {
|
||||
|
||||
@@ -8,23 +8,24 @@ async function authenticate(): Promise<string> {
|
||||
const collections = ['_superusers', 'users'];
|
||||
|
||||
for (const collection of collections) {
|
||||
const res = await fetch(
|
||||
`${config.upsnap.url}/api/collections/${collection}/auth-with-password`,
|
||||
{
|
||||
const url = `${config.upsnap.url}/api/collections/${collection}/auth-with-password`;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
identity: config.upsnap.username,
|
||||
password: config.upsnap.password,
|
||||
}),
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as UpSnapAuthResponse;
|
||||
token = data.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');
|
||||
@@ -67,15 +68,18 @@ export async function wakeDevice(): Promise<void> {
|
||||
{ method: 'PATCH', body: JSON.stringify({ status: 'on' }) }
|
||||
);
|
||||
|
||||
// UpSnap may also have a dedicated wake endpoint — try both approaches
|
||||
if (!res.ok) {
|
||||
// Fallback: POST to wake endpoint
|
||||
if (res.ok) return;
|
||||
|
||||
const body1 = await res.text().catch(() => '');
|
||||
console.error(`[UpSnap] PATCH wake failed (${res.status}): ${body1}`);
|
||||
|
||||
// Fallback: GET wake endpoint
|
||||
const res2 = await upSnapFetch(`/api/upsnap/wake/${config.upsnap.deviceId}`, {
|
||||
method: 'GET',
|
||||
});
|
||||
if (!res2.ok) {
|
||||
throw new Error(`UpSnap wake failed: ${res2.status}`);
|
||||
}
|
||||
const body2 = await res2.text().catch(() => '');
|
||||
throw new Error(`UpSnap wake failed: ${res2.status} — ${body2}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user