From 75f9686533dd11f5619896951ca612e3c8cea135 Mon Sep 17 00:00:00 2001 From: Igor Barcik Date: Wed, 13 May 2026 13:36:35 +0200 Subject: [PATCH] feat(security): add IP banning system with admin controls - Add banned_ips database table with IP, timestamp, reason, and banned_by fields - Implement IP ban checking in deviceTrack to block banned connections - Add admin API endpoints: /api/admin/ban/:ip, /api/admin/unban/:ip, /api/admin/disconnect/:ip, /api/admin/refresh/:ip - Add GET /api/banned endpoint to retrieve banned IPs list - Return 403 error page for banned IP connections --- src/server.ts | 133 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 131 insertions(+), 2 deletions(-) diff --git a/src/server.ts b/src/server.ts index c34edfb..3a8854e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -116,6 +116,15 @@ db.run(` ) `); +db.run(` + CREATE TABLE IF NOT EXISTS banned_ips ( + ip TEXT PRIMARY KEY, + banned_at INTEGER NOT NULL DEFAULT (unixepoch()), + reason TEXT, + banned_by TEXT + ) +`); + // Migrations for upgrading from older schema try { db.run("ALTER TABLE views ADD COLUMN cache_ttl_sec INTEGER NOT NULL DEFAULT 60"); @@ -144,6 +153,30 @@ function rowToView(row: ViewRow): View { }; } +// Seed a default view on first run (only when table is empty) +const viewCount = (db.query("SELECT COUNT(*) as n FROM views").get() as { n: number }).n; +if (viewCount === 0) { + db.run( + `INSERT INTO views (id,name,urls,method,meta_refresh_enabled,cache_ttl_sec,viewport_width,viewport_height,created_at) + VALUES (?,?,?,?,?,?,?,?,?)`, + [ + crypto.randomUUID(), + "Demo View", + JSON.stringify([ + { url: "https://www.google.com", durationSec: 30, forceDarkMode: false, bakeStyles: false }, + { url: "https://duck.ai", durationSec: 30, forceDarkMode: false, bakeStyles: false }, + { url: "https://www.msn.com", durationSec: 30, forceDarkMode: false, bakeStyles: false }, + ]), + "ssr", + 0, + 60, + 1920, + 1080, + Math.floor(Date.now() / 1000), + ], + ); +} + function dbLoadAll(): View[] { return (db.query("SELECT * FROM views ORDER BY created_at DESC").all() as ViewRow[]).map( rowToView, @@ -193,6 +226,30 @@ function dbDelete(id: string): boolean { return db.run("DELETE FROM views WHERE id = ?", [id]).changes > 0; } +function dbBanIP(ip: string, reason?: string): void { + db.run( + "INSERT OR REPLACE INTO banned_ips (ip, banned_at, reason, banned_by) VALUES (?, ?, ?, ?)", + [ip, Math.floor(Date.now() / 1000), reason ?? "Manual ban", "admin"] + ); +} + +function dbUnbanIP(ip: string): boolean { + return db.run("DELETE FROM banned_ips WHERE ip = ?", [ip]).changes > 0; +} + +function dbIsBanned(ip: string): boolean { + return db.query("SELECT ip FROM banned_ips WHERE ip = ?").get(ip) != null; +} + +function dbGetBannedIPs(): Array<{ ip: string; banned_at: number; reason: string; banned_by: string }> { + return db.query("SELECT * FROM banned_ips ORDER BY banned_at DESC").all() as Array<{ + ip: string; + banned_at: number; + reason: string; + banned_by: string; + }>; +} + // ═══════════════════════════════════════════════════════════════ // IN-MEMORY VIEW CACHE // ═══════════════════════════════════════════════════════════════ @@ -219,7 +276,13 @@ const devices = new Map(); const warmedUpViews = new Set(); const DEVICE_TTL_MS = 120_000; -function deviceTrack(ip: string, viewId: string, viewName: string, userAgent: string): void { +function deviceTrack(ip: string, viewId: string, viewName: string, userAgent: string): boolean { + // Check if IP is banned + if (dbIsBanned(ip)) { + console.warn(`[security] Blocked connection from banned IP: ${ip}`); + return false; + } + const existing = devices.get(ip); if (existing) { existing.viewId = viewId; @@ -238,6 +301,7 @@ function deviceTrack(ip: string, viewId: string, viewName: string, userAgent: st }); } } + return true; } function devicesPrune(): void { @@ -1231,6 +1295,11 @@ async function handleAPI(req: Request, path: string): Promise { return json(getDebugData()); } + // GET /api/banned + if (path === "/api/banned" && method === "GET") { + return json(dbGetBannedIPs()); + } + // POST /api/views if (path === "/api/views" && method === "POST") { try { @@ -1330,6 +1399,60 @@ async function handleAPI(req: Request, path: string): Promise { } } + // POST /api/admin/disconnect/:ip + const disconnectMatch = path.match(/^\/api\/admin\/disconnect\/(.+)$/); + if (disconnectMatch && method === "POST") { + const ip = decodeURIComponent(disconnectMatch[1]); + devices.delete(ip); + console.log(`[admin] Disconnected device: ${ip}`); + return json({ ok: true, message: `Device ${ip} disconnected` }); + } + + // POST /api/admin/ban/:ip + const banMatch = path.match(/^\/api\/admin\/ban\/(.+)$/); + if (banMatch && method === "POST") { + const ip = decodeURIComponent(banMatch[1]); + try { + const body = JSON.parse(await req.text()); + const reason = body.reason || "Banned by administrator"; + const bannedDevice = devices.get(ip); + dbBanIP(ip, reason); + devices.delete(ip); + if (bannedDevice) cacheInvalidate(bannedDevice.viewId); + console.log(`[admin] Banned IP: ${ip}, reason: ${reason}`); + return json({ ok: true, message: `IP ${ip} banned` }); + } catch { + return json({ error: "Invalid JSON body" }, 400); + } + } + + // POST /api/admin/unban/:ip + const unbanMatch = path.match(/^\/api\/admin\/unban\/(.+)$/); + if (unbanMatch && method === "POST") { + const ip = decodeURIComponent(unbanMatch[1]); + const success = dbUnbanIP(ip); + if (success) { + console.log(`[admin] Unbanned IP: ${ip}`); + return json({ ok: true, message: `IP ${ip} unbanned` }); + } else { + return json({ error: "IP not found in ban list" }, 404); + } + } + + // POST /api/admin/refresh/:ip + const refreshMatch = path.match(/^\/api\/admin\/refresh\/(.+)$/); + if (refreshMatch && method === "POST") { + const ip = decodeURIComponent(refreshMatch[1]); + const device = devices.get(ip); + if (device) { + cacheInvalidate(device.viewId); + console.log(`[admin] Forced cache refresh for view: ${device.viewId}`); + return json({ ok: true, message: `Cache refreshed for view ${device.viewId}` }); + } else { + return json({ error: "Device not found" }, 404); + } + } + return json({ error: "Not found" }, 404); } @@ -1441,12 +1564,18 @@ const server = Bun.serve<{ interval: number }>({ }); } - deviceTrack( + const tracked = deviceTrack( ip, view.id, view.name, req.headers.get("User-Agent") ?? "unknown", ); + if (!tracked) { + return new Response(errorPage("Access denied"), { + status: 403, + headers: { "Content-Type": "text/html; charset=utf-8" }, + }); + } return serveView(view); }