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

@@ -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();
});
});