From 99b00674fd3bab6938a0554db593a7692a22da4c Mon Sep 17 00:00:00 2001 From: Hendrik Garske Date: Thu, 21 May 2026 15:06:15 +0200 Subject: [PATCH] =?UTF-8?q?v0.1.34=20=E2=80=94=20security:=20command=20inj?= =?UTF-8?q?ection,=20version/health=20auth,=20SSRF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/settings/route.ts | 21 +++++++++++++++++++++ app/api/v1/health/route.ts | 9 ++++++++- app/api/v1/version/route.ts | 5 ++++- lib/caddy.ts | 6 +++--- lib/updater.ts | 11 ++++++++--- package.json | 2 +- 6 files changed, 45 insertions(+), 9 deletions(-) diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts index d0ce0c0..17754e3 100644 --- a/app/api/settings/route.ts +++ b/app/api/settings/route.ts @@ -5,6 +5,24 @@ import { getSetting, setSetting } from "@/lib/db"; const PUBLIC_KEYS = ["base_domain", "admin_email", "update_auto", "update_include_prereleases", "hits_retention_days", "webhook_url"]; +function isPrivateUrl(raw: string): boolean { + try { + const { hostname } = new URL(raw); + const h = hostname.replace(/^\[|\]$/g, ""); // strip IPv6 brackets + if (/^localhost$/i.test(h)) return true; + if (/^127\./.test(h)) return true; + if (/^10\./.test(h)) return true; + if (/^192\.168\./.test(h)) return true; + if (/^172\.(1[6-9]|2\d|3[01])\./.test(h)) return true; + if (/^169\.254\./.test(h)) return true; + if (/^::1$/.test(h)) return true; + if (/^fc[0-9a-f]{2}/i.test(h)) return true; + return false; + } catch { + return true; + } +} + export async function GET() { const session = await getServerSession(authOptions); if (!session) return NextResponse.json({ error: "unauthorized" }, { status: 401 }); @@ -20,6 +38,9 @@ export async function PATCH(req: Request) { const body = await req.json().catch(() => ({})); for (const [k, v] of Object.entries(body)) { if (!PUBLIC_KEYS.includes(k)) continue; + if (k === "webhook_url" && v && isPrivateUrl(String(v))) { + return NextResponse.json({ error: "webhook_url must not point to a private or loopback address" }, { status: 422 }); + } setSetting(k, String(v)); } return NextResponse.json({ ok: true }); diff --git a/app/api/v1/health/route.ts b/app/api/v1/health/route.ts index 7659b33..2530cbf 100644 --- a/app/api/v1/health/route.ts +++ b/app/api/v1/health/route.ts @@ -2,9 +2,16 @@ import { NextResponse } from "next/server"; import fs from "fs"; import path from "path"; import { getDb } from "@/lib/db"; +import { authenticateToken } from "@/lib/api-auth"; import pkg from "../../../../package.json"; -export async function GET() { +export async function GET(req: Request) { + const authed = authenticateToken(req); + + if (!authed) { + return NextResponse.json({ ok: true }); + } + const db = getDb(); const since24h = Date.now() - 24 * 60 * 60 * 1000; diff --git a/app/api/v1/version/route.ts b/app/api/v1/version/route.ts index 22159d6..845788c 100644 --- a/app/api/v1/version/route.ts +++ b/app/api/v1/version/route.ts @@ -1,6 +1,9 @@ import { NextResponse } from "next/server"; +import { requireScope } from "@/lib/api-auth"; import { getUpdateStatus } from "@/lib/updater"; -export async function GET() { +export async function GET(req: Request) { + const auth = requireScope(req, "read:domains"); + if (auth instanceof NextResponse) return auth; return NextResponse.json(getUpdateStatus()); } diff --git a/lib/caddy.ts b/lib/caddy.ts index 9d721e0..7b9ff9a 100644 --- a/lib/caddy.ts +++ b/lib/caddy.ts @@ -1,10 +1,10 @@ import fs from "fs/promises"; import path from "path"; -import { exec } from "child_process"; +import { execFile } from "child_process"; import { promisify } from "util"; import { getDb, getSetting, type DomainRow, type DomainGroupRow } from "./db"; -const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); const CADDYFILE_PATH = process.env.NEXREDIRECT_CADDYFILE || "/etc/caddy/Caddyfile"; const CADDY_ADMIN = process.env.CADDY_ADMIN_URL || "http://localhost:2019"; @@ -84,7 +84,7 @@ export async function reloadCaddy(): Promise<{ ok: boolean; error?: string }> { // Try shell `caddy reload` first — it talks to admin API as caddy itself, no Origin-header issues. try { - await execAsync(`caddy reload --config ${CADDYFILE_PATH} --address localhost:2019`, { timeout: 30_000 }); + await execFileAsync("caddy", ["reload", "--config", CADDYFILE_PATH, "--address", "localhost:2019"], { timeout: 30_000 }); return { ok: true }; } catch (e) { // Fall back to direct admin API POST (older Caddy / different admin URL). diff --git a/lib/updater.ts b/lib/updater.ts index 07e152c..978be5b 100644 --- a/lib/updater.ts +++ b/lib/updater.ts @@ -1,9 +1,11 @@ -import { exec } from "child_process"; +import { execFile } from "child_process"; import { promisify } from "util"; import { getSetting, setSetting, getDb } from "./db"; import pkg from "../package.json"; -const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); + +const VALID_TAG = /^v?\d+\.\d+\.\d+(-[\w.]+)?$/; const REPO = process.env.NEXREDIRECT_REPO || "CoreXManagement/CoreX-NexRedirect"; @@ -104,11 +106,14 @@ export async function applyUpdate(): Promise<{ ok: boolean; from: string; to: st if (!to || !status.update_available) { return { ok: false, from, to, error: "no_update" }; } + if (!VALID_TAG.test(to)) { + return { ok: false, from, to, error: "invalid_tag" }; + } const updateScript = process.env.NEXREDIRECT_UPDATE_SCRIPT || "/opt/corex-nexredirect/scripts/update.sh"; const start = Date.now(); try { - const { stdout, stderr } = await execAsync(`sudo -n ${updateScript} ${to}`, { timeout: 5 * 60 * 1000 }); + const { stdout, stderr } = await execFileAsync("sudo", ["-n", updateScript, to], { timeout: 5 * 60 * 1000 }); getDb().prepare("INSERT INTO update_log (from_version, to_version, ts, status, log) VALUES (?, ?, ?, 'success', ?)") .run(from, to, start, (stdout + "\n" + stderr).slice(0, 10000)); return { ok: true, from, to }; diff --git a/package.json b/package.json index 58626dd..8fcc243 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "corex-nexredirect", - "version": "0.1.33", + "version": "0.1.34", "license": "MIT", "overrides": { "postcss": "^8.5.13",