From be7c210161f4a8c8f2d13aad154689c216bc7898 Mon Sep 17 00:00:00 2001 From: Loi Phan Date: Sun, 12 Jul 2026 17:24:33 +0700 Subject: [PATCH] fix: update the permission for token saver page --- .../(dashboard)/dashboard/token-saver/page.js | 7 +++- src/app/api/settings/route.js | 22 +++++++++++- src/dashboardGuard.js | 4 +-- src/shared/components/Sidebar.js | 2 +- tests/unit/dashboard-guard.test.js | 35 +++++++++++++++++++ 5 files changed, 65 insertions(+), 5 deletions(-) diff --git a/src/app/(dashboard)/dashboard/token-saver/page.js b/src/app/(dashboard)/dashboard/token-saver/page.js index 765b51f9..a7d0ae92 100644 --- a/src/app/(dashboard)/dashboard/token-saver/page.js +++ b/src/app/(dashboard)/dashboard/token-saver/page.js @@ -1,5 +1,10 @@ import TokenSaverClient from "./TokenSaverClient"; +import { redirect } from "next/navigation"; +import { getCurrentDashboardUser } from "@/lib/auth/currentUser"; + +export default async function TokenSaverPage() { + const user = await getCurrentDashboardUser(); + if (user?.role !== "admin") redirect("/dashboard"); -export default function TokenSaverPage() { return ; } diff --git a/src/app/api/settings/route.js b/src/app/api/settings/route.js index 0429109d..1c774d80 100644 --- a/src/app/api/settings/route.js +++ b/src/app/api/settings/route.js @@ -16,6 +16,24 @@ const SETTINGS_RESPONSE_HEADERS = { // Secrets must never be mass-assigned from request body (CWE-915) const PROTECTED_SETTING_KEYS = ["password", "mitmSudoEncrypted"]; +// Token savers change gateway-wide request processing and can start or manage +// local helper processes. They are therefore administrator-only settings. +const TOKEN_SAVER_SETTING_KEYS = [ + "rtkEnabled", + "headroomEnabled", + "headroomUrl", + "headroomCodeAware", + "headroomKompress", + "cavemanEnabled", + "cavemanLevel", + "ponytailEnabled", + "ponytailLevel", + "pxpipeEnabled", + "pxpipeAutoInstall", + "pxpipeMinChars", + "pxpipeTimeoutMs", +]; + export async function GET() { try { const settings = await getSettings(); @@ -26,6 +44,7 @@ export async function GET() { safeSettings.comboStrategies = Object.fromEntries( Object.entries(safeSettings.comboStrategies || {}).filter(([comboId]) => ownedComboIds.has(comboId)) ); + for (const key of TOKEN_SAVER_SETTING_KEYS) delete safeSettings[key]; } safeSettings.oidcConfigured = !!(safeSettings.oidcIssuerUrl && safeSettings.oidcClientId && oidcClientSecret); @@ -51,7 +70,8 @@ export async function PATCH(request) { if ( Object.prototype.hasOwnProperty.call(body, "requireApiKey") || Object.prototype.hasOwnProperty.call(body, "tunnelDashboardAccess") || - Object.prototype.hasOwnProperty.call(body, "comboStrategies") + Object.prototype.hasOwnProperty.call(body, "comboStrategies") || + TOKEN_SAVER_SETTING_KEYS.some((key) => Object.prototype.hasOwnProperty.call(body, key)) ) { let user; try { diff --git a/src/dashboardGuard.js b/src/dashboardGuard.js index ef2fbaa2..5af7f2ea 100644 --- a/src/dashboardGuard.js +++ b/src/dashboardGuard.js @@ -46,11 +46,11 @@ const ALWAYS_PROTECTED = [ // User administration is never exposed to normal users, even if dashboard login // is disabled for local single-user deployments. -const ADMIN_ONLY_PATHS = ["/api/users", "/api/tunnel"]; +const ADMIN_ONLY_PATHS = ["/api/users", "/api/tunnel", "/api/headroom", "/api/pxpipe"]; // Dashboard paths requiring an administrator. Combo access is handled by its // owner-scoped API routes and is available to authenticated users. -const ADMIN_ONLY_DASHBOARD_PATHS = []; +const ADMIN_ONLY_DASHBOARD_PATHS = ["/dashboard/token-saver", "/dashboard/pxpipe"]; // Require auth, but allow through if requireLogin is disabled const PROTECTED_API_PATHS = [ diff --git a/src/shared/components/Sidebar.js b/src/shared/components/Sidebar.js index bfe3e76b..713a1317 100644 --- a/src/shared/components/Sidebar.js +++ b/src/shared/components/Sidebar.js @@ -26,7 +26,7 @@ const navItems = [ { href: "/dashboard/combos", label: "Combos", icon: "layers" }, { href: "/dashboard/usage", label: "Usage", icon: "bar_chart" }, { href: "/dashboard/quota", label: "Quota Tracker", icon: "data_usage" }, - { href: "/dashboard/token-saver", label: "Token Saver", icon: "savings" }, + { href: "/dashboard/token-saver", label: "Token Saver", icon: "savings", adminOnly: true }, // { href: "/dashboard/pxpipe", label: "PXPIPE", icon: "image" }, { href: "/dashboard/cli-tools", label: "CLI Tools", icon: "terminal" }, ]; diff --git a/tests/unit/dashboard-guard.test.js b/tests/unit/dashboard-guard.test.js index 0ac044ed..b905baac 100644 --- a/tests/unit/dashboard-guard.test.js +++ b/tests/unit/dashboard-guard.test.js @@ -301,6 +301,41 @@ describe("dashboard guard combo administration access", () => { }); }); +describe("dashboard guard token saver administration access", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getSettings.mockResolvedValue({ requireLogin: true }); + mocks.getUserById.mockResolvedValue({ id: "user-1", isActive: true, role: "user" }); + mocks.getConsistentMachineId.mockResolvedValue("cli-token"); + mocks.getDashboardAuthSession.mockResolvedValue({ userId: "user-1" }); + mocks.verifyDashboardAuthToken.mockResolvedValue(true); + }); + + it("rejects normal users from Token Saver pages and APIs", async () => { + for (const pathname of [ + "/api/headroom/status", + "/api/pxpipe/status", + ]) { + const response = await proxy(request(pathname, { host: "localhost:20128" }, "user-token")); + + expect(response.status).toBe(403); + expect(response.body.error).toBe("Administrator access required"); + } + + const response = await proxy(request("/dashboard/token-saver", { host: "localhost:20128" }, "user-token")); + expect(response.status).toBe(307); + expect(response.url.href).toBe("http://localhost/dashboard"); + }); + + it("allows administrators to access Token Saver pages and APIs", async () => { + mocks.getUserById.mockResolvedValue({ id: "user-1", isActive: true, role: "admin" }); + + expect(await proxy(request("/dashboard/token-saver", { host: "localhost:20128" }, "admin-token"))).toBe(mocks.nextResponse); + expect(await proxy(request("/api/headroom/status", { host: "localhost:20128" }, "admin-token"))).toBe(mocks.nextResponse); + expect(await proxy(request("/api/pxpipe/status", { host: "localhost:20128" }, "admin-token"))).toBe(mocks.nextResponse); + }); +}); + describe("dashboard guard helpers", () => { it("extracts bearer API keys before x-api-key", () => { const apiRequest = request("/v1/chat/completions", {