mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-09 21:32:38 +00:00
fix: trust devices by a server-issued token, not a client header (GHSA-6gr3-r7jx-wfmh)
This commit is contained in:
1 parent
ad3a18224f
commit
8a45d952fd
8 files changed
+130
-131
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in new issue
Block a user