From 779eb7da966758ae4db82cebbae3e6495b026cb6 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 9 Nov 2026 02:05:32 +0900 Subject: [PATCH 1/4] fix(gui): explain a refused Remember instead of reporting the admin token unverifiable Refs #8338 --- .../src/content/docs/guides/web-dashboard.md | 2 +- gui/src/admin-token-dialog.ts | 20 +++++++++---- gui/src/api.ts | 6 ++++ gui/src/i18n/de.ts | 1 + gui/src/i18n/en.ts | 1 + gui/src/i18n/fr.ts | 1 + gui/src/i18n/ja.ts | 1 + gui/src/i18n/ko.ts | 1 + gui/src/i18n/pt.ts | 1 + gui/src/i18n/ru.ts | 1 + gui/src/i18n/tr.ts | 1 + gui/src/i18n/vi.ts | 1 + gui/src/i18n/zh-TW.ts | 1 + gui/src/i18n/zh.ts | 1 + gui/tests/admin-token-dialog.test.ts | 28 +++++++++++++++++++ gui/tests/persistent-dashboard.test.ts | 21 ++++++++++++++ src/server/management/session-routes.ts | 2 +- .../persistent-dashboard-admission.test.ts | 10 +++++++ 18 files changed, 92 insertions(+), 8 deletions(-) diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index 818d52692a6..07c74760856 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -37,7 +37,7 @@ password manager can offer to save and autofill it. The dashboard itself still k in memory and does not write it to `localStorage` or `sessionStorage`; whether it is saved is entirely the browser or password manager's decision. -**Remember this device** is off by default. On a direct HTTPS dashboard (or local loopback), it exchanges the admin token for a server-issued HttpOnly, SameSite=Strict device cookie plus a separate device proof stored in this site's localStorage. HTTPS cookies are Secure and host-only. Neither the cookie nor the proof is the admin token, and neither works alone. Cookies are shared across ports of the same hostname; the origin-scoped proof binds the remembered device to this dashboard origin. Still prefer a dedicated hostname. The admin token is never written to localStorage or sessionStorage. If browser storage is unavailable or cannot save the proof, Remember reports unavailable. +**Remember this device** is off by default. On a direct HTTPS dashboard (or local loopback), it exchanges the admin token for a server-issued HttpOnly, SameSite=Strict device cookie plus a separate device proof stored in this site's localStorage. HTTPS cookies are Secure and host-only. Neither the cookie nor the proof is the admin token, and neither works alone. Cookies are shared across ports of the same hostname; the origin-scoped proof binds the remembered device to this dashboard origin. Still prefer a dedicated hostname. The admin token is never written to localStorage or sessionStorage. If browser storage is unavailable or cannot save the proof, Remember reports unavailable. Behind a TLS-terminating reverse proxy OpenCodex sees a plain HTTP request, so it refuses Remember for that address; the sign-in dialog says so and turns the option off, and signing in without Remember still works. The private dashboard-sessions.json stores hashes of the cookie, proof, admin credential and browser identity, the server origin and a fixed 30-day expiry. Relaunching the Safari home-screen app resumes a short-lived GUI session while that device record is valid. Remembered sessions cannot approve consent actions: GitHub stars, reset-credit consumption, prompt-layer edits, Meta Muse sign-in, desktop compatibility changes or quota validation. Use a fresh local or paired session for those actions. Cross-origin/relayed dashboards and remote plaintext HTTP do not support Remember. Expiry, a browser identity change, or rotating the admin token and restarting the server requires sign-in again. diff --git a/gui/src/admin-token-dialog.ts b/gui/src/admin-token-dialog.ts index 539ca981b51..ecd0cc5eafe 100644 --- a/gui/src/admin-token-dialog.ts +++ b/gui/src/admin-token-dialog.ts @@ -4,7 +4,7 @@ const ADMIN_TOKEN_DIALOG_ID = "opencodex-admin-token-dialog"; const ADMIN_TOKEN_USERNAME = "OpenCodex"; const ADMIN_TOKEN_DOCS_URL = "https://opencodex.me/guides/web-dashboard/#finding-the-admin-token"; -export type AdminTokenValidation = "accepted" | "rejected" | "unavailable"; +export type AdminTokenValidation = "accepted" | "rejected" | "unavailable" | "remember-unsupported"; export type AdminTokenVerifier = (token: string, rememberDevice?: boolean) => Promise; /** @@ -19,6 +19,7 @@ export function promptForAdminToken( ): Promise { const messages = DICTS[locale]; const titleText = messages["auth.adminTokenTitle"]; + let canRemember = rememberAllowed; return new Promise((resolve) => { const previouslyFocused = document.activeElement instanceof HTMLElement @@ -97,7 +98,7 @@ export function promptForAdminToken( const remember = document.createElement("input"); remember.type = "checkbox"; remember.name = "remember-device"; - remember.disabled = !rememberAllowed; + remember.disabled = !canRemember; rememberLabel.append(remember, " ", messages["auth.rememberDevice"]); const rememberHint = document.createElement("p"); rememberHint.className = "hint"; @@ -158,26 +159,33 @@ export function promptForAdminToken( remember.disabled = true; setValidationError(null); - void verifyToken(token, rememberAllowed && remember.checked).then((result) => { + void verifyToken(token, canRemember && remember.checked).then((result) => { if (settled) return; if (result === "accepted") { finish(token); return; } + if (result === "remember-unsupported") { + // The server refused Remember for this address; signing in without it still works. + canRemember = false; + remember.checked = false; + } password.value = ""; password.disabled = false; submit.disabled = false; - remember.disabled = !rememberAllowed; + remember.disabled = !canRemember; setValidationError(result === "rejected" ? messages["auth.adminTokenRejected"] - : messages["auth.adminTokenUnavailable"]); + : result === "remember-unsupported" + ? messages["auth.rememberUnsupported"] + : messages["auth.adminTokenUnavailable"]); password.focus(); }).catch(() => { if (settled) return; password.value = ""; password.disabled = false; submit.disabled = false; - remember.disabled = !rememberAllowed; + remember.disabled = !canRemember; setValidationError(messages["auth.adminTokenUnavailable"]); password.focus(); }); diff --git a/gui/src/api.ts b/gui/src/api.ts index c08238661e9..96b8b818920 100644 --- a/gui/src/api.ts +++ b/gui/src/api.ts @@ -479,6 +479,12 @@ async function verifyAdminToken(recovery: RecoveryAttempt, token: string, rememb const response = await rawFetch!(input, init); if (signal.aborted || !isCurrentRecovery(recovery)) return "unavailable"; if (response.status === 401) return "rejected"; + // The token was accepted, but this dashboard address cannot hold a remembered device (#8338). + if (remember && response.status === 403) { + const body = await response.json().catch(() => null) as { code?: unknown } | null; + if (signal.aborted || !isCurrentRecovery(recovery)) return "unavailable"; + return body?.code === "remember_requires_direct_origin" ? "remember-unsupported" : "unavailable"; + } if (!response.ok) return "unavailable"; if (remember) { const body = await response.json(); diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index bbf162a96cc..78ccbfe5e70 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -426,6 +426,7 @@ export const de: Record = { "auth.adminTokenHelp": "Dies ist der Admin-Token der OpenCodex-Verwaltungs-API, kein Anbieter-API-Schlüssel. Beim ersten Start schreibt der Proxy ihn nach ~/.opencodex/admin-api-token (oder $OPENCODEX_HOME/admin-api-token); OPENCODEX_ADMIN_AUTH_TOKEN hat Vorrang.", "auth.rememberDevice": "Dieses Gerät merken", "auth.rememberDeviceHint": "Eine widerrufbare HttpOnly-Dashboard-Sitzung 30 Tage behalten, nicht das Admin-Token. Erfordert ein direktes HTTPS-Dashboard mit derselben Origin wie die API (oder lokalen Loopback).", + "auth.rememberUnsupported": "OpenCodex hat den Admin-Token akzeptiert, kann dieses Gerät unter dieser Adresse aber nicht merken. Merken erfordert ein direktes HTTPS-Dashboard (oder lokalen Loopback); hinter einem Reverse Proxy melden Sie sich ohne diese Option an.", "connection.forgetDevice": "Dieses Gerät vergessen", "connection.forgetDeviceFailed": "Dieses Gerät konnte nicht vergessen werden. Die Sitzung wurde beibehalten.", "auth.adminTokenDocsLink": "So finden Sie ihn", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 8b2f5ae6e5e..a303a2f399a 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -361,6 +361,7 @@ export const en = { "auth.adminTokenHelp": "This is the OpenCodex management admin token, not a provider API key. The proxy writes it to ~/.opencodex/admin-api-token (or $OPENCODEX_HOME/admin-api-token) on first start, and OPENCODEX_ADMIN_AUTH_TOKEN overrides it.", "auth.rememberDevice": "Remember this device", "auth.rememberDeviceHint": "Keep a revocable HttpOnly dashboard session for 30 days, not the admin token. Requires a direct HTTPS dashboard that is same-origin with the API (or local loopback).", + "auth.rememberUnsupported": "OpenCodex accepted the admin token but cannot remember this device at this address. Remember needs a direct HTTPS dashboard (or local loopback); behind a reverse proxy, sign in without it.", "connection.forgetDevice": "Forget this device", "connection.forgetDeviceFailed": "Could not forget this device. The session was kept.", "auth.adminTokenDocsLink": "How to find it", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 87d5a25c97f..644f5ee8707 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -358,6 +358,7 @@ export const fr: Record = { "auth.adminTokenHelp": "Il s’agit du jeton d’administration de l’API de gestion OpenCodex, pas d’une clé API de fournisseur. Au premier démarrage, le proxy l’écrit dans ~/.opencodex/admin-api-token (ou $OPENCODEX_HOME/admin-api-token), et OPENCODEX_ADMIN_AUTH_TOKEN a la priorité.", "auth.rememberDevice": "Mémoriser cet appareil", "auth.rememberDeviceHint": "Conserve une session HttpOnly révocable pendant 30 jours, pas le jeton administrateur. Nécessite un tableau de bord HTTPS direct de même origine que l'API (ou une boucle locale).", + "auth.rememberUnsupported": "OpenCodex a accepté le jeton d’administration, mais ne peut pas mémoriser cet appareil à cette adresse. La mémorisation exige un tableau de bord HTTPS direct (ou une boucle locale) ; derrière un proxy inverse, connectez-vous sans cette option.", "connection.forgetDevice": "Oublier cet appareil", "connection.forgetDeviceFailed": "Impossible d’oublier cet appareil. La session a été conservée.", "auth.adminTokenDocsLink": "Où le trouver", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index a4c60cff6b7..a48d7d562a2 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -428,6 +428,7 @@ export const ja: Record = { "auth.adminTokenHelp": "これは OpenCodex 管理 API の管理者トークンで、プロバイダーの API キーではありません。プロキシは初回起動時に ~/.opencodex/admin-api-token(または $OPENCODEX_HOME/admin-api-token)へ書き込み、OPENCODEX_ADMIN_AUTH_TOKEN を設定するとそちらが優先されます。", "auth.rememberDevice": "このデバイスを記憶する", "auth.rememberDeviceHint": "管理者トークンではなく、取り消し可能な HttpOnly セッションを30日間保持します。API と同一オリジンの HTTPS ダッシュボードへの直接アクセス(またはローカルループバック)が必要です。", + "auth.rememberUnsupported": "OpenCodex は管理者トークンを受け付けましたが、このアドレスではこのデバイスを記憶できません。記憶には HTTPS ダッシュボードへの直接アクセス(またはローカルループバック)が必要です。リバースプロキシ経由の場合は、このオプションなしでサインインしてください。", "connection.forgetDevice": "このデバイスを解除", "connection.forgetDeviceFailed": "デバイスを解除できませんでした。セッションは保持されています。", "auth.adminTokenDocsLink": "確認方法", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index acd5ffb8064..6a278ff1399 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -426,6 +426,7 @@ export const ko: Record = { "auth.adminTokenHelp": "이 값은 OpenCodex 관리 API의 관리자 토큰이며 공급자 API 키가 아닙니다. 프록시가 처음 실행될 때 ~/.opencodex/admin-api-token(또는 $OPENCODEX_HOME/admin-api-token)에 기록하고, OPENCODEX_ADMIN_AUTH_TOKEN을 설정하면 그 값이 우선합니다.", "auth.rememberDevice": "이 기기 기억하기", "auth.rememberDeviceHint": "관리자 토큰이 아닌 취소 가능한 HttpOnly 세션을 30일 동안 유지합니다. API와 동일한 오리진의 HTTPS 대시보드에 직접 연결(또는 로컬 루프백)이 필요합니다.", + "auth.rememberUnsupported": "OpenCodex가 관리자 토큰을 확인했지만 이 주소에서는 이 기기를 기억할 수 없습니다. 기기 기억에는 HTTPS 대시보드 직접 연결(또는 로컬 루프백)이 필요합니다. 리버스 프록시 뒤에서는 이 옵션 없이 로그인하세요.", "connection.forgetDevice": "이 기기 잊기", "connection.forgetDeviceFailed": "이 기기를 해제하지 못했습니다. 세션은 유지됩니다.", "auth.adminTokenDocsLink": "찾는 방법", diff --git a/gui/src/i18n/pt.ts b/gui/src/i18n/pt.ts index 191d243a22d..0448a67d1b4 100644 --- a/gui/src/i18n/pt.ts +++ b/gui/src/i18n/pt.ts @@ -383,6 +383,7 @@ export const pt: Record = { "auth.adminTokenHelp": "Este é o token de administrador de gerenciamento do OpenCodex, não uma chave de API de provedor. O proxy o grava em ~/.opencodex/admin-api-token (ou $OPENCODEX_HOME/admin-api-token) na primeira inicialização, e OPENCODEX_ADMIN_AUTH_TOKEN o substitui.", "auth.rememberDevice": "Lembrar este dispositivo", "auth.rememberDeviceHint": "Mantém uma sessão HttpOnly revogável por 30 dias, não o token de administrador. Requer um painel HTTPS direto de mesma origem que a API (ou loopback local).", + "auth.rememberUnsupported": "O OpenCodex aceitou o token de administrador, mas não pode lembrar este dispositivo neste endereço. Lembrar requer um painel HTTPS direto (ou loopback local); atrás de um proxy reverso, entre sem esta opção.", "connection.forgetDevice": "Esquecer este dispositivo", "connection.forgetDeviceFailed": "Não foi possível esquecer este dispositivo. A sessão foi mantida.", "auth.adminTokenDocsLink": "Como encontrá-lo", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 16cc7a8f665..687960ae442 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -428,6 +428,7 @@ export const ru: Record = { "auth.adminTokenHelp": "Это административный токен управляющего API OpenCodex, а не ключ API провайдера. При первом запуске прокси записывает его в ~/.opencodex/admin-api-token (или $OPENCODEX_HOME/admin-api-token), а OPENCODEX_ADMIN_AUTH_TOKEN переопределяет это значение.", "auth.rememberDevice": "Запомнить это устройство", "auth.rememberDeviceHint": "Сохраняет отзываемую HttpOnly-сессию на 30 дней, а не токен администратора. Требуется прямой доступ к панели по HTTPS с тем же источником, что и API (или локальный loopback).", + "auth.rememberUnsupported": "OpenCodex принял токен администратора, но не может запомнить это устройство по этому адресу. Для запоминания нужен прямой доступ к панели по HTTPS (или локальный loopback); за обратным прокси входите без этой опции.", "connection.forgetDevice": "Забыть это устройство", "connection.forgetDeviceFailed": "Не удалось забыть это устройство. Сессия сохранена.", "auth.adminTokenDocsLink": "Как его найти", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 9c9f8f938b8..8edaa8874ac 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -360,6 +360,7 @@ export const tr: Record = { "auth.adminTokenHelp": "Bu, sağlayıcı API anahtarı değil, OpenCodex yönetim API’sinin yönetici jetonudur. Proxy ilk açılışta bunu ~/.opencodex/admin-api-token (veya $OPENCODEX_HOME/admin-api-token) dosyasına yazar; OPENCODEX_ADMIN_AUTH_TOKEN bu değeri geçersiz kılar.", "auth.rememberDevice": "Bu cihazı hatırla", "auth.rememberDeviceHint": "Yönetici belirteci yerine iptal edilebilir HttpOnly oturumunu 30 gün tutar. API ile aynı origin'de doğrudan bir HTTPS paneli (veya yerel loopback) gerekir.", + "auth.rememberUnsupported": "OpenCodex yönetici jetonunu kabul etti ancak bu adreste bu cihazı hatırlayamıyor. Hatırlama, doğrudan bir HTTPS paneli (veya yerel loopback) gerektirir; ters proxy arkasında bu seçenek olmadan oturum açın.", "connection.forgetDevice": "Bu cihazı unut", "connection.forgetDeviceFailed": "Bu cihaz unutulamadı. Oturum korundu.", "auth.adminTokenDocsLink": "Nasıl bulunur", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 87530280e12..4a9cde32777 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -358,6 +358,7 @@ export const vi: Record = { "auth.adminTokenHelp": "Đây là token quản trị quản lý OpenCodex, không phải là API key của provider. Proxy ghi nó vào ~/.opencodex/admin-api-token (hoặc $OPENCODEX_HOME/admin-api-token) trong lần khởi động đầu tiên và OPENCODEX_ADMIN_AUTH_TOKEN sẽ ghi đè nó.", "auth.rememberDevice": "Ghi nhớ thiết bị này", "auth.rememberDeviceHint": "Giữ phiên HttpOnly có thể thu hồi trong 30 ngày, không lưu mã quản trị. Yêu cầu bảng điều khiển HTTPS trực tiếp cùng nguồn gốc với API (hoặc loopback cục bộ).", + "auth.rememberUnsupported": "OpenCodex đã chấp nhận token quản trị nhưng không thể ghi nhớ thiết bị này tại địa chỉ này. Ghi nhớ yêu cầu bảng điều khiển HTTPS trực tiếp (hoặc loopback cục bộ); phía sau reverse proxy, hãy đăng nhập mà không chọn tùy chọn này.", "connection.forgetDevice": "Quên thiết bị này", "connection.forgetDeviceFailed": "Không thể quên thiết bị này. Phiên vẫn được giữ.", "auth.adminTokenDocsLink": "Cách tìm nó", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 1e9e42176be..153e3d67828 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -3088,6 +3088,7 @@ export const zhTW: Record = { "auth.adminTokenHelp": "這是 OpenCodex 管理 API 的管理員金鑰,不是服務商 API 金鑰。代理首次啟動時會寫入 ~/.opencodex/admin-api-token(或 $OPENCODEX_HOME/admin-api-token),設定 OPENCODEX_ADMIN_AUTH_TOKEN 可覆寫該值。", "auth.rememberDevice": "記住此裝置", "auth.rememberDeviceHint": "保留可撤銷的 HttpOnly 工作階段30天,不儲存管理員權杖。需要直接存取與 API 同源的 HTTPS 儀表板(或本機回環)。", + "auth.rememberUnsupported": "OpenCodex 已接受管理員權杖,但無法在此位址記住此裝置。記住裝置需要直接存取 HTTPS 儀表板(或本機回環);在反向代理後方,請不勾選此項登入。", "connection.forgetDevice": "忘記此裝置", "connection.forgetDeviceFailed": "無法忘記此裝置。工作階段已保留。", "auth.adminTokenDocsLink": "如何尋找", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index b988a709ecb..cbfa2547bdc 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -426,6 +426,7 @@ export const zh: Record = { "auth.adminTokenHelp": "这是 OpenCodex 管理 API 的管理员令牌,不是服务商 API 密钥。代理首次启动时会写入 ~/.opencodex/admin-api-token(或 $OPENCODEX_HOME/admin-api-token),设置 OPENCODEX_ADMIN_AUTH_TOKEN 可覆盖该值。", "auth.rememberDevice": "记住此设备", "auth.rememberDeviceHint": "保留可撤销的 HttpOnly 会话30天,不保存管理员令牌。需要直接访问与 API 同源的 HTTPS 仪表板(或本地回环)。", + "auth.rememberUnsupported": "OpenCodex 已接受管理员令牌,但无法在此地址记住此设备。记住设备需要直接访问 HTTPS 仪表板(或本地回环);在反向代理后面,请不勾选此项登录。", "connection.forgetDevice": "忘记此设备", "connection.forgetDeviceFailed": "无法忘记此设备。会话已保留。", "auth.adminTokenDocsLink": "如何查找", diff --git a/gui/tests/admin-token-dialog.test.ts b/gui/tests/admin-token-dialog.test.ts index 849753833c3..67c6d5e81de 100644 --- a/gui/tests/admin-token-dialog.test.ts +++ b/gui/tests/admin-token-dialog.test.ts @@ -181,6 +181,34 @@ test("notice display rules are scoped so a hidden notice cannot paint", async () }); /* #3353 — a bare password box explained nothing. */ +/* #8338 — a valid token behind a TLS-terminating proxy was reported as unverifiable. */ +test("a Remember refusal explains the address and lets the user sign in without it", async () => { + const calls: Array<[string, boolean | undefined]> = []; + const pending = promptForAdminToken(async (token, remember) => { + calls.push([token, remember]); + return remember ? "remember-unsupported" : "accepted"; + }); + const dialog = document.querySelector("#opencodex-admin-token-dialog")!; + const form = dialog.querySelector("form")!; + const password = form.elements.namedItem("password") as HTMLInputElement; + const remember = form.querySelector('input[name="remember-device"]')!; + remember.checked = true; + password.value = "valid-token"; + form.dispatchEvent(new testWindow.Event("submit", { bubbles: true, cancelable: true })); + await Promise.resolve(); + await Promise.resolve(); + const alert = dialog.querySelector('[role="alert"]')!; + expect(alert.textContent).toContain("cannot remember this device"); + expect(alert.textContent).not.toContain("could not be verified"); + expect(remember.checked).toBe(false); + expect(remember.disabled).toBe(true); + expect(dialog.isConnected).toBe(true); + password.value = "valid-token"; + form.dispatchEvent(new testWindow.Event("submit", { bubbles: true, cancelable: true })); + expect(await pending).toBe("valid-token"); + expect(calls).toEqual([["valid-token", true], ["valid-token", false]]); +}); + test("the dialog explains the credential and links the setup guide", async () => { const pending = promptForAdminToken(async () => "accepted"); const dialog = document.querySelector("#opencodex-admin-token-dialog")!; diff --git a/gui/tests/persistent-dashboard.test.ts b/gui/tests/persistent-dashboard.test.ts index 7c452920d96..e3de456cd8c 100644 --- a/gui/tests/persistent-dashboard.test.ts +++ b/gui/tests/persistent-dashboard.test.ts @@ -117,6 +117,27 @@ test("a hung Remember response body is aborted and cannot install a session", as }); function sessionHtml(): string { + return sessionHtmlFixture(); +} + +for (const [label, body, expected] of [ + ["the server's direct-origin refusal", { error: "Remember requires a direct HTTPS dashboard (or loopback) and matching Origin", code: "remember_requires_direct_origin" }, "remember-unsupported"], + ["an uncoded 403", { error: "Admin token required" }, "unavailable"], +] as const) { + test(`Remember maps ${label} to ${expected} (#8338)`, async () => { + let verdict: unknown; + resetApiAuthFetchForTests(async verify => { verdict = await verify("ocx_admin_fixture", true); return null; }); + window.fetch = (async input => String(input).endsWith("/api/session/remember") + ? Response.json(body, { status: 403 }) + : new Response(null, { status: 401 })) as typeof fetch; + installApiAuthFetch(); + expect((await window.fetch("/api/config")).status).toBe(401); + expect(verdict).toBe(expected); expect(hasApiSession("shared")).toBe(false); + expect(localStorage.getItem(PROOF_KEY)).toBeNull(); + }); +} + +function sessionHtmlFixture(): string { return ` diff --git a/src/server/management/session-routes.ts b/src/server/management/session-routes.ts index 107a345818d..4da3a000bde 100644 --- a/src/server/management/session-routes.ts +++ b/src/server/management/session-routes.ts @@ -6,7 +6,7 @@ export function handleSessionRoutes(ctx: ManagementContext): Response | null { if (ctx.principal !== "admin-token") return jsonResponse({ error: "Admin token required" }, 403, ctx.req, ctx.config); try { const remembered = ctx.sessionControl?.rememberDevice?.(ctx.req, ctx.config); - if (!remembered) return jsonResponse({ error: "Remember requires a direct HTTPS dashboard (or loopback) and matching Origin" }, 403, ctx.req, ctx.config); + if (!remembered) return jsonResponse({ error: "Remember requires a direct HTTPS dashboard (or loopback) and matching Origin", code: "remember_requires_direct_origin" }, 403, ctx.req, ctx.config); const response = jsonResponse({ session: remembered.session, device: remembered.device }, 200, ctx.req, ctx.config); response.headers.set("Set-Cookie", remembered.cookie); response.headers.set("Cache-Control", "no-store"); diff --git a/tests/server/persistent-dashboard-admission.test.ts b/tests/server/persistent-dashboard-admission.test.ts index 5563cec1bfb..0630a26f571 100644 --- a/tests/server/persistent-dashboard-admission.test.ts +++ b/tests/server/persistent-dashboard-admission.test.ts @@ -87,6 +87,16 @@ test("real remember admission requires admin, exact Origin, HTTPS or loopback", expect((await remember("http://127.0.0.1:10100")).body.session.issuance).toBe("remembered-admin"); }); +test("a Remember refusal for a proxied or plaintext origin carries a stable code (#8338)", async () => { + const refused = await dispatch(request("/api/session/remember", { origin: "http://dashboard.example.test" })); + expect(refused.status).toBe(403); + expect(await refused.json()).toMatchObject({ code: "remember_requires_direct_origin" }); + const device = await remember(); + const notAdmin = await dispatch(deviceRequest(device, "/api/session/remember")); + expect(notAdmin.status).toBe(403); + expect((await notAdmin.json()).code).toBeUndefined(); +}); + test("forwarded headers cannot upgrade plaintext or change the origin binding", async () => { const headers = { "X-Forwarded-Proto": "https", "X-Forwarded-Host": "dashboard.example.test", "X-Forwarded-For": "127.0.0.1" }; expect((await dispatch(request("/api/session/remember", { origin: "http://dashboard.example.test", headers }))).status).toBe(403); From d1ca911ea9d0b439b9ca4ec7f6b73caebd05e8b7 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 9 Nov 2026 02:54:50 +0900 Subject: [PATCH 2/4] docs: explain the reverse-proxy Remember refusal in every locale --- docs-site/src/content/docs/fr/guides/web-dashboard.md | 2 +- docs-site/src/content/docs/ja/guides/web-dashboard.md | 2 +- docs-site/src/content/docs/ko/guides/web-dashboard.md | 2 +- docs-site/src/content/docs/ru/guides/web-dashboard.md | 2 +- docs-site/src/content/docs/tr/guides/web-dashboard.md | 2 +- docs-site/src/content/docs/zh-cn/guides/web-dashboard.md | 2 +- docs-site/src/content/docs/zh-tw/guides/web-dashboard.md | 2 +- gui/tests/persistent-dashboard.test.ts | 6 +----- 8 files changed, 8 insertions(+), 12 deletions(-) diff --git a/docs-site/src/content/docs/fr/guides/web-dashboard.md b/docs-site/src/content/docs/fr/guides/web-dashboard.md index 8dad87f1b94..fb00d4b6c07 100644 --- a/docs-site/src/content/docs/fr/guides/web-dashboard.md +++ b/docs-site/src/content/docs/fr/guides/web-dashboard.md @@ -39,7 +39,7 @@ remplissage automatique. Le tableau de bord lui-même ne conserve le jeton qu'en dans `localStorage` ni dans `sessionStorage` ; son enregistrement dépend entièrement du navigateur ou du gestionnaire de mots de passe. -**Mémoriser cet appareil** est désactivé par défaut. Sur un tableau de bord HTTPS direct (ou en boucle locale), le serveur échange le jeton administrateur contre un cookie d’appareil HttpOnly, SameSite=Strict et une preuve distincte stockée dans le localStorage de ce site. Le cookie est Secure en HTTPS et limité à l’hôte. Ni le cookie ni la preuve ne sont le jeton administrateur, et aucun ne fonctionne seul. Les cookies sont partagés entre les ports d’un même nom d’hôte ; la preuve, limitée à l’origine, lie l’appareil mémorisé à l’origine de ce tableau de bord. Préférez tout de même un nom d’hôte dédié. Le jeton administrateur n’est jamais écrit dans localStorage ou sessionStorage. Si le stockage du navigateur est indisponible ou ne peut enregistrer la preuve, la mémorisation est signalée comme indisponible. +**Mémoriser cet appareil** est désactivé par défaut. Sur un tableau de bord HTTPS direct (ou en boucle locale), le serveur échange le jeton administrateur contre un cookie d’appareil HttpOnly, SameSite=Strict et une preuve distincte stockée dans le localStorage de ce site. Le cookie est Secure en HTTPS et limité à l’hôte. Ni le cookie ni la preuve ne sont le jeton administrateur, et aucun ne fonctionne seul. Les cookies sont partagés entre les ports d’un même nom d’hôte ; la preuve, limitée à l’origine, lie l’appareil mémorisé à l’origine de ce tableau de bord. Préférez tout de même un nom d’hôte dédié. Le jeton administrateur n’est jamais écrit dans localStorage ou sessionStorage. Si le stockage du navigateur est indisponible ou ne peut enregistrer la preuve, la mémorisation est signalée comme indisponible. Derrière un proxy inverse qui termine TLS, OpenCodex voit une requête HTTP simple et refuse donc la mémorisation pour cette adresse ; la fenêtre de connexion l’explique et désactive l’option, et la connexion sans mémorisation fonctionne toujours. Le fichier privé dashboard-sessions.json conserve les empreintes du cookie, de la preuve, du jeton administrateur et de l’identité du navigateur, l’origine du serveur et une expiration fixe de 30 jours. La relance de l’application Safari reprend une session courte tant que cet appareil reste autorisé. Les sessions mémorisées ne peuvent pas approuver les actions de consentement : étoile GitHub, consommation de crédits de réinitialisation, modification des couches de prompt, connexion Meta Muse, changements de compatibilité du bureau ou validation des quotas. Utilisez une nouvelle session locale ou appairée pour ces actions. Les tableaux de bord relayés, inter-origines ou HTTP distant ne prennent pas en charge la mémorisation. L’expiration, un changement d’identité du navigateur ou la rotation du jeton suivie d’un redémarrage imposent une nouvelle connexion. diff --git a/docs-site/src/content/docs/ja/guides/web-dashboard.md b/docs-site/src/content/docs/ja/guides/web-dashboard.md index 8bc23e1faae..416b97a13f8 100644 --- a/docs-site/src/content/docs/ja/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ja/guides/web-dashboard.md @@ -27,7 +27,7 @@ bun run dev:gui リモートダッシュボードでは標準のパスワードフォームが表示され、ブラウザのパスワードマネージャーで保存・自動入力できます。ダッシュボード自体はトークンをメモリ内だけに保持し、`localStorage` や `sessionStorage` には書き込みません。保存するかどうかはブラウザまたはパスワードマネージャーだけが決定します。 -**このデバイスを記憶する** は既定でオフです。直接アクセスする HTTPS ダッシュボード(またはローカルループバック)で、管理者トークンをサーバー発行の HttpOnly・SameSite=Strict デバイス Cookie と、このサイトの localStorage に保存する別のデバイス証明に交換します。HTTPS の Cookie は Secure でホスト限定です。Cookie も証明も管理者トークンではなく、どちらも単独では機能しません。同じホスト名のポート間では Cookie が共有されるため、オリジン単位の証明が記憶したデバイスをこのダッシュボードのオリジンに結び付けます。それでも専用ホスト名を推奨します。管理者トークンは localStorage や sessionStorage に保存しません。ブラウザーのストレージが使えない、または証明を保存できない場合、記憶機能は利用不可と表示されます。 +**このデバイスを記憶する** は既定でオフです。直接アクセスする HTTPS ダッシュボード(またはローカルループバック)で、管理者トークンをサーバー発行の HttpOnly・SameSite=Strict デバイス Cookie と、このサイトの localStorage に保存する別のデバイス証明に交換します。HTTPS の Cookie は Secure でホスト限定です。Cookie も証明も管理者トークンではなく、どちらも単独では機能しません。同じホスト名のポート間では Cookie が共有されるため、オリジン単位の証明が記憶したデバイスをこのダッシュボードのオリジンに結び付けます。それでも専用ホスト名を推奨します。管理者トークンは localStorage や sessionStorage に保存しません。ブラウザーのストレージが使えない、または証明を保存できない場合、記憶機能は利用不可と表示されます。TLS を終端するリバースプロキシ経由では OpenCodex には通常の HTTP リクエストとして見えるため、そのアドレスでは記憶を拒否します。サインイン画面がその旨を表示してオプションをオフにし、記憶なしのサインインは引き続き使えます。 非公開の dashboard-sessions.json は Cookie・証明・管理者認証情報・ブラウザー識別のハッシュ、サーバーのオリジンと固定30日間の有効期限を保存します。Safari ホーム画面アプリを再起動すると、有効なデバイス記録から短期セッションを再開します。記憶したセッションでは、GitHub スター、リセットクレジットの消費、プロンプト層の編集、Meta Muse サインイン、デスクトップ互換性の変更、クォータ検証などの同意操作を承認できません。これらには新しいローカルまたはペアリング済みセッションを使ってください。中継・別オリジン・リモートの平文 HTTP は記憶機能に対応しません。期限切れ、ブラウザー識別の変更、管理者トークン変更後のサーバー再起動では再ログインが必要です。 diff --git a/docs-site/src/content/docs/ko/guides/web-dashboard.md b/docs-site/src/content/docs/ko/guides/web-dashboard.md index a96306024ca..0c65fc98c23 100644 --- a/docs-site/src/content/docs/ko/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ko/guides/web-dashboard.md @@ -27,7 +27,7 @@ bun run dev:gui 원격 대시보드는 표준 비밀번호 폼을 표시하므로 브라우저 비밀번호 관리자가 토큰 저장과 자동 완성을 제안할 수 있습니다. 대시보드 자체는 토큰을 메모리에만 보관하며 `localStorage`나 `sessionStorage`에 쓰지 않습니다. 저장 여부는 전적으로 브라우저 또는 비밀번호 관리자가 결정합니다. -**이 기기 기억하기**는 기본적으로 꺼져 있습니다. 직접 HTTPS 대시보드(또는 로컬 루프백)에서 관리자 토큰을 서버 발급 HttpOnly, SameSite=Strict 기기 쿠키와 이 사이트의 localStorage에 저장하는 별도의 기기 증명으로 교환합니다. HTTPS 쿠키는 Secure이며 해당 호스트에만 적용됩니다. 쿠키와 증명은 모두 관리자 토큰이 아니며, 어느 쪽도 단독으로 작동하지 않습니다. 같은 호스트명의 여러 포트는 쿠키를 공유하므로, 오리진별 저장소의 증명이 기억한 기기를 이 대시보드 오리진에 묶습니다. 그래도 전용 호스트명을 권장합니다. 관리자 토큰은 localStorage나 sessionStorage에 저장하지 않습니다. 브라우저 저장소를 사용할 수 없거나 증명 저장에 실패하면 기억하기는 사용 불가로 표시됩니다. +**이 기기 기억하기**는 기본적으로 꺼져 있습니다. 직접 HTTPS 대시보드(또는 로컬 루프백)에서 관리자 토큰을 서버 발급 HttpOnly, SameSite=Strict 기기 쿠키와 이 사이트의 localStorage에 저장하는 별도의 기기 증명으로 교환합니다. HTTPS 쿠키는 Secure이며 해당 호스트에만 적용됩니다. 쿠키와 증명은 모두 관리자 토큰이 아니며, 어느 쪽도 단독으로 작동하지 않습니다. 같은 호스트명의 여러 포트는 쿠키를 공유하므로, 오리진별 저장소의 증명이 기억한 기기를 이 대시보드 오리진에 묶습니다. 그래도 전용 호스트명을 권장합니다. 관리자 토큰은 localStorage나 sessionStorage에 저장하지 않습니다. 브라우저 저장소를 사용할 수 없거나 증명 저장에 실패하면 기억하기는 사용 불가로 표시됩니다. TLS를 종료하는 리버스 프록시 뒤에서는 OpenCodex가 일반 HTTP 요청으로 보기 때문에 해당 주소에서는 기억하기를 거부합니다. 로그인 창이 이를 안내하고 옵션을 끄며, 기억하기 없이 로그인하는 것은 계속 가능합니다. 비공개 dashboard-sessions.json에는 쿠키·증명·관리자 인증 정보·브라우저 식별 해시, 서버 오리진과 고정 30일 만료를 저장합니다. Safari 홈 화면 앱을 다시 열면 유효한 기기 기록으로 단기 세션을 재개합니다. 기억한 세션으로는 GitHub 스타, 리셋 크레딧 소비, 프롬프트 계층 편집, Meta Muse 로그인, 데스크톱 호환성 변경, 할당량 검증에 동의할 수 없습니다. 이 작업에는 새 로컬 세션이나 페어링한 세션을 사용하세요. 릴레이·다른 오리진·원격 평문 HTTP에서는 기억하기를 지원하지 않습니다. 만료, 브라우저 식별 변경, 관리자 토큰 교체 후 서버 재시작 시 다시 로그인해야 합니다. diff --git a/docs-site/src/content/docs/ru/guides/web-dashboard.md b/docs-site/src/content/docs/ru/guides/web-dashboard.md index 284f65a5ecd..a0b34524047 100644 --- a/docs-site/src/content/docs/ru/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ru/guides/web-dashboard.md @@ -30,7 +30,7 @@ bun run dev:gui Удалённый дашборд показывает стандартную форму пароля, поэтому менеджер паролей браузера может предложить сохранить и автозаполнять токен. Сам дашборд хранит токен только в памяти и не записывает его в `localStorage` или `sessionStorage`; решение о сохранении полностью остаётся за браузером или менеджером паролей. -**Запомнить это устройство** по умолчанию выключено. При прямом HTTPS-доступе (или локальном loopback) сервер обменивает токен администратора на cookie устройства HttpOnly, SameSite=Strict и отдельное доказательство устройства в localStorage этого сайта. В HTTPS cookie имеет Secure и привязку к хосту. Ни cookie, ни доказательство не являются токеном администратора и не работают по отдельности. Cookie общие для портов одного имени хоста; доказательство в хранилище конкретного origin привязывает запомненное устройство к origin этой панели. Всё же предпочтительно отдельное имя хоста. Токен администратора не записывается в localStorage или sessionStorage. Если хранилище браузера недоступно или доказательство нельзя сохранить, запоминание сообщает о недоступности. +**Запомнить это устройство** по умолчанию выключено. При прямом HTTPS-доступе (или локальном loopback) сервер обменивает токен администратора на cookie устройства HttpOnly, SameSite=Strict и отдельное доказательство устройства в localStorage этого сайта. В HTTPS cookie имеет Secure и привязку к хосту. Ни cookie, ни доказательство не являются токеном администратора и не работают по отдельности. Cookie общие для портов одного имени хоста; доказательство в хранилище конкретного origin привязывает запомненное устройство к origin этой панели. Всё же предпочтительно отдельное имя хоста. Токен администратора не записывается в localStorage или sessionStorage. Если хранилище браузера недоступно или доказательство нельзя сохранить, запоминание сообщает о недоступности. За обратным прокси, который завершает TLS, OpenCodex видит обычный HTTP-запрос и поэтому отказывает в запоминании для этого адреса; окно входа сообщает об этом и отключает опцию, а вход без запоминания по-прежнему работает. Приватный dashboard-sessions.json хранит хеши cookie, доказательства, учётных данных администратора и идентичности браузера, origin сервера и фиксированный срок 30 дней. Повторный запуск приложения Safari возобновляет короткую сессию, пока запись действительна. Запомненные сессии не могут подтверждать действия, требующие согласия: звезду GitHub, расход кредитов сброса, изменение слоёв промпта, вход Meta Muse, изменения совместимости настольного приложения или проверку квот. Для них используйте новую локальную или сопряжённую сессию. Релейный доступ, доступ с другого origin и удалённый HTTP без шифрования не поддерживают запоминание. Истечение срока, смена идентичности браузера или смена токена с перезапуском сервера требуют нового входа. diff --git a/docs-site/src/content/docs/tr/guides/web-dashboard.md b/docs-site/src/content/docs/tr/guides/web-dashboard.md index e366cd8fa5f..a0ba1f0d84f 100644 --- a/docs-site/src/content/docs/tr/guides/web-dashboard.md +++ b/docs-site/src/content/docs/tr/guides/web-dashboard.md @@ -41,7 +41,7 @@ yalnızca bellekte tutar ve `localStorage` veya `sessionStorage`'a yazmaz; kaydedilip kaydedilmeyeceği tamamen tarayıcının veya şifre yöneticisinin kararıdır. -**Bu cihazı hatırla** varsayılan olarak kapalıdır. Doğrudan HTTPS panelinde (veya yerel loopback üzerinden) yönetici belirteci, sunucunun verdiği HttpOnly, SameSite=Strict cihaz çerezi ve bu sitenin localStorage alanında saklanan ayrı bir cihaz kanıtıyla değiştirilir. HTTPS çerezi Secure ve ana makineyle sınırlıdır. Çerez de kanıt da yönetici belirteci değildir; hiçbiri tek başına çalışmaz. Aynı ana makine adının portları çerezleri paylaşır; origin kapsamındaki kanıt, hatırlanan cihazı bu panelin origin’ine bağlar. Yine de ayrı bir ana makine adı tercih edin. Yönetici belirteci localStorage veya sessionStorage içine yazılmaz. Tarayıcı depolaması kullanılamıyorsa veya kanıt kaydedilemiyorsa Hatırla işlemi kullanılamadığını bildirir. +**Bu cihazı hatırla** varsayılan olarak kapalıdır. Doğrudan HTTPS panelinde (veya yerel loopback üzerinden) yönetici belirteci, sunucunun verdiği HttpOnly, SameSite=Strict cihaz çerezi ve bu sitenin localStorage alanında saklanan ayrı bir cihaz kanıtıyla değiştirilir. HTTPS çerezi Secure ve ana makineyle sınırlıdır. Çerez de kanıt da yönetici belirteci değildir; hiçbiri tek başına çalışmaz. Aynı ana makine adının portları çerezleri paylaşır; origin kapsamındaki kanıt, hatırlanan cihazı bu panelin origin’ine bağlar. Yine de ayrı bir ana makine adı tercih edin. Yönetici belirteci localStorage veya sessionStorage içine yazılmaz. Tarayıcı depolaması kullanılamıyorsa veya kanıt kaydedilemiyorsa Hatırla işlemi kullanılamadığını bildirir. TLS'i sonlandıran bir ters proxy arkasında OpenCodex düz bir HTTP isteği görür ve bu adres için Hatırla'yı reddeder; oturum açma penceresi bunu açıklar ve seçeneği kapatır, Hatırla olmadan oturum açmak çalışmaya devam eder. Özel dashboard-sessions.json çerezin, kanıtın, yönetici kimlik bilgisinin ve tarayıcı kimliğinin özetlerini, sunucu origin’ini ve sabit 30 günlük süreyi saklar. Safari ana ekran uygulaması yeniden açılınca geçerli cihaz kaydı kısa bir oturumu yeniler. Hatırlanan oturumlar onay gerektiren işlemleri yetkilendiremez: GitHub yıldızı, sıfırlama kredisi tüketimi, istem katmanı düzenlemeleri, Meta Muse girişi, masaüstü uyumluluk değişiklikleri veya kota doğrulaması. Bunlar için yeni bir yerel veya eşleştirilmiş oturum kullanın. Relay, başka origin ve uzak düz HTTP Hatırla seçeneğini desteklemez. Süre dolması, tarayıcı kimliği değişmesi veya belirtecin değiştirilip sunucunun yeniden başlatılması yeniden giriş gerektirir. diff --git a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md index 9767c159b8d..091073fa25d 100644 --- a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md +++ b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md @@ -26,7 +26,7 @@ bun run dev:gui 远程仪表盘会显示标准密码表单,浏览器密码管理器可以提示保存并自动填充 token。仪表盘本身只在内存中保存 token,不会写入 `localStorage` 或 `sessionStorage`;是否持久保存完全由浏览器或密码管理器决定。 -**记住此设备** 默认关闭。直接 HTTPS 仪表板(或本地回环)会将管理员令牌交换为服务器签发的 HttpOnly、SameSite=Strict 设备 Cookie,以及单独存入本站 localStorage 的设备证明。HTTPS 下 Cookie 启用 Secure 且仅限本主机。Cookie 和证明都不是管理员令牌,且都不能单独使用。同一主机名的不同端口共享 Cookie;按来源隔离的证明将记住的设备绑定到本仪表板来源。仍建议使用专用主机名。管理员令牌不存入 localStorage 或 sessionStorage。浏览器存储不可用或无法保存证明时,记住设备会报告不可用。 +**记住此设备** 默认关闭。直接 HTTPS 仪表板(或本地回环)会将管理员令牌交换为服务器签发的 HttpOnly、SameSite=Strict 设备 Cookie,以及单独存入本站 localStorage 的设备证明。HTTPS 下 Cookie 启用 Secure 且仅限本主机。Cookie 和证明都不是管理员令牌,且都不能单独使用。同一主机名的不同端口共享 Cookie;按来源隔离的证明将记住的设备绑定到本仪表板来源。仍建议使用专用主机名。管理员令牌不存入 localStorage 或 sessionStorage。浏览器存储不可用或无法保存证明时,记住设备会报告不可用。在终止 TLS 的反向代理之后,OpenCodex 看到的是普通 HTTP 请求,因此会拒绝为该地址记住设备;登录对话框会说明原因并关闭该选项,不勾选记住设备仍可正常登录。 私有 dashboard-sessions.json 保存 Cookie、证明、管理员凭据和浏览器标识的哈希、服务器来源及固定30天有效期。重新打开 Safari 主屏幕应用时,有效设备记录可恢复短期会话。记住的会话不能批准需要同意的操作:GitHub 星标、重置额度消费、提示词层编辑、Meta Muse 登录、桌面兼容性更改或配额验证。这些操作请使用新的本地会话或已配对会话。中继、跨来源和远程明文 HTTP 不支持记住设备。过期、浏览器标识改变,或更换管理员令牌并重启服务器后需重新登录。 diff --git a/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md b/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md index f175c90e21e..64b8349176b 100644 --- a/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md +++ b/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md @@ -31,7 +31,7 @@ GUI session 簽發到服務的頁面中,並在到期或代理重啟時靜默 儀表板本身仍然只在記憶體中保留 token,不會寫入 `localStorage` 或 `sessionStorage`;是否儲存完全 由瀏覽器或密碼管理員決定。 -**記住此裝置** 預設關閉。直接 HTTPS 儀表板(或本機回環)會將管理員權杖交換為伺服器簽發的 HttpOnly、SameSite=Strict 裝置 Cookie,以及另外存入本站 localStorage 的裝置證明。HTTPS 下 Cookie 使用 Secure,且僅限本主機。Cookie 和證明都不是管理員權杖,且都不能單獨使用。同一主機名稱的不同連接埠共用 Cookie;依來源隔離的證明將記住的裝置綁定至本儀表板來源。仍建議使用專用主機名稱。管理員權杖不存入 localStorage 或 sessionStorage。瀏覽器儲存空間無法使用或無法儲存證明時,記住裝置會回報無法使用。 +**記住此裝置** 預設關閉。直接 HTTPS 儀表板(或本機回環)會將管理員權杖交換為伺服器簽發的 HttpOnly、SameSite=Strict 裝置 Cookie,以及另外存入本站 localStorage 的裝置證明。HTTPS 下 Cookie 使用 Secure,且僅限本主機。Cookie 和證明都不是管理員權杖,且都不能單獨使用。同一主機名稱的不同連接埠共用 Cookie;依來源隔離的證明將記住的裝置綁定至本儀表板來源。仍建議使用專用主機名稱。管理員權杖不存入 localStorage 或 sessionStorage。瀏覽器儲存空間無法使用或無法儲存證明時,記住裝置會回報無法使用。在終止 TLS 的反向代理後方,OpenCodex 看到的是一般 HTTP 請求,因此會拒絕為該位址記住裝置;登入對話框會說明原因並關閉該選項,不勾選記住裝置仍可正常登入。 私有 dashboard-sessions.json 儲存 Cookie、證明、管理員憑證和瀏覽器識別的雜湊、伺服器來源及固定30天有效期。重新開啟 Safari 主畫面應用程式時,有效裝置記錄可恢復短期工作階段。記住的工作階段無法核准需要同意的操作:GitHub 星號、重設額度消耗、提示詞層編輯、Meta Muse 登入、桌面相容性變更或配額驗證。這些操作請使用新的本機工作階段或已配對工作階段。中繼、跨來源和遠端明文 HTTP 不支援記住裝置。過期、瀏覽器識別變更,或更換管理員權杖並重新啟動伺服器後需重新登入。 diff --git a/gui/tests/persistent-dashboard.test.ts b/gui/tests/persistent-dashboard.test.ts index e3de456cd8c..aa00808dce2 100644 --- a/gui/tests/persistent-dashboard.test.ts +++ b/gui/tests/persistent-dashboard.test.ts @@ -116,10 +116,6 @@ test("a hung Remember response body is aborted and cannot install a session", as expect(attempts).toBe(1); expect(verdict).toBe("unavailable"); expect(signal?.aborted).toBe(true); expect(hasApiSession("shared")).toBe(false); }); -function sessionHtml(): string { - return sessionHtmlFixture(); -} - for (const [label, body, expected] of [ ["the server's direct-origin refusal", { error: "Remember requires a direct HTTPS dashboard (or loopback) and matching Origin", code: "remember_requires_direct_origin" }, "remember-unsupported"], ["an uncoded 403", { error: "Admin token required" }, "unavailable"], @@ -137,7 +133,7 @@ for (const [label, body, expected] of [ }); } -function sessionHtmlFixture(): string { +function sessionHtml(): string { return ` From c8397a0206f7026008790b8b90b8ee798cf0acba Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 9 Nov 2026 04:03:20 +0900 Subject: [PATCH 3/4] fix(server): name the Remember refusal at the management Origin gate too --- gui/src/admin-token-dialog.ts | 13 ++++++++----- gui/src/i18n/de.ts | 2 +- gui/src/i18n/en.ts | 2 +- gui/src/i18n/fr.ts | 2 +- gui/src/i18n/ja.ts | 2 +- gui/src/i18n/ko.ts | 2 +- gui/src/i18n/pt.ts | 2 +- gui/src/i18n/ru.ts | 2 +- gui/src/i18n/tr.ts | 2 +- gui/src/i18n/vi.ts | 2 +- gui/src/i18n/zh-TW.ts | 2 +- gui/src/i18n/zh.ts | 2 +- gui/tests/admin-token-dialog.test.ts | 3 ++- src/server/management-api.ts | 4 +++- structure/gui-and-management-api.md | 2 +- tests/server/persistent-dashboard-admission.test.ts | 7 +++++++ 16 files changed, 32 insertions(+), 19 deletions(-) diff --git a/gui/src/admin-token-dialog.ts b/gui/src/admin-token-dialog.ts index ecd0cc5eafe..824018660f3 100644 --- a/gui/src/admin-token-dialog.ts +++ b/gui/src/admin-token-dialog.ts @@ -165,21 +165,24 @@ export function promptForAdminToken( finish(token); return; } - if (result === "remember-unsupported") { - // The server refused Remember for this address; signing in without it still works. + const rememberRefused = result === "remember-unsupported"; + if (rememberRefused) { + // The server refused Remember for this address; keep the typed token so signing in + // without it is one more submit. canRemember = false; remember.checked = false; + } else { + password.value = ""; } - password.value = ""; password.disabled = false; submit.disabled = false; remember.disabled = !canRemember; setValidationError(result === "rejected" ? messages["auth.adminTokenRejected"] - : result === "remember-unsupported" + : rememberRefused ? messages["auth.rememberUnsupported"] : messages["auth.adminTokenUnavailable"]); - password.focus(); + (rememberRefused ? submit : password).focus(); }).catch(() => { if (settled) return; password.value = ""; diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 78ccbfe5e70..78c029bba4f 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -426,7 +426,7 @@ export const de: Record = { "auth.adminTokenHelp": "Dies ist der Admin-Token der OpenCodex-Verwaltungs-API, kein Anbieter-API-Schlüssel. Beim ersten Start schreibt der Proxy ihn nach ~/.opencodex/admin-api-token (oder $OPENCODEX_HOME/admin-api-token); OPENCODEX_ADMIN_AUTH_TOKEN hat Vorrang.", "auth.rememberDevice": "Dieses Gerät merken", "auth.rememberDeviceHint": "Eine widerrufbare HttpOnly-Dashboard-Sitzung 30 Tage behalten, nicht das Admin-Token. Erfordert ein direktes HTTPS-Dashboard mit derselben Origin wie die API (oder lokalen Loopback).", - "auth.rememberUnsupported": "OpenCodex hat den Admin-Token akzeptiert, kann dieses Gerät unter dieser Adresse aber nicht merken. Merken erfordert ein direktes HTTPS-Dashboard (oder lokalen Loopback); hinter einem Reverse Proxy melden Sie sich ohne diese Option an.", + "auth.rememberUnsupported": "OpenCodex kann dieses Gerät unter dieser Adresse nicht merken. Merken erfordert ein direktes HTTPS-Dashboard (oder lokalen Loopback); hinter einem Reverse Proxy melden Sie sich ohne diese Option an.", "connection.forgetDevice": "Dieses Gerät vergessen", "connection.forgetDeviceFailed": "Dieses Gerät konnte nicht vergessen werden. Die Sitzung wurde beibehalten.", "auth.adminTokenDocsLink": "So finden Sie ihn", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index a303a2f399a..6fa11a26f50 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -361,7 +361,7 @@ export const en = { "auth.adminTokenHelp": "This is the OpenCodex management admin token, not a provider API key. The proxy writes it to ~/.opencodex/admin-api-token (or $OPENCODEX_HOME/admin-api-token) on first start, and OPENCODEX_ADMIN_AUTH_TOKEN overrides it.", "auth.rememberDevice": "Remember this device", "auth.rememberDeviceHint": "Keep a revocable HttpOnly dashboard session for 30 days, not the admin token. Requires a direct HTTPS dashboard that is same-origin with the API (or local loopback).", - "auth.rememberUnsupported": "OpenCodex accepted the admin token but cannot remember this device at this address. Remember needs a direct HTTPS dashboard (or local loopback); behind a reverse proxy, sign in without it.", + "auth.rememberUnsupported": "OpenCodex cannot remember this device at this address. Remember needs a direct HTTPS dashboard (or local loopback); behind a reverse proxy, sign in without it.", "connection.forgetDevice": "Forget this device", "connection.forgetDeviceFailed": "Could not forget this device. The session was kept.", "auth.adminTokenDocsLink": "How to find it", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 644f5ee8707..37191a7b98f 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -358,7 +358,7 @@ export const fr: Record = { "auth.adminTokenHelp": "Il s’agit du jeton d’administration de l’API de gestion OpenCodex, pas d’une clé API de fournisseur. Au premier démarrage, le proxy l’écrit dans ~/.opencodex/admin-api-token (ou $OPENCODEX_HOME/admin-api-token), et OPENCODEX_ADMIN_AUTH_TOKEN a la priorité.", "auth.rememberDevice": "Mémoriser cet appareil", "auth.rememberDeviceHint": "Conserve une session HttpOnly révocable pendant 30 jours, pas le jeton administrateur. Nécessite un tableau de bord HTTPS direct de même origine que l'API (ou une boucle locale).", - "auth.rememberUnsupported": "OpenCodex a accepté le jeton d’administration, mais ne peut pas mémoriser cet appareil à cette adresse. La mémorisation exige un tableau de bord HTTPS direct (ou une boucle locale) ; derrière un proxy inverse, connectez-vous sans cette option.", + "auth.rememberUnsupported": "OpenCodex ne peut pas mémoriser cet appareil à cette adresse. La mémorisation exige un tableau de bord HTTPS direct (ou une boucle locale) ; derrière un proxy inverse, connectez-vous sans cette option.", "connection.forgetDevice": "Oublier cet appareil", "connection.forgetDeviceFailed": "Impossible d’oublier cet appareil. La session a été conservée.", "auth.adminTokenDocsLink": "Où le trouver", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index a48d7d562a2..5f1be4e8afd 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -428,7 +428,7 @@ export const ja: Record = { "auth.adminTokenHelp": "これは OpenCodex 管理 API の管理者トークンで、プロバイダーの API キーではありません。プロキシは初回起動時に ~/.opencodex/admin-api-token(または $OPENCODEX_HOME/admin-api-token)へ書き込み、OPENCODEX_ADMIN_AUTH_TOKEN を設定するとそちらが優先されます。", "auth.rememberDevice": "このデバイスを記憶する", "auth.rememberDeviceHint": "管理者トークンではなく、取り消し可能な HttpOnly セッションを30日間保持します。API と同一オリジンの HTTPS ダッシュボードへの直接アクセス(またはローカルループバック)が必要です。", - "auth.rememberUnsupported": "OpenCodex は管理者トークンを受け付けましたが、このアドレスではこのデバイスを記憶できません。記憶には HTTPS ダッシュボードへの直接アクセス(またはローカルループバック)が必要です。リバースプロキシ経由の場合は、このオプションなしでサインインしてください。", + "auth.rememberUnsupported": "このアドレスでは OpenCodex はこのデバイスを記憶できません。記憶には HTTPS ダッシュボードへの直接アクセス(またはローカルループバック)が必要です。リバースプロキシ経由の場合は、このオプションなしでサインインしてください。", "connection.forgetDevice": "このデバイスを解除", "connection.forgetDeviceFailed": "デバイスを解除できませんでした。セッションは保持されています。", "auth.adminTokenDocsLink": "確認方法", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 6a278ff1399..bc118190596 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -426,7 +426,7 @@ export const ko: Record = { "auth.adminTokenHelp": "이 값은 OpenCodex 관리 API의 관리자 토큰이며 공급자 API 키가 아닙니다. 프록시가 처음 실행될 때 ~/.opencodex/admin-api-token(또는 $OPENCODEX_HOME/admin-api-token)에 기록하고, OPENCODEX_ADMIN_AUTH_TOKEN을 설정하면 그 값이 우선합니다.", "auth.rememberDevice": "이 기기 기억하기", "auth.rememberDeviceHint": "관리자 토큰이 아닌 취소 가능한 HttpOnly 세션을 30일 동안 유지합니다. API와 동일한 오리진의 HTTPS 대시보드에 직접 연결(또는 로컬 루프백)이 필요합니다.", - "auth.rememberUnsupported": "OpenCodex가 관리자 토큰을 확인했지만 이 주소에서는 이 기기를 기억할 수 없습니다. 기기 기억에는 HTTPS 대시보드 직접 연결(또는 로컬 루프백)이 필요합니다. 리버스 프록시 뒤에서는 이 옵션 없이 로그인하세요.", + "auth.rememberUnsupported": "이 주소에서는 OpenCodex가 이 기기를 기억할 수 없습니다. 기기 기억에는 HTTPS 대시보드 직접 연결(또는 로컬 루프백)이 필요합니다. 리버스 프록시 뒤에서는 이 옵션 없이 로그인하세요.", "connection.forgetDevice": "이 기기 잊기", "connection.forgetDeviceFailed": "이 기기를 해제하지 못했습니다. 세션은 유지됩니다.", "auth.adminTokenDocsLink": "찾는 방법", diff --git a/gui/src/i18n/pt.ts b/gui/src/i18n/pt.ts index 0448a67d1b4..4657ba62fab 100644 --- a/gui/src/i18n/pt.ts +++ b/gui/src/i18n/pt.ts @@ -383,7 +383,7 @@ export const pt: Record = { "auth.adminTokenHelp": "Este é o token de administrador de gerenciamento do OpenCodex, não uma chave de API de provedor. O proxy o grava em ~/.opencodex/admin-api-token (ou $OPENCODEX_HOME/admin-api-token) na primeira inicialização, e OPENCODEX_ADMIN_AUTH_TOKEN o substitui.", "auth.rememberDevice": "Lembrar este dispositivo", "auth.rememberDeviceHint": "Mantém uma sessão HttpOnly revogável por 30 dias, não o token de administrador. Requer um painel HTTPS direto de mesma origem que a API (ou loopback local).", - "auth.rememberUnsupported": "O OpenCodex aceitou o token de administrador, mas não pode lembrar este dispositivo neste endereço. Lembrar requer um painel HTTPS direto (ou loopback local); atrás de um proxy reverso, entre sem esta opção.", + "auth.rememberUnsupported": "O OpenCodex não pode lembrar este dispositivo neste endereço. Lembrar requer um painel HTTPS direto (ou loopback local); atrás de um proxy reverso, entre sem esta opção.", "connection.forgetDevice": "Esquecer este dispositivo", "connection.forgetDeviceFailed": "Não foi possível esquecer este dispositivo. A sessão foi mantida.", "auth.adminTokenDocsLink": "Como encontrá-lo", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 687960ae442..ae0973335a0 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -428,7 +428,7 @@ export const ru: Record = { "auth.adminTokenHelp": "Это административный токен управляющего API OpenCodex, а не ключ API провайдера. При первом запуске прокси записывает его в ~/.opencodex/admin-api-token (или $OPENCODEX_HOME/admin-api-token), а OPENCODEX_ADMIN_AUTH_TOKEN переопределяет это значение.", "auth.rememberDevice": "Запомнить это устройство", "auth.rememberDeviceHint": "Сохраняет отзываемую HttpOnly-сессию на 30 дней, а не токен администратора. Требуется прямой доступ к панели по HTTPS с тем же источником, что и API (или локальный loopback).", - "auth.rememberUnsupported": "OpenCodex принял токен администратора, но не может запомнить это устройство по этому адресу. Для запоминания нужен прямой доступ к панели по HTTPS (или локальный loopback); за обратным прокси входите без этой опции.", + "auth.rememberUnsupported": "OpenCodex не может запомнить это устройство по этому адресу. Для запоминания нужен прямой доступ к панели по HTTPS (или локальный loopback); за обратным прокси входите без этой опции.", "connection.forgetDevice": "Забыть это устройство", "connection.forgetDeviceFailed": "Не удалось забыть это устройство. Сессия сохранена.", "auth.adminTokenDocsLink": "Как его найти", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 8edaa8874ac..5d4afa1c925 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -360,7 +360,7 @@ export const tr: Record = { "auth.adminTokenHelp": "Bu, sağlayıcı API anahtarı değil, OpenCodex yönetim API’sinin yönetici jetonudur. Proxy ilk açılışta bunu ~/.opencodex/admin-api-token (veya $OPENCODEX_HOME/admin-api-token) dosyasına yazar; OPENCODEX_ADMIN_AUTH_TOKEN bu değeri geçersiz kılar.", "auth.rememberDevice": "Bu cihazı hatırla", "auth.rememberDeviceHint": "Yönetici belirteci yerine iptal edilebilir HttpOnly oturumunu 30 gün tutar. API ile aynı origin'de doğrudan bir HTTPS paneli (veya yerel loopback) gerekir.", - "auth.rememberUnsupported": "OpenCodex yönetici jetonunu kabul etti ancak bu adreste bu cihazı hatırlayamıyor. Hatırlama, doğrudan bir HTTPS paneli (veya yerel loopback) gerektirir; ters proxy arkasında bu seçenek olmadan oturum açın.", + "auth.rememberUnsupported": "OpenCodex bu adreste bu cihazı hatırlayamıyor. Hatırlama, doğrudan bir HTTPS paneli (veya yerel loopback) gerektirir; ters proxy arkasında bu seçenek olmadan oturum açın.", "connection.forgetDevice": "Bu cihazı unut", "connection.forgetDeviceFailed": "Bu cihaz unutulamadı. Oturum korundu.", "auth.adminTokenDocsLink": "Nasıl bulunur", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 4a9cde32777..a3d4a07f8be 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -358,7 +358,7 @@ export const vi: Record = { "auth.adminTokenHelp": "Đây là token quản trị quản lý OpenCodex, không phải là API key của provider. Proxy ghi nó vào ~/.opencodex/admin-api-token (hoặc $OPENCODEX_HOME/admin-api-token) trong lần khởi động đầu tiên và OPENCODEX_ADMIN_AUTH_TOKEN sẽ ghi đè nó.", "auth.rememberDevice": "Ghi nhớ thiết bị này", "auth.rememberDeviceHint": "Giữ phiên HttpOnly có thể thu hồi trong 30 ngày, không lưu mã quản trị. Yêu cầu bảng điều khiển HTTPS trực tiếp cùng nguồn gốc với API (hoặc loopback cục bộ).", - "auth.rememberUnsupported": "OpenCodex đã chấp nhận token quản trị nhưng không thể ghi nhớ thiết bị này tại địa chỉ này. Ghi nhớ yêu cầu bảng điều khiển HTTPS trực tiếp (hoặc loopback cục bộ); phía sau reverse proxy, hãy đăng nhập mà không chọn tùy chọn này.", + "auth.rememberUnsupported": "OpenCodex không thể ghi nhớ thiết bị này tại địa chỉ này. Ghi nhớ yêu cầu bảng điều khiển HTTPS trực tiếp (hoặc loopback cục bộ); phía sau reverse proxy, hãy đăng nhập mà không chọn tùy chọn này.", "connection.forgetDevice": "Quên thiết bị này", "connection.forgetDeviceFailed": "Không thể quên thiết bị này. Phiên vẫn được giữ.", "auth.adminTokenDocsLink": "Cách tìm nó", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 153e3d67828..dad97b42417 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -3088,7 +3088,7 @@ export const zhTW: Record = { "auth.adminTokenHelp": "這是 OpenCodex 管理 API 的管理員金鑰,不是服務商 API 金鑰。代理首次啟動時會寫入 ~/.opencodex/admin-api-token(或 $OPENCODEX_HOME/admin-api-token),設定 OPENCODEX_ADMIN_AUTH_TOKEN 可覆寫該值。", "auth.rememberDevice": "記住此裝置", "auth.rememberDeviceHint": "保留可撤銷的 HttpOnly 工作階段30天,不儲存管理員權杖。需要直接存取與 API 同源的 HTTPS 儀表板(或本機回環)。", - "auth.rememberUnsupported": "OpenCodex 已接受管理員權杖,但無法在此位址記住此裝置。記住裝置需要直接存取 HTTPS 儀表板(或本機回環);在反向代理後方,請不勾選此項登入。", + "auth.rememberUnsupported": "OpenCodex 無法在此位址記住此裝置。記住裝置需要直接存取 HTTPS 儀表板(或本機回環);在反向代理後方,請不勾選此項登入。", "connection.forgetDevice": "忘記此裝置", "connection.forgetDeviceFailed": "無法忘記此裝置。工作階段已保留。", "auth.adminTokenDocsLink": "如何尋找", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index cbfa2547bdc..b85962505fe 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -426,7 +426,7 @@ export const zh: Record = { "auth.adminTokenHelp": "这是 OpenCodex 管理 API 的管理员令牌,不是服务商 API 密钥。代理首次启动时会写入 ~/.opencodex/admin-api-token(或 $OPENCODEX_HOME/admin-api-token),设置 OPENCODEX_ADMIN_AUTH_TOKEN 可覆盖该值。", "auth.rememberDevice": "记住此设备", "auth.rememberDeviceHint": "保留可撤销的 HttpOnly 会话30天,不保存管理员令牌。需要直接访问与 API 同源的 HTTPS 仪表板(或本地回环)。", - "auth.rememberUnsupported": "OpenCodex 已接受管理员令牌,但无法在此地址记住此设备。记住设备需要直接访问 HTTPS 仪表板(或本地回环);在反向代理后面,请不勾选此项登录。", + "auth.rememberUnsupported": "OpenCodex 无法在此地址记住此设备。记住设备需要直接访问 HTTPS 仪表板(或本地回环);在反向代理后面,请不勾选此项登录。", "connection.forgetDevice": "忘记此设备", "connection.forgetDeviceFailed": "无法忘记此设备。会话已保留。", "auth.adminTokenDocsLink": "如何查找", diff --git a/gui/tests/admin-token-dialog.test.ts b/gui/tests/admin-token-dialog.test.ts index 67c6d5e81de..da99fc2a95c 100644 --- a/gui/tests/admin-token-dialog.test.ts +++ b/gui/tests/admin-token-dialog.test.ts @@ -203,7 +203,8 @@ test("a Remember refusal explains the address and lets the user sign in without expect(remember.checked).toBe(false); expect(remember.disabled).toBe(true); expect(dialog.isConnected).toBe(true); - password.value = "valid-token"; + // The refusal is about the address, so the typed token stays for a one-submit retry. + expect(password.value).toBe("valid-token"); form.dispatchEvent(new testWindow.Event("submit", { bubbles: true, cancelable: true })); expect(await pending).toBe("valid-token"); expect(calls).toEqual([["valid-token", true], ["valid-token", false]]); diff --git a/src/server/management-api.ts b/src/server/management-api.ts index 58cb7d08c7a..4c6b5b01243 100644 --- a/src/server/management-api.ts +++ b/src/server/management-api.ts @@ -248,7 +248,9 @@ export async function handleManagementAPI( requestIngress: ManagementRequestIngress = { trustedLoopback: false }, ): Promise { if (!isAllowedManagementOrigin(req, config)) { - return jsonResponse({ error: "cross-origin request blocked" }, 403, req, config); + // A proxied dashboard reaches Remember through this gate first; name the reason (#8338). + const remember = url.pathname === "/api/session/remember" ? { code: "remember_requires_direct_origin" } : {}; + return jsonResponse({ error: "cross-origin request blocked", ...remember }, 403, req, config); } if (principal === "local-account-switch-capability") { if (req.method !== "PUT" || !isLocalAccountSwitchPath(url.pathname) || url.search !== "") { diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 8b9db2eb4a7..d4d32e281d4 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -119,7 +119,7 @@ OpenCodex uses three mutually exclusive reusable admission credential classes: The service token file remains a delivery mechanism for the data-plane environment token; it is not a fourth credential class. A management credential that equals any configured data-plane credential -does not enable management access. The data plane may continue to start, but `/api/*` remains closed. The opt-in bodyless `POST /api/session/remember` returns a short-lived GUI session plus a device proof for this site's origin-scoped localStorage alongside a host-only HttpOnly, SameSite=Strict device cookie (Secure on HTTPS); neither is the admin token or works alone, and because cookies span ports on one hostname, the proof binds resume to the dashboard origin (a dedicated hostname is still preferred). `src/server/persistent-gui-session.ts` owns the private hash-only `dashboard-sessions.json` store (128 devices/128 KiB), server-origin/browser/admin-credential binding and fixed 30-day expiry; enrollment requires direct same-origin HTTPS or loopback, browser-storage failure is reported unavailable, bootstrap alone sends the proof header, `remembered-admin` sessions never authorize pairing, data-key reveal or consent actions (GitHub star, reset-credit consumption, prompt-layer edits, Meta Muse sign-in, desktop compatibility changes and quota validation require a fresh local or paired session), and successful Forget/logout immediately revokes the record and derived sessions after content fsync and publication, removes the browser proof (failed logout retains it for retry), with directory-entry crash durability best effort and legacy bearer keys purged without reading or migration; tests: `tests/server/persistent-dashboard-session.test.ts`. +does not enable management access. The data plane may continue to start, but `/api/*` remains closed. The opt-in bodyless `POST /api/session/remember` returns a short-lived GUI session plus a device proof for this site's origin-scoped localStorage alongside a host-only HttpOnly, SameSite=Strict device cookie (Secure on HTTPS); neither is the admin token or works alone, and because cookies span ports on one hostname, the proof binds resume to the dashboard origin (a dedicated hostname is still preferred). `src/server/persistent-gui-session.ts` owns the private hash-only `dashboard-sessions.json` store (128 devices/128 KiB), server-origin/browser/admin-credential binding and fixed 30-day expiry; enrollment requires direct same-origin HTTPS or loopback (a refusal, including the management Origin gate on this path, carries `code: "remember_requires_direct_origin"`, which the sign-in dialog turns into an explanation, an unticked Remember and a kept token), browser-storage failure is reported unavailable, bootstrap alone sends the proof header, `remembered-admin` sessions never authorize pairing, data-key reveal or consent actions (GitHub star, reset-credit consumption, prompt-layer edits, Meta Muse sign-in, desktop compatibility changes and quota validation require a fresh local or paired session), and successful Forget/logout immediately revokes the record and derived sessions after content fsync and publication, removes the browser proof (failed logout retains it for retry), with directory-entry crash durability best effort and legacy bearer keys purged without reading or migration; tests: `tests/server/persistent-dashboard-session.test.ts`. CLI health collection follows the same boundary without transporting the reusable management credential. Its local-read HMAC capability is an additional single-use, route-scoped admission mechanism, not a reusable credential class. `ocx doctor` and OAuth health derive these capabilities diff --git a/tests/server/persistent-dashboard-admission.test.ts b/tests/server/persistent-dashboard-admission.test.ts index 0630a26f571..e554b3f125e 100644 --- a/tests/server/persistent-dashboard-admission.test.ts +++ b/tests/server/persistent-dashboard-admission.test.ts @@ -95,6 +95,13 @@ test("a Remember refusal for a proxied or plaintext origin carries a stable code const notAdmin = await dispatch(deviceRequest(device, "/api/session/remember")); expect(notAdmin.status).toBe(403); expect((await notAdmin.json()).code).toBeUndefined(); + // Behind a proxy without corsAllowOrigins the Origin gate answers first; it names the same reason. + const gated = await dispatch(request("/api/session/remember", { headers: { Origin: "https://other.test" } })); + expect(gated.status).toBe(403); + expect(await gated.json()).toMatchObject({ error: "cross-origin request blocked", code: "remember_requires_direct_origin" }); + const otherPath = await dispatch(request("/api/config", { method: "GET", headers: { Origin: "https://other.test" } })); + expect(otherPath.status).toBe(403); + expect((await otherPath.json()).code).toBeUndefined(); }); test("forwarded headers cannot upgrade plaintext or change the origin binding", async () => { From c77fbe42aea5cb8a8524937c6c89aac38a6a4015 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 9 Nov 2026 04:21:11 +0900 Subject: [PATCH 4/4] fix(server): reference the Remember path through its route constant at the Origin gate --- src/server/management-api.ts | 4 ++-- src/server/management/session-routes.ts | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/server/management-api.ts b/src/server/management-api.ts index 4c6b5b01243..dc206ce4e2e 100644 --- a/src/server/management-api.ts +++ b/src/server/management-api.ts @@ -94,7 +94,7 @@ import { CatalogGatherBusyError } from "../codex/catalog/provider-fetch"; import type { CatalogDisposition, ConvergeCodex } from "../codex/convergence-types"; import { normalizeCatalogDisposition } from "../codex/catalog-refresh-status"; import { managementBodyTooLargeResponse } from "./management/body"; -import { handleSessionRoutes } from "./management/session-routes"; +import { handleSessionRoutes, SESSION_REMEMBER_PATH } from "./management/session-routes"; import { siblingRefusesManagementRequest } from "./management/sibling-guard"; import { siblingOfLivePort, siblingSkipMessage } from "../codex/sibling-start"; import { packageVersion } from "../lib/package-version"; @@ -249,7 +249,7 @@ export async function handleManagementAPI( ): Promise { if (!isAllowedManagementOrigin(req, config)) { // A proxied dashboard reaches Remember through this gate first; name the reason (#8338). - const remember = url.pathname === "/api/session/remember" ? { code: "remember_requires_direct_origin" } : {}; + const remember = url.pathname === SESSION_REMEMBER_PATH ? { code: "remember_requires_direct_origin" } : {}; return jsonResponse({ error: "cross-origin request blocked", ...remember }, 403, req, config); } if (principal === "local-account-switch-capability") { diff --git a/src/server/management/session-routes.ts b/src/server/management/session-routes.ts index 4da3a000bde..e99bbafb1b9 100644 --- a/src/server/management/session-routes.ts +++ b/src/server/management/session-routes.ts @@ -1,6 +1,9 @@ import { jsonResponse } from "../auth-cors"; import type { ManagementContext } from "./context"; +/** The Remember enrollment route; the management Origin gate names its refusal reason (#8338). */ +export const SESSION_REMEMBER_PATH = "/api/session/remember"; + export function handleSessionRoutes(ctx: ManagementContext): Response | null { if (ctx.url.pathname === "/api/session/remember" && ctx.req.method === "POST") { if (ctx.principal !== "admin-token") return jsonResponse({ error: "Admin token required" }, 403, ctx.req, ctx.config);