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/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
|
||||||
|
|
||||||
## Деплой
|
## Деплой
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user