- Improved dashboard access control by blocking tunnel/Tailscale access when disabled.

This commit is contained in:
decolua
2026-04-13 17:38:09 +07:00
parent e02dd07a2c
commit 41c079baba
5 changed files with 29 additions and 10 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "9router-app", "name": "9router-app",
"version": "0.3.87", "version": "0.3.88",
"description": "9Router web dashboard", "description": "9Router web dashboard",
"private": true, "private": true,
"scripts": { "scripts": {

View File

@@ -121,8 +121,8 @@ export default function APIPageClient({ machineId }) {
// Ping once to verify reachable // Ping once to verify reachable
const healthUrl = `${tPublicUrl || tUrl}/api/health`; const healthUrl = `${tPublicUrl || tUrl}/api/health`;
try { try {
const ping = await fetch(healthUrl, { mode: "no-cors", cache: "no-store" }); const ping = await fetch(healthUrl, { cache: "no-store" });
if (ping.ok || ping.type === "opaque") { if (ping.ok) {
setTunnelEnabled(true); setTunnelEnabled(true);
} else { } else {
pingTunnelHealth(tPublicUrl || tUrl); pingTunnelHealth(tPublicUrl || tUrl);
@@ -769,7 +769,7 @@ export default function APIPageClient({ machineId }) {
/> />
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<p className="font-medium text-sm">Allow dashboard access via tunnel</p> <p className="font-medium text-sm">Allow dashboard access via tunnel</p>
<Tooltip text="When enabled, the dashboard can be accessed through your tunnel or Tailscale URL without requiring login. Only enable if you trust everyone who can reach your tunnel URL." /> <Tooltip text="When enabled, the dashboard can be accessed through your tunnel or Tailscale URL (login still required). When disabled, dashboard access via tunnel/Tailscale is completely blocked." />
</div> </div>
</div> </div>
)} )}

View File

@@ -8,11 +8,23 @@ const SECRET = new TextEncoder().encode(
process.env.JWT_SECRET || "9router-default-secret-change-me" process.env.JWT_SECRET || "9router-default-secret-change-me"
); );
function isTunnelRequest(request, settings) {
const host = (request.headers.get("host") || "").split(":")[0].toLowerCase();
const tunnelHost = settings.tunnelUrl ? new URL(settings.tunnelUrl).hostname.toLowerCase() : "";
const tailscaleHost = settings.tailscaleUrl ? new URL(settings.tailscaleUrl).hostname.toLowerCase() : "";
return (tunnelHost && host === tunnelHost) || (tailscaleHost && host === tailscaleHost);
}
export async function POST(request) { export async function POST(request) {
try { try {
const { password } = await request.json(); const { password } = await request.json();
const settings = await getSettings(); const settings = await getSettings();
// Block login via tunnel/tailscale if dashboard access is disabled
if (isTunnelRequest(request, settings) && settings.tunnelDashboardAccess !== true) {
return NextResponse.json({ error: "Dashboard access via tunnel is disabled" }, { status: 403 });
}
// Default password is '123456' if not set // Default password is '123456' if not set
const storedHash = settings.password; const storedHash = settings.password;

View File

@@ -6,7 +6,9 @@ export async function GET() {
const settings = await getSettings(); const settings = await getSettings();
const requireLogin = settings.requireLogin !== false; const requireLogin = settings.requireLogin !== false;
const tunnelDashboardAccess = settings.tunnelDashboardAccess === true; const tunnelDashboardAccess = settings.tunnelDashboardAccess === true;
return NextResponse.json({ requireLogin, tunnelDashboardAccess }); const tunnelUrl = settings.tunnelUrl || "";
const tailscaleUrl = settings.tailscaleUrl || "";
return NextResponse.json({ requireLogin, tunnelDashboardAccess, tunnelUrl, tailscaleUrl });
} catch (error) { } catch (error) {
return NextResponse.json({ requireLogin: true }, { status: 200 }); return NextResponse.json({ requireLogin: true }, { status: 200 });
} }

View File

@@ -81,15 +81,20 @@ export async function proxy(request) {
const data = await res.json(); const data = await res.json();
requireLogin = data.requireLogin !== false; requireLogin = data.requireLogin !== false;
tunnelDashboardAccess = data.tunnelDashboardAccess === true; tunnelDashboardAccess = data.tunnelDashboardAccess === true;
// Block tunnel/tailscale access if disabled (redirect to login)
if (!tunnelDashboardAccess) {
const host = (request.headers.get("host") || "").split(":")[0].toLowerCase();
const tunnelHost = data.tunnelUrl ? new URL(data.tunnelUrl).hostname.toLowerCase() : "";
const tailscaleHost = data.tailscaleUrl ? new URL(data.tailscaleUrl).hostname.toLowerCase() : "";
if ((tunnelHost && host === tunnelHost) || (tailscaleHost && host === tailscaleHost)) {
return NextResponse.redirect(new URL("/login", request.url));
}
}
} catch { } catch {
// On error, keep defaults (require login, block tunnel) // On error, keep defaults (require login, block tunnel)
} }
// Block tunnel access if disabled (checked before token to enforce the setting)
if (!isLocalRequest(request) && !tunnelDashboardAccess) {
return NextResponse.redirect(new URL("/login", request.url));
}
// If login not required, allow through // If login not required, allow through
if (!requireLogin) return NextResponse.next(); if (!requireLogin) return NextResponse.next();