icebear icebear0828 commited on
Commit
91ee702
·
unverified ·
1 Parent(s): dfab27b

feat: dashboard login gate for public deployments (#148)

Browse files

* feat: dashboard login gate for public deployments (#141)

Cookie-based session auth when proxy_api_key is set and request is non-localhost.
API routes (/v1/*) unaffected, Electron (localhost) auto-bypassed.

- In-memory session store with TTL from session.ttl_minutes config
- Dashboard auth middleware with path-based allowlist
- Login/logout/status endpoints with per-IP brute-force protection
- HTTPS auto-detection: Secure flag on cookie when X-Forwarded-Proto: https
- Remote session cannot clear proxy_api_key (prevents disabling login gate)
- Frontend LoginGate component with i18n (en/zh), conditional logout button

* fix: dashboard login security hardening

- Timing-safe password comparison (crypto.timingSafeEqual)
- Rate limiter cleanup of expired entries to prevent memory leak
- Extract shared parseSessionCookie to src/utils/parse-cookie.ts
- Remove silent password.trim() that could cause subtle mismatches
- Logout button visible on mobile (icon-only on small screens)
- Fix fragile cleanup timer test (use vi.setSystemTime consistently)

---------

Co-authored-by: icebear0828 <icebear0828@users.noreply.github.com>

CHANGELOG.md CHANGED
@@ -14,6 +14,14 @@
14
 
15
  ### Added
16
 
 
 
 
 
 
 
 
 
17
  - 账号封禁检测:上游返回非 Cloudflare 的 403 时自动标记为 `banned` 状态
18
  - Dashboard 卡片/表格显示玫红色 `Banned`/`已封禁` 状态徽章
19
  - 状态筛选下拉新增 `Banned` 选项
 
14
 
15
  ### Added
16
 
17
+ - Dashboard 登录门(#141):当 `proxy_api_key` 已配置且请求来自非 localhost 时,需输入密码才能访问控制台
18
+ - Cookie-based session,TTL 由 `session.ttl_minutes` 控制(默认 60 分钟)
19
+ - `POST /auth/dashboard-login`、`POST /auth/dashboard-logout`、`GET /auth/dashboard-status` 端点
20
+ - API 路由(`/v1/*`)不受影响,Electron(localhost)自动跳过
21
+ - 简单防暴力:同 IP 5 次/60s 限制
22
+ - HTTPS 自动检测:反代 `X-Forwarded-Proto: https` 时 cookie 加 `Secure` flag
23
+ - 远程 session 禁止清空 `proxy_api_key`(防止误操作导致登录门失效)
24
+ - Header 显示条件性退出按钮
25
  - 账号封禁检测:上游返回非 Cloudflare 的 403 时自动标记为 `banned` 状态
26
  - Dashboard 卡片/表格显示玫红色 `Banned`/`已封禁` 状态徽章
27
  - 状态筛选下拉新增 `Banned` 选项
shared/hooks/use-dashboard-auth.ts ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect, useCallback } from "preact/hooks";
2
+
3
+ export type DashboardAuthStatus = "loading" | "login" | "authenticated";
4
+
5
+ export function useDashboardAuth() {
6
+ const [status, setStatus] = useState<DashboardAuthStatus>("loading");
7
+ const [error, setError] = useState<string | null>(null);
8
+ const [isRemoteSession, setIsRemoteSession] = useState(false);
9
+
10
+ const checkStatus = useCallback(async () => {
11
+ try {
12
+ const res = await fetch("/auth/dashboard-status");
13
+ const data: { required: boolean; authenticated: boolean } = await res.json();
14
+ if (!data.required || data.authenticated) {
15
+ setStatus("authenticated");
16
+ setIsRemoteSession(data.required && data.authenticated);
17
+ } else {
18
+ setStatus("login");
19
+ }
20
+ } catch {
21
+ // If status endpoint fails, assume no gate (backwards compat)
22
+ setStatus("authenticated");
23
+ }
24
+ }, []);
25
+
26
+ useEffect(() => {
27
+ checkStatus();
28
+ }, [checkStatus]);
29
+
30
+ const login = useCallback(async (password: string) => {
31
+ setError(null);
32
+ try {
33
+ const res = await fetch("/auth/dashboard-login", {
34
+ method: "POST",
35
+ headers: { "Content-Type": "application/json" },
36
+ body: JSON.stringify({ password }),
37
+ });
38
+ if (res.ok) {
39
+ setStatus("authenticated");
40
+ setIsRemoteSession(true);
41
+ } else {
42
+ const data = await res.json();
43
+ setError(data.error || "Login failed");
44
+ }
45
+ } catch {
46
+ setError("Network error");
47
+ }
48
+ }, []);
49
+
50
+ const logout = useCallback(async () => {
51
+ try {
52
+ await fetch("/auth/dashboard-logout", { method: "POST" });
53
+ } finally {
54
+ setStatus("login");
55
+ setIsRemoteSession(false);
56
+ }
57
+ }, []);
58
+
59
+ return { status, error, login, logout, isRemoteSession };
60
+ }
shared/i18n/translations.ts CHANGED
@@ -220,6 +220,13 @@ export const translations = {
220
  last24h: "Last 24h",
221
  last3d: "Last 3d",
222
  last7d: "Last 7d",
 
 
 
 
 
 
 
223
  },
224
  zh: {
225
  serverOnline: "\u670d\u52a1\u8fd0\u884c\u4e2d",
@@ -445,6 +452,13 @@ export const translations = {
445
  last24h: "最近 24h",
446
  last3d: "最近 3 天",
447
  last7d: "最近 7 天",
 
 
 
 
 
 
 
448
  },
449
  } as const;
450
 
 
220
  last24h: "Last 24h",
221
  last3d: "Last 3d",
222
  last7d: "Last 7d",
223
+ dashboardLogin: "Dashboard Login",
224
+ dashboardPassword: "Password",
225
+ dashboardLoginBtn: "Log In",
226
+ dashboardLoginError: "Invalid password",
227
+ dashboardLoginRequired: "Enter the proxy API key to access the dashboard.",
228
+ dashboardLogout: "Logout",
229
+ dashboardTooManyAttempts: "Too many attempts. Try again later.",
230
  },
231
  zh: {
232
  serverOnline: "\u670d\u52a1\u8fd0\u884c\u4e2d",
 
452
  last24h: "最近 24h",
453
  last3d: "最近 3 天",
454
  last7d: "最近 7 天",
455
+ dashboardLogin: "控制台登录",
456
+ dashboardPassword: "密码",
457
+ dashboardLoginBtn: "登录",
458
+ dashboardLoginError: "密码错误",
459
+ dashboardLoginRequired: "请输入代理 API 密钥以访问控制台。",
460
+ dashboardLogout: "退出登录",
461
+ dashboardTooManyAttempts: "尝试次数过多,请稍后再试。",
462
  },
463
  } as const;
464
 
src/auth/__tests__/dashboard-session.test.ts ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+
3
+ vi.mock("../../config.js", () => ({
4
+ getConfig: vi.fn(() => ({
5
+ session: { ttl_minutes: 1, cleanup_interval_minutes: 1 },
6
+ })),
7
+ }));
8
+
9
+ import {
10
+ createSession,
11
+ validateSession,
12
+ deleteSession,
13
+ getSessionCount,
14
+ startSessionCleanup,
15
+ stopSessionCleanup,
16
+ _resetForTest,
17
+ } from "../dashboard-session.js";
18
+
19
+ beforeEach(() => {
20
+ _resetForTest();
21
+ });
22
+
23
+ afterEach(() => {
24
+ stopSessionCleanup();
25
+ _resetForTest();
26
+ });
27
+
28
+ describe("dashboard-session", () => {
29
+ it("createSession returns a session with id", () => {
30
+ const session = createSession();
31
+ expect(session.id).toBeTruthy();
32
+ expect(typeof session.id).toBe("string");
33
+ expect(session.expiresAt).toBeGreaterThan(Date.now());
34
+ });
35
+
36
+ it("validateSession returns true for valid session", () => {
37
+ const session = createSession();
38
+ expect(validateSession(session.id)).toBe(true);
39
+ });
40
+
41
+ it("validateSession returns false for nonexistent session", () => {
42
+ expect(validateSession("nonexistent")).toBe(false);
43
+ });
44
+
45
+ it("validateSession returns false for expired session", () => {
46
+ const session = createSession();
47
+ // Manually expire it
48
+ vi.spyOn(Date, "now").mockReturnValue(session.expiresAt + 1);
49
+ expect(validateSession(session.id)).toBe(false);
50
+ vi.restoreAllMocks();
51
+ });
52
+
53
+ it("deleteSession invalidates the session", () => {
54
+ const session = createSession();
55
+ expect(validateSession(session.id)).toBe(true);
56
+ deleteSession(session.id);
57
+ expect(validateSession(session.id)).toBe(false);
58
+ });
59
+
60
+ it("getSessionCount tracks correctly", () => {
61
+ expect(getSessionCount()).toBe(0);
62
+ createSession();
63
+ createSession();
64
+ expect(getSessionCount()).toBe(2);
65
+ });
66
+
67
+ it("cleanup removes expired sessions", () => {
68
+ vi.useFakeTimers();
69
+ const baseTime = Date.now();
70
+
71
+ const s1 = createSession();
72
+ const s2 = createSession();
73
+ expect(getSessionCount()).toBe(2);
74
+
75
+ // Expire both sessions (ttl_minutes=1 → 60_000ms)
76
+ vi.setSystemTime(baseTime + 60_001);
77
+
78
+ // Start cleanup and advance to trigger interval (cleanup_interval_minutes=1 → 60_000ms)
79
+ startSessionCleanup();
80
+ vi.advanceTimersByTime(60_001);
81
+
82
+ expect(validateSession(s1.id)).toBe(false);
83
+ expect(validateSession(s2.id)).toBe(false);
84
+
85
+ vi.useRealTimers();
86
+ });
87
+ });
src/auth/dashboard-session.ts ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Dashboard Session Store — in-memory session management for web dashboard login gate.
3
+ *
4
+ * Sessions are cookie-based and protect the dashboard when proxy_api_key is set
5
+ * and requests come from non-localhost origins. TTL is configured via session.ttl_minutes.
6
+ *
7
+ * Sessions are NOT persisted — server restart requires re-login, which is acceptable.
8
+ */
9
+
10
+ import { randomUUID } from "crypto";
11
+ import { getConfig } from "../config.js";
12
+
13
+ export interface DashboardSession {
14
+ id: string;
15
+ createdAt: number;
16
+ expiresAt: number;
17
+ }
18
+
19
+ const sessions = new Map<string, DashboardSession>();
20
+ let cleanupTimer: ReturnType<typeof setInterval> | null = null;
21
+
22
+ export function createSession(): DashboardSession {
23
+ const config = getConfig();
24
+ const ttlMs = config.session.ttl_minutes * 60_000;
25
+ const now = Date.now();
26
+ const session: DashboardSession = {
27
+ id: randomUUID(),
28
+ createdAt: now,
29
+ expiresAt: now + ttlMs,
30
+ };
31
+ sessions.set(session.id, session);
32
+ return session;
33
+ }
34
+
35
+ export function validateSession(id: string): boolean {
36
+ const session = sessions.get(id);
37
+ if (!session) return false;
38
+ if (Date.now() > session.expiresAt) {
39
+ sessions.delete(id);
40
+ return false;
41
+ }
42
+ return true;
43
+ }
44
+
45
+ export function deleteSession(id: string): void {
46
+ sessions.delete(id);
47
+ }
48
+
49
+ export function getSessionCount(): number {
50
+ return sessions.size;
51
+ }
52
+
53
+ function cleanupExpired(): void {
54
+ const now = Date.now();
55
+ for (const [id, session] of sessions) {
56
+ if (now > session.expiresAt) {
57
+ sessions.delete(id);
58
+ }
59
+ }
60
+ }
61
+
62
+ export function startSessionCleanup(): void {
63
+ if (cleanupTimer) return;
64
+ const config = getConfig();
65
+ const intervalMs = config.session.cleanup_interval_minutes * 60_000;
66
+ cleanupTimer = setInterval(cleanupExpired, intervalMs);
67
+ if (cleanupTimer.unref) cleanupTimer.unref();
68
+ }
69
+
70
+ export function stopSessionCleanup(): void {
71
+ if (cleanupTimer) {
72
+ clearInterval(cleanupTimer);
73
+ cleanupTimer = null;
74
+ }
75
+ }
76
+
77
+ /** Reset all sessions — for tests only. */
78
+ export function _resetForTest(): void {
79
+ sessions.clear();
80
+ stopSessionCleanup();
81
+ }
src/index.ts CHANGED
@@ -7,6 +7,7 @@ import { RefreshScheduler } from "./auth/refresh-scheduler.js";
7
  import { requestId } from "./middleware/request-id.js";
8
  import { logger } from "./middleware/logger.js";
9
  import { errorHandler } from "./middleware/error-handler.js";
 
10
  import { createAuthRoutes } from "./routes/auth.js";
11
  import { createAccountRoutes } from "./routes/accounts.js";
12
  import { createChatRoutes } from "./routes/chat.js";
@@ -26,6 +27,8 @@ import { loadStaticModels } from "./models/model-store.js";
26
  import { startModelRefresh, stopModelRefresh } from "./models/model-fetcher.js";
27
  import { startQuotaRefresh, stopQuotaRefresh } from "./auth/usage-refresher.js";
28
  import { UsageStatsStore } from "./auth/usage-stats.js";
 
 
29
 
30
  export interface ServerHandle {
31
  close: () => Promise<void>;
@@ -69,6 +72,7 @@ export async function startServer(options?: StartOptions): Promise<ServerHandle>
69
  app.use("*", requestId);
70
  app.use("*", logger);
71
  app.use("*", errorHandler);
 
72
 
73
  // Mount routes
74
  const authRoutes = createAuthRoutes(accountPool, refreshScheduler);
@@ -81,6 +85,7 @@ export async function startServer(options?: StartOptions): Promise<ServerHandle>
81
  const usageStats = new UsageStatsStore();
82
  const webRoutes = createWebRoutes(accountPool, usageStats);
83
 
 
84
  app.route("/", authRoutes);
85
  app.route("/", accountRoutes);
86
  app.route("/", chatRoutes);
@@ -119,6 +124,9 @@ export async function startServer(options?: StartOptions): Promise<ServerHandle>
119
  }
120
  console.log();
121
 
 
 
 
122
  // Start background update checkers
123
  // (Electron has its own native auto-updater — skip proxy update checker)
124
  startUpdateChecker();
@@ -152,6 +160,7 @@ export async function startServer(options?: StartOptions): Promise<ServerHandle>
152
  stopProxyUpdateChecker();
153
  stopModelRefresh();
154
  stopQuotaRefresh();
 
155
  refreshScheduler.destroy();
156
  proxyPool.destroy();
157
  cookieJar.destroy();
 
7
  import { requestId } from "./middleware/request-id.js";
8
  import { logger } from "./middleware/logger.js";
9
  import { errorHandler } from "./middleware/error-handler.js";
10
+ import { dashboardAuth } from "./middleware/dashboard-auth.js";
11
  import { createAuthRoutes } from "./routes/auth.js";
12
  import { createAccountRoutes } from "./routes/accounts.js";
13
  import { createChatRoutes } from "./routes/chat.js";
 
27
  import { startModelRefresh, stopModelRefresh } from "./models/model-fetcher.js";
28
  import { startQuotaRefresh, stopQuotaRefresh } from "./auth/usage-refresher.js";
29
  import { UsageStatsStore } from "./auth/usage-stats.js";
30
+ import { startSessionCleanup, stopSessionCleanup } from "./auth/dashboard-session.js";
31
+ import { createDashboardAuthRoutes } from "./routes/dashboard-login.js";
32
 
33
  export interface ServerHandle {
34
  close: () => Promise<void>;
 
72
  app.use("*", requestId);
73
  app.use("*", logger);
74
  app.use("*", errorHandler);
75
+ app.use("*", dashboardAuth);
76
 
77
  // Mount routes
78
  const authRoutes = createAuthRoutes(accountPool, refreshScheduler);
 
85
  const usageStats = new UsageStatsStore();
86
  const webRoutes = createWebRoutes(accountPool, usageStats);
87
 
88
+ app.route("/", createDashboardAuthRoutes());
89
  app.route("/", authRoutes);
90
  app.route("/", accountRoutes);
91
  app.route("/", chatRoutes);
 
124
  }
125
  console.log();
126
 
127
+ // Start dashboard session cleanup
128
+ startSessionCleanup();
129
+
130
  // Start background update checkers
131
  // (Electron has its own native auto-updater — skip proxy update checker)
132
  startUpdateChecker();
 
160
  stopProxyUpdateChecker();
161
  stopModelRefresh();
162
  stopQuotaRefresh();
163
+ stopSessionCleanup();
164
  refreshScheduler.destroy();
165
  proxyPool.destroy();
166
  cookieJar.destroy();
src/middleware/__tests__/dashboard-auth.test.ts ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { Hono } from "hono";
3
+
4
+ const mockConfig = {
5
+ server: { proxy_api_key: "test-key" as string | null },
6
+ session: { ttl_minutes: 60, cleanup_interval_minutes: 5 },
7
+ };
8
+
9
+ vi.mock("../../config.js", () => ({
10
+ getConfig: vi.fn(() => mockConfig),
11
+ }));
12
+
13
+ const mockGetConnInfo = vi.fn(() => ({ remote: { address: "192.168.1.100" } }));
14
+ vi.mock("@hono/node-server/conninfo", () => ({
15
+ getConnInfo: (...args: unknown[]) => mockGetConnInfo(...args),
16
+ }));
17
+
18
+ vi.mock("../../auth/dashboard-session.js", async () => {
19
+ const validSessions = new Set<string>();
20
+ return {
21
+ validateSession: vi.fn((id: string) => validSessions.has(id)),
22
+ _addTestSession: (id: string) => validSessions.add(id),
23
+ _clearTestSessions: () => validSessions.clear(),
24
+ };
25
+ });
26
+
27
+ import { dashboardAuth } from "../dashboard-auth.js";
28
+ // eslint-disable-next-line @typescript-eslint/consistent-type-imports
29
+ const sessionMod = await import("../../auth/dashboard-session.js") as {
30
+ validateSession: ReturnType<typeof vi.fn>;
31
+ _addTestSession: (id: string) => void;
32
+ _clearTestSessions: () => void;
33
+ };
34
+
35
+ function createApp(): Hono {
36
+ const app = new Hono();
37
+ app.use("*", dashboardAuth);
38
+ // Catch-all handler to confirm middleware passed through
39
+ app.all("*", (c) => c.json({ ok: true }));
40
+ return app;
41
+ }
42
+
43
+ describe("dashboard-auth middleware", () => {
44
+ beforeEach(() => {
45
+ vi.clearAllMocks();
46
+ mockConfig.server.proxy_api_key = "test-key";
47
+ mockGetConnInfo.mockReturnValue({ remote: { address: "192.168.1.100" } });
48
+ sessionMod._clearTestSessions();
49
+ });
50
+
51
+ it("passes through when proxy_api_key is not set", async () => {
52
+ mockConfig.server.proxy_api_key = null;
53
+ const app = createApp();
54
+ const res = await app.request("/auth/accounts");
55
+ expect(res.status).toBe(200);
56
+ });
57
+
58
+ it("passes through for localhost requests", async () => {
59
+ mockGetConnInfo.mockReturnValue({ remote: { address: "127.0.0.1" } });
60
+ const app = createApp();
61
+ const res = await app.request("/auth/accounts");
62
+ expect(res.status).toBe(200);
63
+ });
64
+
65
+ it("passes through for ::1 localhost", async () => {
66
+ mockGetConnInfo.mockReturnValue({ remote: { address: "::1" } });
67
+ const app = createApp();
68
+ const res = await app.request("/admin/rotation-settings");
69
+ expect(res.status).toBe(200);
70
+ });
71
+
72
+ it("passes through for GET / (HTML shell)", async () => {
73
+ const app = createApp();
74
+ const res = await app.request("/");
75
+ expect(res.status).toBe(200);
76
+ });
77
+
78
+ it("passes through for GET /desktop", async () => {
79
+ const app = createApp();
80
+ const res = await app.request("/desktop");
81
+ expect(res.status).toBe(200);
82
+ });
83
+
84
+ it("passes through for /assets/* (static files)", async () => {
85
+ const app = createApp();
86
+ const res = await app.request("/assets/index-abc123.js");
87
+ expect(res.status).toBe(200);
88
+ });
89
+
90
+ it("passes through for /health", async () => {
91
+ const app = createApp();
92
+ const res = await app.request("/health");
93
+ expect(res.status).toBe(200);
94
+ });
95
+
96
+ it("passes through for /v1/* API routes", async () => {
97
+ const app = createApp();
98
+ const res = await app.request("/v1/chat/completions");
99
+ expect(res.status).toBe(200);
100
+ });
101
+
102
+ it("passes through for /v1beta/* API routes", async () => {
103
+ const app = createApp();
104
+ const res = await app.request("/v1beta/models");
105
+ expect(res.status).toBe(200);
106
+ });
107
+
108
+ it("passes through for dashboard auth endpoints", async () => {
109
+ const app = createApp();
110
+ for (const path of ["/auth/dashboard-login", "/auth/dashboard-logout", "/auth/dashboard-status"]) {
111
+ const res = await app.request(path);
112
+ expect(res.status).toBe(200);
113
+ }
114
+ });
115
+
116
+ it("returns 401 for /auth/accounts without session", async () => {
117
+ const app = createApp();
118
+ const res = await app.request("/auth/accounts");
119
+ expect(res.status).toBe(401);
120
+ const body = await res.json();
121
+ expect(body.error).toBeTruthy();
122
+ });
123
+
124
+ it("returns 401 for /admin/* without session", async () => {
125
+ const app = createApp();
126
+ const res = await app.request("/admin/rotation-settings");
127
+ expect(res.status).toBe(401);
128
+ });
129
+
130
+ it("returns 401 for /auth/status without session", async () => {
131
+ const app = createApp();
132
+ const res = await app.request("/auth/status");
133
+ expect(res.status).toBe(401);
134
+ });
135
+
136
+ it("passes through with valid session cookie", async () => {
137
+ sessionMod._addTestSession("valid-session-id");
138
+ const app = createApp();
139
+ const res = await app.request("/auth/accounts", {
140
+ headers: { Cookie: "_codex_session=valid-session-id" },
141
+ });
142
+ expect(res.status).toBe(200);
143
+ });
144
+
145
+ it("returns 401 with invalid session cookie", async () => {
146
+ const app = createApp();
147
+ const res = await app.request("/auth/accounts", {
148
+ headers: { Cookie: "_codex_session=invalid-id" },
149
+ });
150
+ expect(res.status).toBe(401);
151
+ });
152
+ });
src/middleware/dashboard-auth.ts ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Dashboard Auth Middleware — cookie-based login gate for the web dashboard.
3
+ *
4
+ * When proxy_api_key is configured and the request originates from a non-localhost
5
+ * address, require a valid _codex_session cookie. Protects dashboard data endpoints
6
+ * while allowing: static assets, health, API routes, login endpoints, and HTML shell.
7
+ */
8
+
9
+ import type { Context, Next } from "hono";
10
+ import { getConnInfo } from "@hono/node-server/conninfo";
11
+ import { getConfig } from "../config.js";
12
+ import { isLocalhostRequest } from "../utils/is-localhost.js";
13
+ import { validateSession } from "../auth/dashboard-session.js";
14
+ import { parseSessionCookie } from "../utils/parse-cookie.js";
15
+
16
+ /** Paths that are always allowed through without dashboard session. */
17
+ const ALLOWED_PREFIXES = ["/assets/", "/v1/", "/v1beta/"];
18
+ const ALLOWED_EXACT = new Set([
19
+ "/health",
20
+ "/auth/dashboard-login",
21
+ "/auth/dashboard-logout",
22
+ "/auth/dashboard-status",
23
+ ]);
24
+ /** GET-only paths allowed (HTML shell must load to render login form). */
25
+ const ALLOWED_GET_EXACT = new Set(["/", "/desktop"]);
26
+
27
+ export async function dashboardAuth(c: Context, next: Next): Promise<Response | void> {
28
+ const config = getConfig();
29
+
30
+ // No key configured → no gate
31
+ if (!config.server.proxy_api_key) return next();
32
+
33
+ // Localhost → bypass (Electron + local dev)
34
+ const remoteAddr = getConnInfo(c).remote.address ?? "";
35
+ if (isLocalhostRequest(remoteAddr)) return next();
36
+
37
+ // Always-allowed paths
38
+ const path = c.req.path;
39
+ if (ALLOWED_EXACT.has(path)) return next();
40
+ if (ALLOWED_PREFIXES.some((p) => path.startsWith(p))) return next();
41
+ if (c.req.method === "GET" && ALLOWED_GET_EXACT.has(path)) return next();
42
+
43
+ // Check session cookie
44
+ const sessionId = parseSessionCookie(c.req.header("cookie"));
45
+ if (sessionId && validateSession(sessionId)) return next();
46
+
47
+ // Not authenticated — reject
48
+ c.status(401);
49
+ return c.json({ error: "Dashboard login required" });
50
+ }
src/routes/__tests__/dashboard-login.test.ts ADDED
@@ -0,0 +1,303 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { Hono } from "hono";
3
+
4
+ const mockConfig = {
5
+ server: { proxy_api_key: "secret-key" as string | null },
6
+ session: { ttl_minutes: 60, cleanup_interval_minutes: 5 },
7
+ auth: { rotation_strategy: "least_used" as string },
8
+ quota: {
9
+ refresh_interval_minutes: 5,
10
+ warning_thresholds: { primary: [80, 90], secondary: [80, 90] },
11
+ skip_exhausted: true,
12
+ },
13
+ };
14
+
15
+ vi.mock("../../config.js", () => ({
16
+ getConfig: vi.fn(() => mockConfig),
17
+ reloadAllConfigs: vi.fn(),
18
+ ROTATION_STRATEGIES: ["least_used", "round_robin", "sticky"],
19
+ }));
20
+
21
+ const mockGetConnInfo = vi.fn(() => ({ remote: { address: "192.168.1.100" } }));
22
+ vi.mock("@hono/node-server/conninfo", () => ({
23
+ getConnInfo: (...args: unknown[]) => mockGetConnInfo(...args),
24
+ }));
25
+
26
+ vi.mock("../../auth/dashboard-session.js", async (importOriginal) => {
27
+ const actual = await importOriginal<typeof import("../../auth/dashboard-session.js")>();
28
+ return actual;
29
+ });
30
+
31
+ vi.mock("../../paths.js", () => ({
32
+ getConfigDir: vi.fn(() => "/tmp/test-config"),
33
+ getPublicDir: vi.fn(() => "/tmp/test-public"),
34
+ getDesktopPublicDir: vi.fn(() => "/tmp/test-desktop"),
35
+ getDataDir: vi.fn(() => "/tmp/test-data"),
36
+ getBinDir: vi.fn(() => "/tmp/test-bin"),
37
+ isEmbedded: vi.fn(() => false),
38
+ }));
39
+
40
+ vi.mock("../../utils/yaml-mutate.js", () => ({
41
+ mutateYaml: vi.fn(),
42
+ }));
43
+
44
+ vi.mock("../../tls/transport.js", () => ({
45
+ getTransport: vi.fn(),
46
+ getTransportInfo: vi.fn(() => ({})),
47
+ }));
48
+
49
+ vi.mock("../../tls/curl-binary.js", () => ({
50
+ getCurlDiagnostics: vi.fn(() => ({})),
51
+ }));
52
+
53
+ vi.mock("../../fingerprint/manager.js", () => ({
54
+ buildHeaders: vi.fn(() => ({})),
55
+ }));
56
+
57
+ vi.mock("../../update-checker.js", () => ({
58
+ getUpdateState: vi.fn(() => ({})),
59
+ checkForUpdate: vi.fn(),
60
+ isUpdateInProgress: vi.fn(() => false),
61
+ }));
62
+
63
+ vi.mock("../../self-update.js", () => ({
64
+ getProxyInfo: vi.fn(() => ({})),
65
+ canSelfUpdate: vi.fn(() => false),
66
+ checkProxySelfUpdate: vi.fn(),
67
+ applyProxySelfUpdate: vi.fn(),
68
+ isProxyUpdateInProgress: vi.fn(() => false),
69
+ getCachedProxyUpdateResult: vi.fn(() => null),
70
+ getDeployMode: vi.fn(() => "git"),
71
+ }));
72
+
73
+ vi.mock("@hono/node-server/serve-static", () => ({
74
+ serveStatic: vi.fn(() => vi.fn()),
75
+ }));
76
+
77
+ import {
78
+ createDashboardAuthRoutes,
79
+ _resetRateLimitForTest,
80
+ } from "../dashboard-login.js";
81
+ import { createSettingsRoutes } from "../admin/settings.js";
82
+ import { _resetForTest } from "../../auth/dashboard-session.js";
83
+
84
+ function createApp(): Hono {
85
+ const app = new Hono();
86
+ app.route("/", createDashboardAuthRoutes());
87
+ app.route("/", createSettingsRoutes());
88
+ return app;
89
+ }
90
+
91
+ describe("dashboard auth endpoints", () => {
92
+ beforeEach(() => {
93
+ vi.clearAllMocks();
94
+ mockConfig.server.proxy_api_key = "secret-key";
95
+ mockGetConnInfo.mockReturnValue({ remote: { address: "192.168.1.100" } });
96
+ _resetForTest();
97
+ _resetRateLimitForTest();
98
+ });
99
+
100
+ describe("POST /auth/dashboard-login", () => {
101
+ it("returns 200 and sets cookie with correct password", async () => {
102
+ const app = createApp();
103
+ const res = await app.request("/auth/dashboard-login", {
104
+ method: "POST",
105
+ headers: { "Content-Type": "application/json" },
106
+ body: JSON.stringify({ password: "secret-key" }),
107
+ });
108
+ expect(res.status).toBe(200);
109
+ const body = await res.json();
110
+ expect(body.success).toBe(true);
111
+
112
+ const cookie = res.headers.get("set-cookie");
113
+ expect(cookie).toContain("_codex_session=");
114
+ expect(cookie).toContain("HttpOnly");
115
+ expect(cookie).toContain("SameSite=Strict");
116
+ expect(cookie).toContain("Path=/");
117
+ expect(cookie).toContain("Max-Age=");
118
+ });
119
+
120
+ it("returns 401 with wrong password", async () => {
121
+ const app = createApp();
122
+ const res = await app.request("/auth/dashboard-login", {
123
+ method: "POST",
124
+ headers: { "Content-Type": "application/json" },
125
+ body: JSON.stringify({ password: "wrong" }),
126
+ });
127
+ expect(res.status).toBe(401);
128
+ const body = await res.json();
129
+ expect(body.error).toBeTruthy();
130
+ });
131
+
132
+ it("returns 400 with missing body", async () => {
133
+ const app = createApp();
134
+ const res = await app.request("/auth/dashboard-login", {
135
+ method: "POST",
136
+ headers: { "Content-Type": "application/json" },
137
+ body: JSON.stringify({}),
138
+ });
139
+ expect(res.status).toBe(400);
140
+ });
141
+
142
+ it("sets Secure flag when behind HTTPS reverse proxy", async () => {
143
+ const app = createApp();
144
+ const res = await app.request("/auth/dashboard-login", {
145
+ method: "POST",
146
+ headers: {
147
+ "Content-Type": "application/json",
148
+ "X-Forwarded-Proto": "https",
149
+ },
150
+ body: JSON.stringify({ password: "secret-key" }),
151
+ });
152
+ expect(res.status).toBe(200);
153
+ const cookie = res.headers.get("set-cookie");
154
+ expect(cookie).toContain("Secure");
155
+ });
156
+
157
+ it("omits Secure flag for HTTP", async () => {
158
+ const app = createApp();
159
+ const res = await app.request("/auth/dashboard-login", {
160
+ method: "POST",
161
+ headers: { "Content-Type": "application/json" },
162
+ body: JSON.stringify({ password: "secret-key" }),
163
+ });
164
+ expect(res.status).toBe(200);
165
+ const cookie = res.headers.get("set-cookie");
166
+ expect(cookie).not.toContain("Secure");
167
+ });
168
+
169
+ it("returns 429 after 5 failed attempts", async () => {
170
+ const app = createApp();
171
+ for (let i = 0; i < 5; i++) {
172
+ await app.request("/auth/dashboard-login", {
173
+ method: "POST",
174
+ headers: { "Content-Type": "application/json" },
175
+ body: JSON.stringify({ password: "wrong" }),
176
+ });
177
+ }
178
+ const res = await app.request("/auth/dashboard-login", {
179
+ method: "POST",
180
+ headers: { "Content-Type": "application/json" },
181
+ body: JSON.stringify({ password: "wrong" }),
182
+ });
183
+ expect(res.status).toBe(429);
184
+ });
185
+ });
186
+
187
+ describe("POST /auth/dashboard-logout", () => {
188
+ it("clears session and cookie", async () => {
189
+ const app = createApp();
190
+ // Login first
191
+ const loginRes = await app.request("/auth/dashboard-login", {
192
+ method: "POST",
193
+ headers: { "Content-Type": "application/json" },
194
+ body: JSON.stringify({ password: "secret-key" }),
195
+ });
196
+ const cookie = loginRes.headers.get("set-cookie")!;
197
+ const sessionId = cookie.match(/_codex_session=([^;]+)/)![1];
198
+
199
+ // Logout
200
+ const logoutRes = await app.request("/auth/dashboard-logout", {
201
+ method: "POST",
202
+ headers: { Cookie: `_codex_session=${sessionId}` },
203
+ });
204
+ expect(logoutRes.status).toBe(200);
205
+ const body = await logoutRes.json();
206
+ expect(body.success).toBe(true);
207
+
208
+ const clearCookie = logoutRes.headers.get("set-cookie");
209
+ expect(clearCookie).toContain("Max-Age=0");
210
+ });
211
+ });
212
+
213
+ describe("GET /auth/dashboard-status", () => {
214
+ it("returns required=false when no key configured", async () => {
215
+ mockConfig.server.proxy_api_key = null;
216
+ const app = createApp();
217
+ const res = await app.request("/auth/dashboard-status");
218
+ const body = await res.json();
219
+ expect(body.required).toBe(false);
220
+ expect(body.authenticated).toBe(true);
221
+ });
222
+
223
+ it("returns required=false for localhost", async () => {
224
+ mockGetConnInfo.mockReturnValue({ remote: { address: "127.0.0.1" } });
225
+ const app = createApp();
226
+ const res = await app.request("/auth/dashboard-status");
227
+ const body = await res.json();
228
+ expect(body.required).toBe(false);
229
+ expect(body.authenticated).toBe(true);
230
+ });
231
+
232
+ it("returns required=true, authenticated=false for remote without session", async () => {
233
+ const app = createApp();
234
+ const res = await app.request("/auth/dashboard-status");
235
+ const body = await res.json();
236
+ expect(body.required).toBe(true);
237
+ expect(body.authenticated).toBe(false);
238
+ });
239
+
240
+ it("returns required=true, authenticated=true for remote with valid session", async () => {
241
+ const app = createApp();
242
+ // Login first
243
+ const loginRes = await app.request("/auth/dashboard-login", {
244
+ method: "POST",
245
+ headers: { "Content-Type": "application/json" },
246
+ body: JSON.stringify({ password: "secret-key" }),
247
+ });
248
+ const cookie = loginRes.headers.get("set-cookie")!;
249
+ const sessionId = cookie.match(/_codex_session=([^;]+)/)![1];
250
+
251
+ const statusRes = await app.request("/auth/dashboard-status", {
252
+ headers: { Cookie: `_codex_session=${sessionId}` },
253
+ });
254
+ const body = await statusRes.json();
255
+ expect(body.required).toBe(true);
256
+ expect(body.authenticated).toBe(true);
257
+ });
258
+ });
259
+
260
+ describe("POST /admin/settings — remote clear protection", () => {
261
+ it("blocks remote session from clearing proxy_api_key", async () => {
262
+ const app = createApp();
263
+ const res = await app.request("/admin/settings", {
264
+ method: "POST",
265
+ headers: {
266
+ "Content-Type": "application/json",
267
+ Authorization: "Bearer secret-key",
268
+ },
269
+ body: JSON.stringify({ proxy_api_key: null }),
270
+ });
271
+ expect(res.status).toBe(403);
272
+ const body = await res.json();
273
+ expect(body.error).toContain("Cannot clear");
274
+ });
275
+
276
+ it("allows localhost to clear proxy_api_key", async () => {
277
+ mockGetConnInfo.mockReturnValue({ remote: { address: "127.0.0.1" } });
278
+ const app = createApp();
279
+ const res = await app.request("/admin/settings", {
280
+ method: "POST",
281
+ headers: {
282
+ "Content-Type": "application/json",
283
+ Authorization: "Bearer secret-key",
284
+ },
285
+ body: JSON.stringify({ proxy_api_key: null }),
286
+ });
287
+ expect(res.status).toBe(200);
288
+ });
289
+
290
+ it("allows remote session to change (not clear) proxy_api_key", async () => {
291
+ const app = createApp();
292
+ const res = await app.request("/admin/settings", {
293
+ method: "POST",
294
+ headers: {
295
+ "Content-Type": "application/json",
296
+ Authorization: "Bearer secret-key",
297
+ },
298
+ body: JSON.stringify({ proxy_api_key: "new-key" }),
299
+ });
300
+ expect(res.status).toBe(200);
301
+ });
302
+ });
303
+ });
src/routes/admin/health.ts CHANGED
@@ -7,6 +7,7 @@ import { getConfig, getFingerprint } from "../../config.js";
7
  import { getConfigDir, getDataDir, getBinDir, isEmbedded } from "../../paths.js";
8
  import { getTransportInfo } from "../../tls/transport.js";
9
  import { getCurlDiagnostics } from "../../tls/curl-binary.js";
 
10
 
11
  export function createHealthRoutes(accountPool: AccountPool): Hono {
12
  const app = new Hono();
@@ -25,7 +26,7 @@ export function createHealthRoutes(accountPool: AccountPool): Hono {
25
  app.get("/debug/fingerprint", (c) => {
26
  const isProduction = process.env.NODE_ENV === "production";
27
  const remoteAddr = getConnInfo(c).remote.address ?? "";
28
- const isLocalhost = remoteAddr === "" || remoteAddr === "127.0.0.1" || remoteAddr === "::1" || remoteAddr === "::ffff:127.0.0.1";
29
  if (isProduction && !isLocalhost) {
30
  c.status(404);
31
  return c.json({ error: { message: "Not found", type: "invalid_request_error" } });
@@ -86,7 +87,7 @@ export function createHealthRoutes(accountPool: AccountPool): Hono {
86
 
87
  app.get("/debug/diagnostics", (c) => {
88
  const remoteAddr = getConnInfo(c).remote.address ?? "";
89
- const isLocalhost = remoteAddr === "" || remoteAddr === "127.0.0.1" || remoteAddr === "::1" || remoteAddr === "::ffff:127.0.0.1";
90
  if (process.env.NODE_ENV === "production" && !isLocalhost) {
91
  c.status(404);
92
  return c.json({ error: { message: "Not found", type: "invalid_request_error" } });
 
7
  import { getConfigDir, getDataDir, getBinDir, isEmbedded } from "../../paths.js";
8
  import { getTransportInfo } from "../../tls/transport.js";
9
  import { getCurlDiagnostics } from "../../tls/curl-binary.js";
10
+ import { isLocalhostRequest } from "../../utils/is-localhost.js";
11
 
12
  export function createHealthRoutes(accountPool: AccountPool): Hono {
13
  const app = new Hono();
 
26
  app.get("/debug/fingerprint", (c) => {
27
  const isProduction = process.env.NODE_ENV === "production";
28
  const remoteAddr = getConnInfo(c).remote.address ?? "";
29
+ const isLocalhost = isLocalhostRequest(remoteAddr);
30
  if (isProduction && !isLocalhost) {
31
  c.status(404);
32
  return c.json({ error: { message: "Not found", type: "invalid_request_error" } });
 
87
 
88
  app.get("/debug/diagnostics", (c) => {
89
  const remoteAddr = getConnInfo(c).remote.address ?? "";
90
+ const isLocalhost = isLocalhostRequest(remoteAddr);
91
  if (process.env.NODE_ENV === "production" && !isLocalhost) {
92
  c.status(404);
93
  return c.json({ error: { message: "Not found", type: "invalid_request_error" } });
src/routes/admin/settings.ts CHANGED
@@ -1,8 +1,10 @@
1
  import { Hono } from "hono";
2
  import { resolve } from "path";
 
3
  import { getConfig, reloadAllConfigs, ROTATION_STRATEGIES } from "../../config.js";
4
  import { getConfigDir } from "../../paths.js";
5
  import { mutateYaml } from "../../utils/yaml-mutate.js";
 
6
 
7
  export function createSettingsRoutes(): Hono {
8
  const app = new Hono();
@@ -73,6 +75,15 @@ export function createSettingsRoutes(): Hono {
73
  const body = await c.req.json() as { proxy_api_key?: string | null };
74
  const newKey = body.proxy_api_key === undefined ? currentKey : (body.proxy_api_key || null);
75
 
 
 
 
 
 
 
 
 
 
76
  const configPath = resolve(getConfigDir(), "default.yaml");
77
  mutateYaml(configPath, (data) => {
78
  const server = data.server as Record<string, unknown>;
 
1
  import { Hono } from "hono";
2
  import { resolve } from "path";
3
+ import { getConnInfo } from "@hono/node-server/conninfo";
4
  import { getConfig, reloadAllConfigs, ROTATION_STRATEGIES } from "../../config.js";
5
  import { getConfigDir } from "../../paths.js";
6
  import { mutateYaml } from "../../utils/yaml-mutate.js";
7
+ import { isLocalhostRequest } from "../../utils/is-localhost.js";
8
 
9
  export function createSettingsRoutes(): Hono {
10
  const app = new Hono();
 
75
  const body = await c.req.json() as { proxy_api_key?: string | null };
76
  const newKey = body.proxy_api_key === undefined ? currentKey : (body.proxy_api_key || null);
77
 
78
+ // Prevent remote sessions from clearing the key (would disable login gate)
79
+ if (currentKey && !newKey) {
80
+ const remoteAddr = getConnInfo(c).remote.address ?? "";
81
+ if (!isLocalhostRequest(remoteAddr)) {
82
+ c.status(403);
83
+ return c.json({ error: "Cannot clear API key from remote session — this would disable the login gate" });
84
+ }
85
+ }
86
+
87
  const configPath = resolve(getConfigDir(), "default.yaml");
88
  mutateYaml(configPath, (data) => {
89
  const server = data.server as Record<string, unknown>;
src/routes/dashboard-login.ts ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Dashboard Login Routes — cookie-based authentication for the web dashboard.
3
+ *
4
+ * Provides login/logout/status endpoints that work with the dashboard-auth middleware.
5
+ * Uses the existing proxy_api_key as the dashboard password.
6
+ */
7
+
8
+ import { timingSafeEqual } from "crypto";
9
+ import { Hono } from "hono";
10
+ import type { Context } from "hono";
11
+ import { getConnInfo } from "@hono/node-server/conninfo";
12
+ import { getConfig } from "../config.js";
13
+ import { isLocalhostRequest } from "../utils/is-localhost.js";
14
+ import { parseSessionCookie } from "../utils/parse-cookie.js";
15
+ import {
16
+ createSession,
17
+ validateSession,
18
+ deleteSession,
19
+ } from "../auth/dashboard-session.js";
20
+
21
+ /** Per-IP brute-force tracking: IP → { count, resetAt } */
22
+ const failedAttempts = new Map<string, { count: number; resetAt: number }>();
23
+ const MAX_ATTEMPTS = 5;
24
+ const WINDOW_MS = 60_000;
25
+
26
+ function checkRateLimit(ip: string): boolean {
27
+ const now = Date.now();
28
+ const entry = failedAttempts.get(ip);
29
+ if (!entry || now > entry.resetAt) {
30
+ if (entry) failedAttempts.delete(ip); // cleanup expired
31
+ return true;
32
+ }
33
+ return entry.count < MAX_ATTEMPTS;
34
+ }
35
+
36
+ function recordFailure(ip: string): void {
37
+ const now = Date.now();
38
+ const entry = failedAttempts.get(ip);
39
+ if (!entry || now > entry.resetAt) {
40
+ failedAttempts.set(ip, { count: 1, resetAt: now + WINDOW_MS });
41
+ } else {
42
+ entry.count++;
43
+ }
44
+ }
45
+
46
+ /** Detect HTTPS from X-Forwarded-Proto (reverse proxy) or protocol. */
47
+ function isHttps(c: Context): boolean {
48
+ const proto = c.req.header("x-forwarded-proto");
49
+ if (proto) return proto.toLowerCase() === "https";
50
+ const url = new URL(c.req.url);
51
+ return url.protocol === "https:";
52
+ }
53
+
54
+ function buildCookieString(name: string, value: string, maxAge: number, secure: boolean): string {
55
+ let cookie = `${name}=${value}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${maxAge}`;
56
+ if (secure) cookie += "; Secure";
57
+ return cookie;
58
+ }
59
+
60
+ /** Reset rate-limit state — for tests only. */
61
+ export function _resetRateLimitForTest(): void {
62
+ failedAttempts.clear();
63
+ }
64
+
65
+ export function createDashboardAuthRoutes(): Hono {
66
+ const app = new Hono();
67
+
68
+ // POST /auth/dashboard-login — validate proxy_api_key and set session cookie
69
+ app.post("/auth/dashboard-login", async (c) => {
70
+ const config = getConfig();
71
+ const remoteAddr = getConnInfo(c).remote.address ?? "unknown";
72
+
73
+ // Rate limit check
74
+ if (!checkRateLimit(remoteAddr)) {
75
+ c.status(429);
76
+ return c.json({ error: "Too many login attempts. Try again later." });
77
+ }
78
+
79
+ let body: { password?: string };
80
+ try {
81
+ body = await c.req.json();
82
+ } catch {
83
+ c.status(400);
84
+ return c.json({ error: "Invalid JSON body" });
85
+ }
86
+
87
+ const password = body.password;
88
+ if (!password || typeof password !== "string") {
89
+ c.status(400);
90
+ return c.json({ error: "Password is required" });
91
+ }
92
+
93
+ const key = config.server.proxy_api_key ?? "";
94
+ const a = Buffer.from(password);
95
+ const b = Buffer.from(key);
96
+ const match = a.length === b.length && timingSafeEqual(a, b);
97
+ if (!match) {
98
+ recordFailure(remoteAddr);
99
+ c.status(401);
100
+ return c.json({ error: "Invalid password" });
101
+ }
102
+
103
+ const session = createSession();
104
+ const maxAge = config.session.ttl_minutes * 60;
105
+ const secure = isHttps(c);
106
+ c.header("Set-Cookie", buildCookieString("_codex_session", session.id, maxAge, secure));
107
+ return c.json({ success: true });
108
+ });
109
+
110
+ // POST /auth/dashboard-logout — clear session and cookie
111
+ app.post("/auth/dashboard-logout", (c) => {
112
+ const sessionId = parseSessionCookie(c.req.header("cookie"));
113
+ if (sessionId) {
114
+ deleteSession(sessionId);
115
+ }
116
+ const secure = isHttps(c);
117
+ c.header("Set-Cookie", buildCookieString("_codex_session", "", 0, secure));
118
+ return c.json({ success: true });
119
+ });
120
+
121
+ // GET /auth/dashboard-status — check if login is required and current auth state
122
+ app.get("/auth/dashboard-status", (c) => {
123
+ const config = getConfig();
124
+
125
+ // No key → no gate required
126
+ if (!config.server.proxy_api_key) {
127
+ return c.json({ required: false, authenticated: true });
128
+ }
129
+
130
+ // Localhost → no gate required
131
+ const remoteAddr = getConnInfo(c).remote.address ?? "";
132
+ if (isLocalhostRequest(remoteAddr)) {
133
+ return c.json({ required: false, authenticated: true });
134
+ }
135
+
136
+ // Check session
137
+ const sessionId = parseSessionCookie(c.req.header("cookie"));
138
+ const authenticated = !!sessionId && validateSession(sessionId);
139
+
140
+ return c.json({ required: true, authenticated });
141
+ });
142
+
143
+ return app;
144
+ }
src/utils/__tests__/is-localhost.test.ts ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect } from "vitest";
2
+ import { isLocalhostRequest } from "../is-localhost.js";
3
+
4
+ describe("isLocalhostRequest", () => {
5
+ it.each([
6
+ ["", true],
7
+ ["127.0.0.1", true],
8
+ ["::1", true],
9
+ ["::ffff:127.0.0.1", true],
10
+ ])("returns true for %s", (addr, expected) => {
11
+ expect(isLocalhostRequest(addr)).toBe(expected);
12
+ });
13
+
14
+ it.each([
15
+ ["192.168.1.1", false],
16
+ ["10.0.0.5", false],
17
+ ["172.16.0.1", false],
18
+ ["8.8.8.8", false],
19
+ ["::ffff:192.168.1.1", false],
20
+ ["2001:db8::1", false],
21
+ ])("returns false for %s", (addr, expected) => {
22
+ expect(isLocalhostRequest(addr)).toBe(expected);
23
+ });
24
+ });
src/utils/is-localhost.ts ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ const LOCALHOST_ADDRS = new Set(["", "127.0.0.1", "::1", "::ffff:127.0.0.1"]);
2
+
3
+ /** Returns true if the remote address represents a localhost connection. */
4
+ export function isLocalhostRequest(remoteAddr: string): boolean {
5
+ return LOCALHOST_ADDRS.has(remoteAddr);
6
+ }
src/utils/parse-cookie.ts ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ const COOKIE_REGEX = /(?:^|;\s*)_codex_session=([^;]*)/;
2
+
3
+ /** Extract the _codex_session cookie value from a Cookie header. */
4
+ export function parseSessionCookie(cookieHeader: string | undefined): string | undefined {
5
+ if (!cookieHeader) return undefined;
6
+ const match = COOKIE_REGEX.exec(cookieHeader);
7
+ return match ? match[1] : undefined;
8
+ }
web/src/App.tsx CHANGED
@@ -1,4 +1,6 @@
1
- import { useState, useEffect, useRef } from "preact/hooks";
 
 
2
  import { I18nProvider } from "../../shared/i18n/context";
3
  import { ThemeProvider } from "../../shared/theme/context";
4
  import { Header } from "./components/Header";
@@ -22,6 +24,11 @@ import { useProxies } from "../../shared/hooks/use-proxies";
22
  import { useStatus } from "../../shared/hooks/use-status";
23
  import { useUpdateStatus } from "../../shared/hooks/use-update-status";
24
  import { useI18n } from "../../shared/i18n/context";
 
 
 
 
 
25
 
26
  function useUpdateMessage() {
27
  const { t } = useI18n();
@@ -75,6 +82,7 @@ function Dashboard() {
75
  const proxies = useProxies();
76
  const status = useStatus(accounts.list.length);
77
  const update = useUpdateMessage();
 
78
  const [showModal, setShowModal] = useState(false);
79
  const prevUpdateAvailable = useRef(false);
80
 
@@ -104,6 +112,7 @@ function Dashboard() {
104
  version={update.status?.proxy.version ?? null}
105
  commit={update.status?.proxy.commit ?? null}
106
  hasUpdate={update.hasUpdate}
 
107
  />
108
  <main class="flex-grow px-4 md:px-8 lg:px-40 py-8 flex justify-center">
109
  <div class="flex flex-col w-full max-w-[960px] gap-6">
@@ -195,13 +204,79 @@ function PageRouter({ hash }: { hash: string }) {
195
  }
196
  }
197
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
198
  export function App() {
199
  const hash = useHash();
200
 
201
  return (
202
  <I18nProvider>
203
  <ThemeProvider>
204
- <PageRouter hash={hash} />
 
 
205
  </ThemeProvider>
206
  </I18nProvider>
207
  );
 
1
+ import { useState, useEffect, useRef, useContext } from "preact/hooks";
2
+ import { createContext } from "preact";
3
+ import type { ComponentChildren } from "preact";
4
  import { I18nProvider } from "../../shared/i18n/context";
5
  import { ThemeProvider } from "../../shared/theme/context";
6
  import { Header } from "./components/Header";
 
24
  import { useStatus } from "../../shared/hooks/use-status";
25
  import { useUpdateStatus } from "../../shared/hooks/use-update-status";
26
  import { useI18n } from "../../shared/i18n/context";
27
+ import { useDashboardAuth } from "../../shared/hooks/use-dashboard-auth";
28
+
29
+ /** Context for dashboard session state (logout button, remote session indicator). */
30
+ const DashboardAuthCtx = createContext<{ onLogout?: () => void }>({});
31
+ function useDashboardAuthCtx() { return useContext(DashboardAuthCtx); }
32
 
33
  function useUpdateMessage() {
34
  const { t } = useI18n();
 
82
  const proxies = useProxies();
83
  const status = useStatus(accounts.list.length);
84
  const update = useUpdateMessage();
85
+ const { onLogout } = useDashboardAuthCtx();
86
  const [showModal, setShowModal] = useState(false);
87
  const prevUpdateAvailable = useRef(false);
88
 
 
112
  version={update.status?.proxy.version ?? null}
113
  commit={update.status?.proxy.commit ?? null}
114
  hasUpdate={update.hasUpdate}
115
+ onLogout={onLogout}
116
  />
117
  <main class="flex-grow px-4 md:px-8 lg:px-40 py-8 flex justify-center">
118
  <div class="flex flex-col w-full max-w-[960px] gap-6">
 
204
  }
205
  }
206
 
207
+ function LoginGate({ children }: { children: ComponentChildren }) {
208
+ const { t } = useI18n();
209
+ const auth = useDashboardAuth();
210
+ const [password, setPassword] = useState("");
211
+
212
+ if (auth.status === "loading") {
213
+ return (
214
+ <div class="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-bg-dark">
215
+ <div class="animate-pulse text-slate-400 dark:text-text-dim text-sm">Loading...</div>
216
+ </div>
217
+ );
218
+ }
219
+
220
+ if (auth.status === "login") {
221
+ const handleSubmit = (e: Event) => {
222
+ e.preventDefault();
223
+ if (password.trim()) auth.login(password.trim());
224
+ };
225
+
226
+ return (
227
+ <div class="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-bg-dark px-4">
228
+ <div class="w-full max-w-sm bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-2xl shadow-lg p-8">
229
+ <div class="flex flex-col items-center gap-2 mb-6">
230
+ <div class="flex items-center justify-center size-12 rounded-full bg-primary/10 text-primary border border-primary/20">
231
+ <svg class="size-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
232
+ <path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />
233
+ </svg>
234
+ </div>
235
+ <h1 class="text-lg font-bold text-slate-800 dark:text-text-main">{t("dashboardLogin")}</h1>
236
+ <p class="text-xs text-slate-500 dark:text-text-dim text-center">{t("dashboardLoginRequired")}</p>
237
+ </div>
238
+ <form onSubmit={handleSubmit} class="flex flex-col gap-4">
239
+ <div>
240
+ <label class="block text-xs font-medium text-slate-600 dark:text-text-dim mb-1.5">{t("dashboardPassword")}</label>
241
+ <input
242
+ type="password"
243
+ value={password}
244
+ onInput={(e) => setPassword((e.target as HTMLInputElement).value)}
245
+ class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-border-dark bg-slate-50 dark:bg-bg-dark text-sm text-slate-800 dark:text-text-main focus:outline-none focus:ring-2 focus:ring-primary/40 focus:border-primary transition-colors"
246
+ placeholder="proxy_api_key"
247
+ autofocus
248
+ />
249
+ </div>
250
+ {auth.error && (
251
+ <p class="text-xs text-red-500 font-medium">
252
+ {auth.error.includes("Too many") ? t("dashboardTooManyAttempts") : t("dashboardLoginError")}
253
+ </p>
254
+ )}
255
+ <button
256
+ type="submit"
257
+ class="w-full py-2.5 bg-primary hover:bg-primary-hover text-white text-sm font-semibold rounded-lg transition-colors shadow-sm active:scale-[0.98]"
258
+ >
259
+ {t("dashboardLoginBtn")}
260
+ </button>
261
+ </form>
262
+ </div>
263
+ </div>
264
+ );
265
+ }
266
+
267
+ const ctxValue = auth.isRemoteSession ? { onLogout: auth.logout } : {};
268
+ return <DashboardAuthCtx.Provider value={ctxValue}>{children}</DashboardAuthCtx.Provider>;
269
+ }
270
+
271
  export function App() {
272
  const hash = useHash();
273
 
274
  return (
275
  <I18nProvider>
276
  <ThemeProvider>
277
+ <LoginGate>
278
+ <PageRouter hash={hash} />
279
+ </LoginGate>
280
  </ThemeProvider>
281
  </I18nProvider>
282
  );
web/src/components/Header.tsx CHANGED
@@ -39,9 +39,10 @@ interface HeaderProps {
39
  commit?: string | null;
40
  isProxySettings?: boolean;
41
  hasUpdate?: boolean;
 
42
  }
43
 
44
- export function Header({ onAddAccount, onCheckUpdate, onOpenUpdateModal, checking, updateStatusMsg, updateStatusColor, version, commit, isProxySettings, hasUpdate }: HeaderProps) {
45
  const { lang, toggleLang, t } = useI18n();
46
  const { isDark, toggle: toggleTheme } = useTheme();
47
 
@@ -113,6 +114,18 @@ export function Header({ onAddAccount, onCheckUpdate, onOpenUpdateModal, checkin
113
  {updateStatusMsg}
114
  </button>
115
  )}
 
 
 
 
 
 
 
 
 
 
 
 
116
  {/* Language Toggle */}
117
  <button
118
  onClick={toggleLang}
 
39
  commit?: string | null;
40
  isProxySettings?: boolean;
41
  hasUpdate?: boolean;
42
+ onLogout?: () => void;
43
  }
44
 
45
+ export function Header({ onAddAccount, onCheckUpdate, onOpenUpdateModal, checking, updateStatusMsg, updateStatusColor, version, commit, isProxySettings, hasUpdate, onLogout }: HeaderProps) {
46
  const { lang, toggleLang, t } = useI18n();
47
  const { isDark, toggle: toggleTheme } = useTheme();
48
 
 
114
  {updateStatusMsg}
115
  </button>
116
  )}
117
+ {/* Logout (remote sessions only) */}
118
+ {onLogout && (
119
+ <button
120
+ onClick={onLogout}
121
+ class="flex items-center gap-1.5 px-3 py-1.5 rounded-full border border-gray-200 dark:border-border-dark text-slate-600 dark:text-text-dim hover:bg-slate-50 dark:hover:bg-border-dark transition-colors"
122
+ >
123
+ <svg class="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
124
+ <path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9" />
125
+ </svg>
126
+ <span class="hidden sm:inline"><StableText tKey="dashboardLogout" class="text-xs font-semibold">{t("dashboardLogout")}</StableText></span>
127
+ </button>
128
+ )}
129
  {/* Language Toggle */}
130
  <button
131
  onClick={toggleLang}