mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 15:15:24 +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 { AuthManager } from "../utils/auth-manager.js";
|
||||||
import { loginRateLimiter } from "../utils/login-rate-limiter.js";
|
import { loginRateLimiter } from "../utils/login-rate-limiter.js";
|
||||||
import { authLogger } from "../utils/logger.js";
|
import { authLogger } from "../utils/logger.js";
|
||||||
import {
|
import { parseUserAgent } from "../utils/user-agent-parser.js";
|
||||||
generateDeviceFingerprint,
|
|
||||||
getDeviceId,
|
|
||||||
parseUserAgent,
|
|
||||||
} from "../utils/user-agent-parser.js";
|
|
||||||
import { logAudit, getRequestMeta } from "../utils/audit-logger.js";
|
import { logAudit, getRequestMeta } from "../utils/audit-logger.js";
|
||||||
import {
|
import {
|
||||||
createCurrentUserAuthRepository,
|
createCurrentUserAuthRepository,
|
||||||
@@ -37,6 +33,7 @@ import {
|
|||||||
type SecondFactor,
|
type SecondFactor,
|
||||||
} from "./registry.js";
|
} from "./registry.js";
|
||||||
import {
|
import {
|
||||||
|
isNativeAppRequest,
|
||||||
issueSession,
|
issueSession,
|
||||||
sendSession,
|
sendSession,
|
||||||
syncSharedCredentialsForUserRoles,
|
syncSharedCredentialsForUserRoles,
|
||||||
@@ -131,10 +128,33 @@ function shouldRunSecondFactors(methodId: string): boolean {
|
|||||||
return isSecondFactorAfterExternalLoginEnabled();
|
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> {
|
async function isTrustedDevice(req: Request, userId: string): Promise<boolean> {
|
||||||
const deviceInfo = parseUserAgent(req);
|
const token = readTrustToken(req);
|
||||||
const fingerprint = generateDeviceFingerprint(deviceInfo, getDeviceId(req));
|
if (!token) return false;
|
||||||
if (!fingerprint) return false;
|
const fingerprint = hashTrustToken(token);
|
||||||
const trusted = await AuthManager.getInstance().isTrustedDevice(
|
const trusted = await AuthManager.getInstance().isTrustedDevice(
|
||||||
userId,
|
userId,
|
||||||
fingerprint,
|
fingerprint,
|
||||||
@@ -473,22 +493,29 @@ export async function verifySecondFactorAndRespond(
|
|||||||
? req.body.rememberMe
|
? req.body.rememberMe
|
||||||
: (lookup.pending?.rememberMe ?? false);
|
: (lookup.pending?.rememberMe ?? false);
|
||||||
|
|
||||||
|
let trustToken: string | null = null;
|
||||||
if (rememberMe) {
|
if (rememberMe) {
|
||||||
const deviceInfo = parseUserAgent(req);
|
const deviceInfo = parseUserAgent(req);
|
||||||
const fingerprint = generateDeviceFingerprint(deviceInfo, getDeviceId(req));
|
trustToken = crypto.randomBytes(32).toString("hex");
|
||||||
if (fingerprint) {
|
await AuthManager.getInstance().addTrustedDevice(
|
||||||
await AuthManager.getInstance().addTrustedDevice(
|
user.id,
|
||||||
user.id,
|
hashTrustToken(trustToken),
|
||||||
fingerprint,
|
deviceInfo.type,
|
||||||
deviceInfo.type,
|
deviceInfo.deviceInfo,
|
||||||
deviceInfo.deviceInfo,
|
);
|
||||||
);
|
res.cookie(
|
||||||
authLogger.info("Device automatically trusted via Remember Me", {
|
TRUST_DEVICE_COOKIE,
|
||||||
operation: "totp_auto_trust",
|
trustToken,
|
||||||
userId: user.id,
|
AuthManager.getInstance().getSecureCookieOptions(
|
||||||
deviceType: deviceInfo.type,
|
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;
|
const pending = lookup.pending;
|
||||||
@@ -498,6 +525,10 @@ export async function verifySecondFactorAndRespond(
|
|||||||
externalSession: pending?.externalSession ?? null,
|
externalSession: pending?.externalSession ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (trustToken && isNativeAppRequest(req)) {
|
||||||
|
session.body.trustToken = trustToken;
|
||||||
|
}
|
||||||
|
|
||||||
consumePendingLogin(lookup.token);
|
consumePendingLogin(lookup.token);
|
||||||
res.clearCookie(
|
res.clearCookie(
|
||||||
PENDING_LOGIN_COOKIE,
|
PENDING_LOGIN_COOKIE,
|
||||||
|
|||||||
@@ -235,7 +235,6 @@ export function fakeAuthManager(state: AuthState) {
|
|||||||
export function fakeRequest(overrides: Record<string, unknown> = {}) {
|
export function fakeRequest(overrides: Record<string, unknown> = {}) {
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
"user-agent": "Mozilla/5.0 (X11; Linux x86_64) Firefox/120.0",
|
"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),
|
...(overrides.headers as Record<string, string> | undefined),
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -88,6 +88,8 @@ const {
|
|||||||
respondWithRedirectLogin,
|
respondWithRedirectLogin,
|
||||||
verifySecondFactorAndRespond,
|
verifySecondFactorAndRespond,
|
||||||
resetPendingLoginsForTests,
|
resetPendingLoginsForTests,
|
||||||
|
TRUST_DEVICE_COOKIE,
|
||||||
|
hashTrustToken,
|
||||||
} = await import("../../auth/login-pipeline.js");
|
} = await import("../../auth/login-pipeline.js");
|
||||||
const { verifyPasswordLogin } =
|
const { verifyPasswordLogin } =
|
||||||
await import("../../auth/builtin-login-methods.js");
|
await import("../../auth/builtin-login-methods.js");
|
||||||
@@ -295,16 +297,46 @@ describe("second factors", () => {
|
|||||||
await verifySecondFactorAndRespond(req as never, res as never, "totp");
|
await verifySecondFactorAndRespond(req as never, res as never, "totp");
|
||||||
|
|
||||||
expect(res.statusCode).toBe(200);
|
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({
|
expect(res.body).toMatchObject({
|
||||||
success: true,
|
success: true,
|
||||||
username: "alice",
|
username: "alice",
|
||||||
totp_enabled: true,
|
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" });
|
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 () => {
|
it("answers the 2.8 route with the user's first factor when none is named", async () => {
|
||||||
enrolTotp();
|
enrolTotp();
|
||||||
const first = await passwordLogin({
|
const first = await passwordLogin({
|
||||||
@@ -369,13 +401,32 @@ describe("second factors", () => {
|
|||||||
expect(res.cookies).toEqual([]);
|
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();
|
enrolTotp();
|
||||||
h.manager.isTrustedDevice.mockResolvedValueOnce(true);
|
const token = "c".repeat(64);
|
||||||
const res = await passwordLogin({ username: "alice", password: PASSWORD });
|
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" });
|
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 () => {
|
it("fails closed when an enrolled factor's plugin is gone", async () => {
|
||||||
addUser();
|
addUser();
|
||||||
h.state.factors.push({
|
h.state.factors.push({
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ import type { Request } from "express";
|
|||||||
import {
|
import {
|
||||||
detectPlatform,
|
detectPlatform,
|
||||||
parseUserAgent,
|
parseUserAgent,
|
||||||
generateDeviceFingerprint,
|
|
||||||
getDeviceId,
|
|
||||||
} from "../../utils/user-agent-parser.js";
|
} from "../../utils/user-agent-parser.js";
|
||||||
|
|
||||||
function reqWith(headers: Record<string, string>): Request {
|
function reqWith(headers: Record<string, string>): Request {
|
||||||
@@ -97,83 +95,3 @@ describe("parseUserAgent", () => {
|
|||||||
expect(info.os).toContain("iOS");
|
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",
|
"User-Agent",
|
||||||
"X-Electron-App",
|
"X-Electron-App",
|
||||||
"X-Termix-Device-ID",
|
"X-Termix-Device-ID",
|
||||||
|
"X-Termix-Trust-Token",
|
||||||
"Cache-Control",
|
"Cache-Control",
|
||||||
"x-admin-target-user",
|
"x-admin-target-user",
|
||||||
...extraHeaders,
|
...extraHeaders,
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { Request } from "express";
|
import type { Request } from "express";
|
||||||
import crypto from "crypto";
|
|
||||||
|
|
||||||
export type DeviceType = "web" | "desktop" | "mobile";
|
export type DeviceType = "web" | "desktop" | "mobile";
|
||||||
|
|
||||||
@@ -249,22 +248,3 @@ function parseMacVersion(userAgent: string): string {
|
|||||||
}
|
}
|
||||||
return "macOS";
|
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,
|
authApi,
|
||||||
handleApiError,
|
handleApiError,
|
||||||
markUserAuthenticated,
|
markUserAuthenticated,
|
||||||
|
TRUST_TOKEN_KEY,
|
||||||
type AuthResponse,
|
type AuthResponse,
|
||||||
} from "@/main-axios";
|
} from "@/main-axios";
|
||||||
|
|
||||||
@@ -26,8 +27,11 @@ export type LoginResponse = AuthResponse & {
|
|||||||
userId?: string;
|
userId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function remember(data: LoginResponse): LoginResponse {
|
function remember(
|
||||||
|
data: LoginResponse & { trustToken?: string },
|
||||||
|
): LoginResponse {
|
||||||
if (data?.token) localStorage.setItem("jwt", data.token);
|
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();
|
if (data?.success && !data.requires_totp) markUserAuthenticated();
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,6 +135,8 @@ type ElectronWindow = Window &
|
|||||||
|
|
||||||
export { isElectron };
|
export { isElectron };
|
||||||
|
|
||||||
|
export const TRUST_TOKEN_KEY = "termixTrustToken";
|
||||||
|
|
||||||
function getLoggerForService(serviceName: string) {
|
function getLoggerForService(serviceName: string) {
|
||||||
if (serviceName.includes("SSH") || serviceName.includes("ssh")) {
|
if (serviceName.includes("SSH") || serviceName.includes("ssh")) {
|
||||||
return sshLogger;
|
return sshLogger;
|
||||||
@@ -370,6 +372,19 @@ function createApiInstance(
|
|||||||
config.headers["Authorization"] = `Bearer ${jwt}`;
|
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 (
|
if (
|
||||||
|
|||||||
Reference in new issue
Block a user