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

@@ -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();
};

View File

@@ -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);

View File

@@ -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 {

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 {
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>

View File

@@ -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

View File

@@ -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> {

View File

@@ -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`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
identity: config.upsnap.username,
password: config.upsnap.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
const res2 = await upSnapFetch(`/api/upsnap/wake/${config.upsnap.deviceId}`, {
method: 'GET',
});
if (!res2.ok) {
throw new Error(`UpSnap wake failed: ${res2.status}`);
}
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) {
const body2 = await res2.text().catch(() => '');
throw new Error(`UpSnap wake failed: ${res2.status}${body2}`);
}
}