mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 18:31:33 +00:00
fix: harden HTTP trust boundaries (#1316)
This commit is contained in:
@@ -4,6 +4,7 @@ import type { UserRecord } from "../../../database/repositories/user-repository.
|
||||
import {
|
||||
isLoopbackRequest,
|
||||
extractBearerOrCookieToken,
|
||||
isNativeTokenExportRequest,
|
||||
resolveDesktopAutoSessionUser,
|
||||
} from "../../../database/routes/desktop-auto-session.js";
|
||||
|
||||
@@ -106,6 +107,34 @@ describe("extractBearerOrCookieToken", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("isNativeTokenExportRequest", () => {
|
||||
function requestWithUserAgent(userAgent: string): Request {
|
||||
return {
|
||||
get: (name: string) =>
|
||||
name.toLowerCase() === "user-agent" ? userAgent : undefined,
|
||||
} as unknown as Request;
|
||||
}
|
||||
|
||||
it.each([
|
||||
"Termix-Mobile/iOS",
|
||||
"Termix-Mobile/Android",
|
||||
"Termix-Desktop/2.8.0 (win32; Electron/43)",
|
||||
])("allows native user agent %s", (userAgent) => {
|
||||
expect(isNativeTokenExportRequest(requestWithUserAgent(userAgent))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["Mozilla/5.0", "Electron/43.0.0", "", "Termix-MobileFake/iOS"])(
|
||||
"rejects non-native user agent %s",
|
||||
(userAgent) => {
|
||||
expect(isNativeTokenExportRequest(requestWithUserAgent(userAgent))).toBe(
|
||||
false,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("resolveDesktopAutoSessionUser", () => {
|
||||
it("returns the sole local user regardless of having a real password", () => {
|
||||
const user = makeUser({ passwordHash: "$2a$10$realbcryptvaluehere" });
|
||||
|
||||
@@ -61,7 +61,7 @@ describe("logAudit", () => {
|
||||
});
|
||||
|
||||
describe("getRequestMeta", () => {
|
||||
it("extracts ip from x-forwarded-for header", () => {
|
||||
it("uses the proxy-validated Express IP", () => {
|
||||
const req = {
|
||||
headers: {
|
||||
"x-forwarded-for": "10.0.0.1, 10.0.0.2",
|
||||
@@ -71,7 +71,7 @@ describe("getRequestMeta", () => {
|
||||
socket: {},
|
||||
};
|
||||
const meta = getRequestMeta(req as never);
|
||||
expect(meta.ipAddress).toBe("10.0.0.1");
|
||||
expect(meta.ipAddress).toBe("127.0.0.1");
|
||||
expect(meta.userAgent).toBe("TestAgent/1.0");
|
||||
});
|
||||
|
||||
@@ -85,16 +85,16 @@ describe("getRequestMeta", () => {
|
||||
expect(meta.ipAddress).toBe("192.168.1.1");
|
||||
});
|
||||
|
||||
it("splits and trims a forwarded header sent as an array", () => {
|
||||
it("ignores an unvalidated forwarded header", () => {
|
||||
const req = {
|
||||
headers: {
|
||||
"x-forwarded-for": ["10.0.0.1, 10.0.0.2"],
|
||||
"user-agent": "TestAgent/1.0",
|
||||
},
|
||||
socket: {},
|
||||
socket: { remoteAddress: "203.0.113.9" },
|
||||
};
|
||||
const meta = getRequestMeta(req as never);
|
||||
expect(meta.ipAddress).toBe("10.0.0.1");
|
||||
expect(meta.ipAddress).toBe("203.0.113.9");
|
||||
});
|
||||
|
||||
it("falls back to the socket peer when there is no forwarded header or req.ip", () => {
|
||||
|
||||
@@ -33,7 +33,7 @@ describe("getAuditUsername", () => {
|
||||
});
|
||||
|
||||
describe("getRequestMeta", () => {
|
||||
it("prefers the first x-forwarded-for hop", () => {
|
||||
it("prefers the proxy-validated Express IP", () => {
|
||||
const meta = getRequestMeta({
|
||||
headers: {
|
||||
"x-forwarded-for": "203.0.113.9, 10.0.0.1",
|
||||
@@ -43,7 +43,7 @@ describe("getRequestMeta", () => {
|
||||
} as never);
|
||||
|
||||
expect(meta).toEqual({
|
||||
ipAddress: "203.0.113.9",
|
||||
ipAddress: "10.0.0.1",
|
||||
userAgent: "Mozilla/5.0",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { Request } from "express";
|
||||
import { isCorsOriginAllowed } from "../../utils/cors-config.js";
|
||||
|
||||
function request(headers: Record<string, string> = {}): Request {
|
||||
return {
|
||||
headers,
|
||||
protocol: "http",
|
||||
} as unknown as Request;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.CORS_ALLOWED_ORIGINS;
|
||||
});
|
||||
|
||||
describe("isCorsOriginAllowed", () => {
|
||||
it("allows requests without an Origin header", () => {
|
||||
expect(isCorsOriginAllowed(request(), undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it("allows the externally forwarded same origin", () => {
|
||||
const req = request({
|
||||
"x-forwarded-proto": "https",
|
||||
"x-forwarded-host": "termix.example",
|
||||
});
|
||||
expect(isCorsOriginAllowed(req, "https://termix.example")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an unrelated origin even when the TCP peer is loopback", () => {
|
||||
const req = {
|
||||
...request({ host: "termix.example" }),
|
||||
socket: { remoteAddress: "127.0.0.1" },
|
||||
} as unknown as Request;
|
||||
expect(isCorsOriginAllowed(req, "https://attacker.example")).toBe(false);
|
||||
});
|
||||
|
||||
it("allows an explicitly configured origin", () => {
|
||||
process.env.CORS_ALLOWED_ORIGINS = "https://portal.example";
|
||||
expect(isCorsOriginAllowed(request(), "https://portal.example")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not allow a wildcard with credentialed requests", () => {
|
||||
process.env.CORS_ALLOWED_ORIGINS = "*";
|
||||
expect(isCorsOriginAllowed(request(), "https://attacker.example")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -122,26 +122,27 @@ describe("getRequestBasePath", () => {
|
||||
});
|
||||
|
||||
describe("getClientIp", () => {
|
||||
it("prefers the leftmost X-Forwarded-For entry over the socket peer", () => {
|
||||
it("uses Express's proxy-validated req.ip", () => {
|
||||
expect(
|
||||
getClientIp(
|
||||
requestWithSocket(
|
||||
{ "x-forwarded-for": "203.0.113.7, 10.0.0.1, 10.0.0.2" },
|
||||
{ remoteAddress: "::ffff:127.0.0.1" },
|
||||
"198.51.100.5",
|
||||
),
|
||||
),
|
||||
).toBe("203.0.113.7");
|
||||
).toBe("198.51.100.5");
|
||||
});
|
||||
|
||||
it("handles X-Forwarded-For sent as a header array", () => {
|
||||
it("does not trust a raw forwarded header without Express validation", () => {
|
||||
expect(
|
||||
getClientIp(
|
||||
requestWithSocket(
|
||||
{ "x-forwarded-for": ["203.0.113.7", "10.0.0.1"] },
|
||||
{ remoteAddress: "::ffff:127.0.0.1" },
|
||||
{ "x-forwarded-for": "127.0.0.1" },
|
||||
{ remoteAddress: "198.51.100.9" },
|
||||
),
|
||||
),
|
||||
).toBe("203.0.113.7");
|
||||
).toBe("198.51.100.9");
|
||||
});
|
||||
|
||||
it("falls back to req.ip when there is no forwarded header", () => {
|
||||
|
||||
Reference in New Issue
Block a user