From 8a45d952fd915a425fb42109a207d73e1aa4f802 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Wed, 7 Oct 2026 21:17:07 -0500 Subject: [PATCH] fix: trust devices by a server-issued token, not a client header (GHSA-6gr3-r7jx-wfmh) --- src/backend/auth/login-pipeline.ts | 75 ++++++++++++----- src/backend/tests/auth/auth-test-helpers.ts | 1 - src/backend/tests/auth/login-pipeline.test.ts | 61 ++++++++++++-- .../tests/utils/user-agent-parser.test.ts | 82 ------------------- src/backend/utils/cors-config.ts | 1 + src/backend/utils/user-agent-parser.ts | 20 ----- src/ui/api/auth-methods-api.ts | 6 +- src/ui/main-axios.ts | 15 ++++ 8 files changed, 130 insertions(+), 131 deletions(-) diff --git a/src/backend/auth/login-pipeline.ts b/src/backend/auth/login-pipeline.ts index 4dcde3e84..2d29a1351 100644 --- a/src/backend/auth/login-pipeline.ts +++ b/src/backend/auth/login-pipeline.ts @@ -14,11 +14,7 @@ import type { Request, Response } from "express"; import { AuthManager } from "../utils/auth-manager.js"; import { loginRateLimiter } from "../utils/login-rate-limiter.js"; import { authLogger } from "../utils/logger.js"; -import { - generateDeviceFingerprint, - getDeviceId, - parseUserAgent, -} from "../utils/user-agent-parser.js"; +import { parseUserAgent } from "../utils/user-agent-parser.js"; import { logAudit, getRequestMeta } from "../utils/audit-logger.js"; import { createCurrentUserAuthRepository, @@ -37,6 +33,7 @@ import { type SecondFactor, } from "./registry.js"; import { + isNativeAppRequest, issueSession, sendSession, syncSharedCredentialsForUserRoles, @@ -131,10 +128,33 @@ function shouldRunSecondFactors(methodId: string): boolean { return isSecondFactorAfterExternalLoginEnabled(); } +export const TRUST_DEVICE_COOKIE = "termix_trust_device"; +const TRUST_TOKEN_PATTERN = /^[a-f0-9]{64}$/; +const TRUST_TOKEN_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; + +/** + * The remember-this-device token the server handed out after a full login. + * Browsers carry it in an HttpOnly cookie; the desktop app, which talks to + * the server cross-origin without cookies, sends it back as a header. + */ +function readTrustToken(req: Request): string | null { + const cookies = (req as Request & { cookies?: Record }) + .cookies; + const value = + cookies?.[TRUST_DEVICE_COOKIE] || req.get("x-termix-trust-token"); + return typeof value === "string" && TRUST_TOKEN_PATTERN.test(value) + ? value + : null; +} + +export function hashTrustToken(token: string): string { + return crypto.createHash("sha256").update(`trust-v2|${token}`).digest("hex"); +} + async function isTrustedDevice(req: Request, userId: string): Promise { - const deviceInfo = parseUserAgent(req); - const fingerprint = generateDeviceFingerprint(deviceInfo, getDeviceId(req)); - if (!fingerprint) return false; + const token = readTrustToken(req); + if (!token) return false; + const fingerprint = hashTrustToken(token); const trusted = await AuthManager.getInstance().isTrustedDevice( userId, fingerprint, @@ -473,22 +493,29 @@ export async function verifySecondFactorAndRespond( ? req.body.rememberMe : (lookup.pending?.rememberMe ?? false); + let trustToken: string | null = null; if (rememberMe) { const deviceInfo = parseUserAgent(req); - const fingerprint = generateDeviceFingerprint(deviceInfo, getDeviceId(req)); - if (fingerprint) { - await AuthManager.getInstance().addTrustedDevice( - user.id, - fingerprint, - deviceInfo.type, - deviceInfo.deviceInfo, - ); - authLogger.info("Device automatically trusted via Remember Me", { - operation: "totp_auto_trust", - userId: user.id, - deviceType: deviceInfo.type, - }); - } + trustToken = crypto.randomBytes(32).toString("hex"); + await AuthManager.getInstance().addTrustedDevice( + user.id, + hashTrustToken(trustToken), + deviceInfo.type, + deviceInfo.deviceInfo, + ); + res.cookie( + TRUST_DEVICE_COOKIE, + trustToken, + AuthManager.getInstance().getSecureCookieOptions( + req, + TRUST_TOKEN_MAX_AGE_MS, + ), + ); + authLogger.info("Device automatically trusted via Remember Me", { + operation: "totp_auto_trust", + userId: user.id, + deviceType: deviceInfo.type, + }); } const pending = lookup.pending; @@ -498,6 +525,10 @@ export async function verifySecondFactorAndRespond( externalSession: pending?.externalSession ?? null, }); + if (trustToken && isNativeAppRequest(req)) { + session.body.trustToken = trustToken; + } + consumePendingLogin(lookup.token); res.clearCookie( PENDING_LOGIN_COOKIE, diff --git a/src/backend/tests/auth/auth-test-helpers.ts b/src/backend/tests/auth/auth-test-helpers.ts index 54d945ddb..ca2d34d38 100644 --- a/src/backend/tests/auth/auth-test-helpers.ts +++ b/src/backend/tests/auth/auth-test-helpers.ts @@ -235,7 +235,6 @@ export function fakeAuthManager(state: AuthState) { export function fakeRequest(overrides: Record = {}) { const headers: Record = { "user-agent": "Mozilla/5.0 (X11; Linux x86_64) Firefox/120.0", - "x-termix-device-id": "a".repeat(64), ...(overrides.headers as Record | undefined), }; return { diff --git a/src/backend/tests/auth/login-pipeline.test.ts b/src/backend/tests/auth/login-pipeline.test.ts index bfb221db3..55b1742cb 100644 --- a/src/backend/tests/auth/login-pipeline.test.ts +++ b/src/backend/tests/auth/login-pipeline.test.ts @@ -88,6 +88,8 @@ const { respondWithRedirectLogin, verifySecondFactorAndRespond, resetPendingLoginsForTests, + TRUST_DEVICE_COOKIE, + hashTrustToken, } = await import("../../auth/login-pipeline.js"); const { verifyPasswordLogin } = await import("../../auth/builtin-login-methods.js"); @@ -295,16 +297,46 @@ describe("second factors", () => { await verifySecondFactorAndRespond(req as never, res as never, "totp"); expect(res.statusCode).toBe(200); - expect(res.cookies[0]).toMatchObject({ name: "jwt" }); + const trust = res.cookies.find((c) => c.name === TRUST_DEVICE_COOKIE); + expect(trust?.value).toMatch(/^[a-f0-9]{64}$/); + expect(res.cookies.find((c) => c.name === "jwt")).toBeDefined(); expect(res.body).toMatchObject({ success: true, username: "alice", totp_enabled: true, }); - expect(h.state.trustedAdded).toHaveLength(1); + expect(res.body).not.toHaveProperty("trustToken"); + expect(h.state.trustedAdded).toEqual([ + `u1:${hashTrustToken(trust!.value)}`, + ]); expect(h.state.audits.at(-1)).toMatchObject({ action: "login" }); }); + it("hands the trust token to the desktop app in the body", async () => { + enrolTotp(); + const first = await passwordLogin({ + username: "alice", + password: PASSWORD, + }); + const res = fakeResponse(); + await verifySecondFactorAndRespond( + fakeRequest({ + headers: { "x-electron-app": "true" }, + body: { + temp_token: (first.body as { temp_token: string }).temp_token, + totp_code: TOTP_CODE, + rememberMe: true, + }, + }) as never, + res as never, + "totp", + ); + expect(res.statusCode).toBe(200); + expect((res.body as { trustToken: string }).trustToken).toMatch( + /^[a-f0-9]{64}$/, + ); + }); + it("answers the 2.8 route with the user's first factor when none is named", async () => { enrolTotp(); const first = await passwordLogin({ @@ -369,13 +401,32 @@ describe("second factors", () => { expect(res.cookies).toEqual([]); }); - it("skips the factor on a trusted device", async () => { + it("skips the factor with a server-issued trust cookie", async () => { enrolTotp(); - h.manager.isTrustedDevice.mockResolvedValueOnce(true); - const res = await passwordLogin({ username: "alice", password: PASSWORD }); + const token = "c".repeat(64); + h.state.trusted.add(`u1:${hashTrustToken(token)}`); + const req = fakeRequest({ + body: { username: "alice", password: PASSWORD }, + cookies: { [TRUST_DEVICE_COOKIE]: token }, + }); + const res = fakeResponse(); + const identity = await verifyPasswordLogin(req as never); + await respondWithLogin(req as never, res as never, identity, { + methodId: "password", + rememberMe: false, + }); expect(res.cookies[0]).toMatchObject({ name: "jwt" }); }); + it("never skips the factor on a client-made device id", async () => { + enrolTotp(); + const deviceId = "a".repeat(64); + h.state.trusted.add(`u1:${deviceId}`); + const res = await passwordLogin({ username: "alice", password: PASSWORD }); + expect(res.body).toMatchObject({ requires_totp: true }); + expect(h.manager.isTrustedDevice).not.toHaveBeenCalled(); + }); + it("fails closed when an enrolled factor's plugin is gone", async () => { addUser(); h.state.factors.push({ diff --git a/src/backend/tests/utils/user-agent-parser.test.ts b/src/backend/tests/utils/user-agent-parser.test.ts index 0121193ca..f2b504fc0 100644 --- a/src/backend/tests/utils/user-agent-parser.test.ts +++ b/src/backend/tests/utils/user-agent-parser.test.ts @@ -3,8 +3,6 @@ import type { Request } from "express"; import { detectPlatform, parseUserAgent, - generateDeviceFingerprint, - getDeviceId, } from "../../utils/user-agent-parser.js"; function reqWith(headers: Record): Request { @@ -97,83 +95,3 @@ describe("parseUserAgent", () => { expect(info.os).toContain("iOS"); }); }); - -describe("generateDeviceFingerprint", () => { - it("is stable for the same client device id", () => { - const a = generateDeviceFingerprint( - { - type: "web", - browser: "Chrome", - version: "120.5", - os: "Windows 10/11", - deviceInfo: "Chrome 120.5 on Windows 10/11", - }, - "a".repeat(64), - ); - const b = generateDeviceFingerprint( - { - type: "web", - browser: "Chrome", - version: "121.9", - os: "Windows 10/11", - deviceInfo: "Chrome 121.9 on Windows 10/11", - }, - "a".repeat(64), - ); - expect(a).toBe(b); - }); - - it("differs for two clients on the same platform", () => { - const a = generateDeviceFingerprint( - { - type: "desktop", - browser: "Termix Desktop", - version: "2.7.0", - os: "Linux", - deviceInfo: "", - }, - "a".repeat(64), - ); - const b = generateDeviceFingerprint( - { - type: "desktop", - browser: "Termix Desktop", - version: "2.7.0", - os: "Linux", - deviceInfo: "", - }, - "b".repeat(64), - ); - expect(a).not.toBe(b); - }); - - it("does not trust clients without a device id", () => { - const fp = generateDeviceFingerprint( - { - type: "desktop", - browser: "Termix Desktop", - version: "2.3.1", - os: "macOS", - deviceInfo: "", - }, - null, - ); - expect(fp).toBeNull(); - }); -}); - -describe("getDeviceId", () => { - it("accepts a 256-bit hex device id", () => { - const deviceId = "a".repeat(64); - expect(getDeviceId(reqWith({ "x-termix-device-id": deviceId }))).toBe( - deviceId, - ); - }); - - it("rejects missing or malformed device ids", () => { - expect(getDeviceId(reqWith({}))).toBeNull(); - expect( - getDeviceId(reqWith({ "x-termix-device-id": "shared-linux" })), - ).toBeNull(); - }); -}); diff --git a/src/backend/utils/cors-config.ts b/src/backend/utils/cors-config.ts index 979991cf4..b273e35bd 100644 --- a/src/backend/utils/cors-config.ts +++ b/src/backend/utils/cors-config.ts @@ -55,6 +55,7 @@ export function createCorsMiddleware( "User-Agent", "X-Electron-App", "X-Termix-Device-ID", + "X-Termix-Trust-Token", "Cache-Control", "x-admin-target-user", ...extraHeaders, diff --git a/src/backend/utils/user-agent-parser.ts b/src/backend/utils/user-agent-parser.ts index cbd9db138..3477fe83b 100644 --- a/src/backend/utils/user-agent-parser.ts +++ b/src/backend/utils/user-agent-parser.ts @@ -1,5 +1,4 @@ import type { Request } from "express"; -import crypto from "crypto"; export type DeviceType = "web" | "desktop" | "mobile"; @@ -249,22 +248,3 @@ function parseMacVersion(userAgent: string): string { } return "macOS"; } - -/** Return the installation-scoped identifier used for trusted-device checks. */ -export function getDeviceId(req: Request): string | null { - const value = req.headers["x-termix-device-id"]; - if (typeof value !== "string" || !/^[a-f0-9]{64}$/.test(value)) return null; - return value; -} - -/** Bind a trusted-device record to one client installation and platform. */ -export function generateDeviceFingerprint( - deviceInfo: DeviceInfo, - deviceId: string | null, -): string | null { - if (!deviceId) return null; - return crypto - .createHash("sha256") - .update(`${deviceInfo.type}|${deviceId}`) - .digest("hex"); -} diff --git a/src/ui/api/auth-methods-api.ts b/src/ui/api/auth-methods-api.ts index d1d888179..227de3673 100644 --- a/src/ui/api/auth-methods-api.ts +++ b/src/ui/api/auth-methods-api.ts @@ -2,6 +2,7 @@ import { authApi, handleApiError, markUserAuthenticated, + TRUST_TOKEN_KEY, type AuthResponse, } from "@/main-axios"; @@ -26,8 +27,11 @@ export type LoginResponse = AuthResponse & { userId?: string; }; -function remember(data: LoginResponse): LoginResponse { +function remember( + data: LoginResponse & { trustToken?: string }, +): LoginResponse { if (data?.token) localStorage.setItem("jwt", data.token); + if (data?.trustToken) localStorage.setItem(TRUST_TOKEN_KEY, data.trustToken); if (data?.success && !data.requires_totp) markUserAuthenticated(); return data; } diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index 10f8407b3..5db98546c 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -135,6 +135,8 @@ type ElectronWindow = Window & export { isElectron }; +export const TRUST_TOKEN_KEY = "termixTrustToken"; + function getLoggerForService(serviceName: string) { if (serviceName.includes("SSH") || serviceName.includes("ssh")) { return sshLogger; @@ -370,6 +372,19 @@ function createApiInstance( config.headers["Authorization"] = `Bearer ${jwt}`; } } + // The desktop app has no cookies with the server, so it carries the + // remember-this-device token itself, and only to login. + const trustToken = localStorage.getItem(TRUST_TOKEN_KEY); + const isLogin = + config.url?.includes("/users/login") || + /\/users\/auth\/[^/]+\/verify$/.test(config.url ?? ""); + if (trustToken && isLogin) { + if (config.headers.set) { + config.headers.set("X-Termix-Trust-Token", trustToken); + } else { + config.headers["X-Termix-Trust-Token"] = trustToken; + } + } } if (