v0.1.34 — security: command injection, version/health auth, SSRF

This commit is contained in:
Hendrik Garske 2026-05-21 15:06:15 +02:00
parent 71915dba04
commit 99b00674fd
6 changed files with 45 additions and 9 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,6 @@
{
"name": "corex-nexredirect",
"version": "0.1.33",
"version": "0.1.34",
"license": "MIT",
"overrides": {
"postcss": "^8.5.13",