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,