mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35: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
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user