fix: harden HTTP trust boundaries (#1316)

This commit is contained in:
ZacharyZcR
2026-08-24 07:52:31 +08:00
committed by GitHub
parent c4c9b51294
commit 30d72554fc
23 changed files with 167 additions and 62 deletions
@@ -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" });
+5 -5
View File
@@ -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", () => {