fix: trust devices by a server-issued token, not a client header (GHSA-6gr3-r7jx-wfmh)

This commit is contained in:
LukeGus committed 2026-10-07 21:17:07 -05:00
1 parent ad3a18224f
commit 8a45d952fd
8 files changed
+130 -131

No files matched your search

+53 -22
View File
@@ -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<string, string> })
.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<boolean> {
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,
@@ -235,7 +235,6 @@ export function fakeAuthManager(state: AuthState) {
export function fakeRequest(overrides: Record<string, unknown> = {}) {
const headers: Record<string, string> = {
"user-agent": "Mozilla/5.0 (X11; Linux x86_64) Firefox/120.0",
"x-termix-device-id": "a".repeat(64),
...(overrides.headers as Record<string, string> | undefined),
};
return {
+56 -5
View File
@@ -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({
@@ -3,8 +3,6 @@ import type { Request } from "express";
import {
detectPlatform,
parseUserAgent,
generateDeviceFingerprint,
getDeviceId,
} from "../../utils/user-agent-parser.js";
function reqWith(headers: Record<string, string>): 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();
});
});
+1
View File
@@ -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,
-20
View File
@@ -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");
}
+5 -1
View File
@@ -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;
}
+15
View File
@@ -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 (