mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 10:21:34 +00:00
fix: harden HTTP trust boundaries (#1316)
This commit is contained in:
@@ -172,7 +172,14 @@ if [ -n "$BASE_PATH" ]; then
|
|||||||
echo "Injecting BASE_PATH: $BASE_PATH"
|
echo "Injecting BASE_PATH: $BASE_PATH"
|
||||||
# Strip trailing slash for use as a path prefix
|
# Strip trailing slash for use as a path prefix
|
||||||
CLEAN_BASE_PATH="${BASE_PATH%/}"
|
CLEAN_BASE_PATH="${BASE_PATH%/}"
|
||||||
find /app/html -name "index.html" -exec sed -i "s|window.__TERMIX_BASE_PATH__ = \"\"|window.__TERMIX_BASE_PATH__ = \"$CLEAN_BASE_PATH\"|g" {} \;
|
case "$CLEAN_BASE_PATH" in
|
||||||
|
/*) ;;
|
||||||
|
*) echo "BASE_PATH must start with /" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
case "$CLEAN_BASE_PATH" in
|
||||||
|
*[!A-Za-z0-9_./~-]*) echo "BASE_PATH contains unsupported characters" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
find /app/html -name "index.html" -exec sed -i "s|name=\"termix-base-path\" content=\"\"|name=\"termix-base-path\" content=\"$CLEAN_BASE_PATH\"|g" {} \;
|
||||||
# Patch sw.js static asset paths with the base path prefix
|
# Patch sw.js static asset paths with the base path prefix
|
||||||
find /app/html -name "sw.js" -exec sed -i "s|__TERMIX_SW_BASE_PATH__|$CLEAN_BASE_PATH|g" {} \;
|
find /app/html -name "sw.js" -exec sed -i "s|__TERMIX_SW_BASE_PATH__|$CLEAN_BASE_PATH|g" {} \;
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -161,6 +161,10 @@ http {
|
|||||||
root /app/html;
|
root /app/html;
|
||||||
index index.html index.htm;
|
index index.html index.htm;
|
||||||
expires off;
|
expires off;
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
|
||||||
|
add_header X-Frame-Options "DENY" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
add_header X-Content-Type-Options nosniff always;
|
add_header X-Content-Type-Options nosniff always;
|
||||||
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always;
|
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always;
|
||||||
|
|||||||
@@ -142,6 +142,10 @@ http {
|
|||||||
root /app/html;
|
root /app/html;
|
||||||
index index.html index.htm;
|
index index.html index.htm;
|
||||||
expires off;
|
expires off;
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
|
||||||
|
add_header X-Frame-Options "DENY" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||||
add_header X-Content-Type-Options nosniff always;
|
add_header X-Content-Type-Options nosniff always;
|
||||||
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always;
|
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always;
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
|
|||||||
+1
-3
@@ -9,6 +9,7 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<meta name="theme-color" content="#09090b" />
|
<meta name="theme-color" content="#09090b" />
|
||||||
|
<meta name="termix-base-path" content="" />
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
<meta
|
<meta
|
||||||
name="apple-mobile-web-app-status-bar-style"
|
name="apple-mobile-web-app-status-bar-style"
|
||||||
@@ -68,9 +69,6 @@
|
|||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<script>
|
|
||||||
window.__TERMIX_BASE_PATH__ = "";
|
|
||||||
</script>
|
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ const __dirname = path.dirname(__filename);
|
|||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
app.set("trust proxy", true);
|
app.set("trust proxy", "loopback");
|
||||||
|
|
||||||
const authManager = AuthManager.getInstance();
|
const authManager = AuthManager.getInstance();
|
||||||
const authenticateJWT = authManager.createAuthMiddleware();
|
const authenticateJWT = authManager.createAuthMiddleware();
|
||||||
@@ -259,9 +259,8 @@ async function fetchGitHubAPI<T>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
app.use(bodyParser.json({ limit: "1gb" }));
|
app.use(bodyParser.json({ limit: "2mb" }));
|
||||||
app.use(bodyParser.urlencoded({ limit: "1gb", extended: true }));
|
app.use(bodyParser.urlencoded({ limit: "2mb", extended: true }));
|
||||||
app.use(bodyParser.raw({ limit: "5gb", type: "application/octet-stream" }));
|
|
||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
app.use((_req, res, next) => {
|
app.use((_req, res, next) => {
|
||||||
res.setHeader("Cache-Control", "no-store");
|
res.setHeader("Cache-Control", "no-store");
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ export function extractBearerOrCookieToken(req: Request): string | undefined {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isNativeTokenExportRequest(req: Request): boolean {
|
||||||
|
const userAgent = req.get("user-agent") || "";
|
||||||
|
return /^(Termix-Mobile|Termix-Desktop)\//.test(userAgent);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decides who the desktop auto-session endpoint should silently log in as.
|
* Decides who the desktop auto-session endpoint should silently log in as.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { deleteUserAndRelatedData } from "./delete-user-data.js";
|
|||||||
import {
|
import {
|
||||||
isLoopbackRequest,
|
isLoopbackRequest,
|
||||||
extractBearerOrCookieToken,
|
extractBearerOrCookieToken,
|
||||||
|
isNativeTokenExportRequest,
|
||||||
resolveDesktopAutoSessionUser,
|
resolveDesktopAutoSessionUser,
|
||||||
} from "./desktop-auto-session.js";
|
} from "./desktop-auto-session.js";
|
||||||
import { shouldShowDonationModal } from "./donation-modal-utils.js";
|
import { shouldShowDonationModal } from "./donation-modal-utils.js";
|
||||||
@@ -2168,7 +2169,7 @@ router.post(
|
|||||||
* /users/me/token:
|
* /users/me/token:
|
||||||
* get:
|
* get:
|
||||||
* summary: Get current session token
|
* summary: Get current session token
|
||||||
* description: Returns the JWT for the currently authenticated session. Intended for mobile WebView clients that cannot read HTTP-only cookies.
|
* description: Returns the JWT for the currently authenticated native Mobile or Desktop client. Browser sessions cannot export their HTTP-only cookie.
|
||||||
* tags:
|
* tags:
|
||||||
* - Users
|
* - Users
|
||||||
* responses:
|
* responses:
|
||||||
@@ -2183,8 +2184,16 @@ router.post(
|
|||||||
* type: string
|
* type: string
|
||||||
* 401:
|
* 401:
|
||||||
* description: Not authenticated.
|
* description: Not authenticated.
|
||||||
|
* 403:
|
||||||
|
* description: Token export is not available to browser clients.
|
||||||
*/
|
*/
|
||||||
router.get("/me/token", authenticateJWT, (req: Request, res: Response) => {
|
router.get("/me/token", authenticateJWT, (req: Request, res: Response) => {
|
||||||
|
if (!isNativeTokenExportRequest(req)) {
|
||||||
|
return res
|
||||||
|
.status(403)
|
||||||
|
.json({ error: "Token export is limited to native clients" });
|
||||||
|
}
|
||||||
|
|
||||||
// authenticateJWT accepts either the jwt cookie or an Authorization:
|
// authenticateJWT accepts either the jwt cookie or an Authorization:
|
||||||
// Bearer header (see auth-manager.ts's createAuthMiddleware) -- this must
|
// Bearer header (see auth-manager.ts's createAuthMiddleware) -- this must
|
||||||
// check both too, or a request that only carried the header (e.g. the
|
// check both too, or a request that only carried the header (e.g. the
|
||||||
|
|||||||
@@ -20,11 +20,14 @@ import {
|
|||||||
const sshLogger = logger;
|
const sshLogger = logger;
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
app.set("trust proxy", "loopback");
|
||||||
|
|
||||||
app.use(createCompressionMiddleware());
|
app.use(createCompressionMiddleware());
|
||||||
app.use(createCorsMiddleware(["GET", "POST", "PUT", "DELETE", "OPTIONS"]));
|
app.use(createCorsMiddleware(["GET", "POST", "PUT", "DELETE", "OPTIONS"]));
|
||||||
|
|
||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
|
const authManager = AuthManager.getInstance();
|
||||||
|
app.use(authManager.createAuthMiddleware());
|
||||||
app.use(express.json({ limit: "100mb" }));
|
app.use(express.json({ limit: "100mb" }));
|
||||||
app.use(express.urlencoded({ limit: "100mb", extended: true }));
|
app.use(express.urlencoded({ limit: "100mb", extended: true }));
|
||||||
app.use((_req, res, next) => {
|
app.use((_req, res, next) => {
|
||||||
@@ -32,9 +35,6 @@ app.use((_req, res, next) => {
|
|||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
const authManager = AuthManager.getInstance();
|
|
||||||
app.use(authManager.createAuthMiddleware());
|
|
||||||
|
|
||||||
registerDockerSshRoutes(app);
|
registerDockerSshRoutes(app);
|
||||||
|
|
||||||
registerDockerContainerRoutes(app, {
|
registerDockerContainerRoutes(app, {
|
||||||
|
|||||||
@@ -117,10 +117,13 @@ function assertResolvedHost(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
app.set("trust proxy", "loopback");
|
||||||
|
|
||||||
app.use(createCompressionMiddleware());
|
app.use(createCompressionMiddleware());
|
||||||
app.use(createCorsMiddleware(["GET", "POST", "PUT", "DELETE", "OPTIONS"]));
|
app.use(createCorsMiddleware(["GET", "POST", "PUT", "DELETE", "OPTIONS"]));
|
||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
|
const authManager = AuthManager.getInstance();
|
||||||
|
app.use(authManager.createAuthMiddleware());
|
||||||
app.use(express.json({ limit: "1gb" }));
|
app.use(express.json({ limit: "1gb" }));
|
||||||
app.use(express.urlencoded({ limit: "1gb", extended: true }));
|
app.use(express.urlencoded({ limit: "1gb", extended: true }));
|
||||||
app.use(express.raw({ limit: "5gb", type: "application/octet-stream" }));
|
app.use(express.raw({ limit: "5gb", type: "application/octet-stream" }));
|
||||||
@@ -129,9 +132,6 @@ app.use((_req, res, next) => {
|
|||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
const authManager = AuthManager.getInstance();
|
|
||||||
app.use(authManager.createAuthMiddleware());
|
|
||||||
|
|
||||||
const sshSessions: Record<string, SSHSession> = {};
|
const sshSessions: Record<string, SSHSession> = {};
|
||||||
const pendingTOTPSessions: Record<string, PendingTOTPSession> = {};
|
const pendingTOTPSessions: Record<string, PendingTOTPSession> = {};
|
||||||
// Keyed by "sessionId:path" to prevent concurrent requests for the same path
|
// Keyed by "sessionId:path" to prevent concurrent requests for the same path
|
||||||
|
|||||||
@@ -1003,6 +1003,7 @@ function validateHostId(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
app.set("trust proxy", "loopback");
|
||||||
app.use(createCompressionMiddleware());
|
app.use(createCompressionMiddleware());
|
||||||
app.use(createCorsMiddleware());
|
app.use(createCorsMiddleware());
|
||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
|
|||||||
@@ -326,6 +326,7 @@ async function collectPaneMetrics(
|
|||||||
// Express app
|
// Express app
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
app.set("trust proxy", "loopback");
|
||||||
const authManager = AuthManager.getInstance();
|
const authManager = AuthManager.getInstance();
|
||||||
|
|
||||||
app.use(createCompressionMiddleware());
|
app.use(createCompressionMiddleware());
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { initializeAutoStartTunnels } from "./manager.js";
|
|||||||
const authManager = AuthManager.getInstance();
|
const authManager = AuthManager.getInstance();
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
app.set("trust proxy", "loopback");
|
||||||
app.use(createCompressionMiddleware());
|
app.use(createCompressionMiddleware());
|
||||||
app.use(createCorsMiddleware(["GET", "POST", "PUT", "DELETE", "OPTIONS"]));
|
app.use(createCorsMiddleware(["GET", "POST", "PUT", "DELETE", "OPTIONS"]));
|
||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import { DataCrypto } from "../utils/data-crypto.js";
|
import { DataCrypto } from "../utils/data-crypto.js";
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
app.set("trust proxy", "loopback");
|
||||||
const authManager = AuthManager.getInstance();
|
const authManager = AuthManager.getInstance();
|
||||||
|
|
||||||
const serverStartTime = Date.now();
|
const serverStartTime = Date.now();
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { homepagePingRouter } from "../database/routes/homepage-ping-routes.js";
|
|||||||
import { homepageProxyRouter } from "../database/routes/homepage-proxy-routes.js";
|
import { homepageProxyRouter } from "../database/routes/homepage-proxy-routes.js";
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
app.set("trust proxy", "loopback");
|
||||||
const authManager = AuthManager.getInstance();
|
const authManager = AuthManager.getInstance();
|
||||||
const PORT = 30012;
|
const PORT = 30012;
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { UserRecord } from "../../../database/repositories/user-repository.
|
|||||||
import {
|
import {
|
||||||
isLoopbackRequest,
|
isLoopbackRequest,
|
||||||
extractBearerOrCookieToken,
|
extractBearerOrCookieToken,
|
||||||
|
isNativeTokenExportRequest,
|
||||||
resolveDesktopAutoSessionUser,
|
resolveDesktopAutoSessionUser,
|
||||||
} from "../../../database/routes/desktop-auto-session.js";
|
} 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", () => {
|
describe("resolveDesktopAutoSessionUser", () => {
|
||||||
it("returns the sole local user regardless of having a real password", () => {
|
it("returns the sole local user regardless of having a real password", () => {
|
||||||
const user = makeUser({ passwordHash: "$2a$10$realbcryptvaluehere" });
|
const user = makeUser({ passwordHash: "$2a$10$realbcryptvaluehere" });
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ describe("logAudit", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("getRequestMeta", () => {
|
describe("getRequestMeta", () => {
|
||||||
it("extracts ip from x-forwarded-for header", () => {
|
it("uses the proxy-validated Express IP", () => {
|
||||||
const req = {
|
const req = {
|
||||||
headers: {
|
headers: {
|
||||||
"x-forwarded-for": "10.0.0.1, 10.0.0.2",
|
"x-forwarded-for": "10.0.0.1, 10.0.0.2",
|
||||||
@@ -71,7 +71,7 @@ describe("getRequestMeta", () => {
|
|||||||
socket: {},
|
socket: {},
|
||||||
};
|
};
|
||||||
const meta = getRequestMeta(req as never);
|
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");
|
expect(meta.userAgent).toBe("TestAgent/1.0");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -85,16 +85,16 @@ describe("getRequestMeta", () => {
|
|||||||
expect(meta.ipAddress).toBe("192.168.1.1");
|
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 = {
|
const req = {
|
||||||
headers: {
|
headers: {
|
||||||
"x-forwarded-for": ["10.0.0.1, 10.0.0.2"],
|
"x-forwarded-for": ["10.0.0.1, 10.0.0.2"],
|
||||||
"user-agent": "TestAgent/1.0",
|
"user-agent": "TestAgent/1.0",
|
||||||
},
|
},
|
||||||
socket: {},
|
socket: { remoteAddress: "203.0.113.9" },
|
||||||
};
|
};
|
||||||
const meta = getRequestMeta(req as never);
|
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", () => {
|
it("falls back to the socket peer when there is no forwarded header or req.ip", () => {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ describe("getAuditUsername", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("getRequestMeta", () => {
|
describe("getRequestMeta", () => {
|
||||||
it("prefers the first x-forwarded-for hop", () => {
|
it("prefers the proxy-validated Express IP", () => {
|
||||||
const meta = getRequestMeta({
|
const meta = getRequestMeta({
|
||||||
headers: {
|
headers: {
|
||||||
"x-forwarded-for": "203.0.113.9, 10.0.0.1",
|
"x-forwarded-for": "203.0.113.9, 10.0.0.1",
|
||||||
@@ -43,7 +43,7 @@ describe("getRequestMeta", () => {
|
|||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
expect(meta).toEqual({
|
expect(meta).toEqual({
|
||||||
ipAddress: "203.0.113.9",
|
ipAddress: "10.0.0.1",
|
||||||
userAgent: "Mozilla/5.0",
|
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", () => {
|
describe("getClientIp", () => {
|
||||||
it("prefers the leftmost X-Forwarded-For entry over the socket peer", () => {
|
it("uses Express's proxy-validated req.ip", () => {
|
||||||
expect(
|
expect(
|
||||||
getClientIp(
|
getClientIp(
|
||||||
requestWithSocket(
|
requestWithSocket(
|
||||||
{ "x-forwarded-for": "203.0.113.7, 10.0.0.1, 10.0.0.2" },
|
{ "x-forwarded-for": "203.0.113.7, 10.0.0.1, 10.0.0.2" },
|
||||||
{ remoteAddress: "::ffff:127.0.0.1" },
|
{ 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(
|
expect(
|
||||||
getClientIp(
|
getClientIp(
|
||||||
requestWithSocket(
|
requestWithSocket(
|
||||||
{ "x-forwarded-for": ["203.0.113.7", "10.0.0.1"] },
|
{ "x-forwarded-for": "127.0.0.1" },
|
||||||
{ remoteAddress: "::ffff: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", () => {
|
it("falls back to req.ip when there is no forwarded header", () => {
|
||||||
|
|||||||
@@ -14,13 +14,18 @@ function getAllowedOrigins(): string[] {
|
|||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isLocalRequest(req: Request): boolean {
|
export function isCorsOriginAllowed(
|
||||||
const remoteAddr = req.socket?.remoteAddress || req.ip || "";
|
req: Request,
|
||||||
return (
|
origin: string | undefined,
|
||||||
remoteAddr === "127.0.0.1" ||
|
): boolean {
|
||||||
remoteAddr === "::1" ||
|
if (!origin) return true;
|
||||||
remoteAddr === "::ffff:127.0.0.1"
|
if (DEV_ORIGINS.includes(origin)) return true;
|
||||||
);
|
if (origin.startsWith(ELECTRON_FILE_ORIGIN)) return true;
|
||||||
|
|
||||||
|
const configured = getAllowedOrigins();
|
||||||
|
if (configured.includes(origin)) return true;
|
||||||
|
|
||||||
|
return origin === getRequestOrigin(req);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createCorsMiddleware(
|
export function createCorsMiddleware(
|
||||||
@@ -44,23 +49,7 @@ export function createCorsMiddleware(
|
|||||||
return (req: Request, res: Response, next: NextFunction) => {
|
return (req: Request, res: Response, next: NextFunction) => {
|
||||||
const handler = cors({
|
const handler = cors({
|
||||||
origin: (origin, callback) => {
|
origin: (origin, callback) => {
|
||||||
// No origin = same-origin or non-browser request (curl, internal service calls)
|
if (isCorsOriginAllowed(req, origin)) return callback(null, true);
|
||||||
if (!origin) return callback(null, true);
|
|
||||||
|
|
||||||
// Requests coming from localhost (nginx proxy, internal service calls)
|
|
||||||
if (isLocalRequest(req)) return callback(null, true);
|
|
||||||
|
|
||||||
if (DEV_ORIGINS.includes(origin)) return callback(null, true);
|
|
||||||
if (origin.startsWith(ELECTRON_FILE_ORIGIN))
|
|
||||||
return callback(null, true);
|
|
||||||
|
|
||||||
const configured = getAllowedOrigins();
|
|
||||||
if (configured.includes("*") || configured.includes(origin))
|
|
||||||
return callback(null, true);
|
|
||||||
|
|
||||||
const sameOrigin = getRequestOrigin(req);
|
|
||||||
if (origin === sameOrigin) return callback(null, true);
|
|
||||||
|
|
||||||
callback(new Error("Not allowed by CORS"));
|
callback(new Error("Not allowed by CORS"));
|
||||||
},
|
},
|
||||||
credentials: true,
|
credentials: true,
|
||||||
|
|||||||
@@ -64,15 +64,8 @@ export function normalizeBasePath(value: unknown): string {
|
|||||||
return basePath.replace(/\/+$/, "");
|
return basePath.replace(/\/+$/, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Real client IP after Express has applied its configured proxy trust policy. */
|
||||||
* Real client IP behind a reverse proxy. `X-Forwarded-For`'s leftmost entry is
|
|
||||||
* the original client; socket.remoteAddress is only the immediate peer, which
|
|
||||||
* behind Traefik/Cloudflare is the proxy itself (often a loopback address).
|
|
||||||
*/
|
|
||||||
export function getClientIp(req: Request | IncomingMessage): string {
|
export function getClientIp(req: Request | IncomingMessage): string {
|
||||||
const forwarded = firstHeaderValue(req.headers["x-forwarded-for"]);
|
|
||||||
if (forwarded) return forwarded;
|
|
||||||
|
|
||||||
if ("ip" in req && req.ip) return req.ip;
|
if ("ip" in req && req.ip) return req.ip;
|
||||||
|
|
||||||
return req.socket?.remoteAddress ?? "unknown";
|
return req.socket?.remoteAddress ?? "unknown";
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
export function getBasePath(): string {
|
export function getBasePath(): string {
|
||||||
const runtime = (window as unknown as Record<string, unknown>)
|
const runtime =
|
||||||
.__TERMIX_BASE_PATH__ as string | undefined;
|
document
|
||||||
|
.querySelector<HTMLMetaElement>('meta[name="termix-base-path"]')
|
||||||
|
?.content.trim() ||
|
||||||
|
((window as unknown as Record<string, unknown>).__TERMIX_BASE_PATH__ as
|
||||||
|
string | undefined);
|
||||||
if (runtime) {
|
if (runtime) {
|
||||||
return runtime.endsWith("/") ? runtime.slice(0, -1) : runtime;
|
return runtime.endsWith("/") ? runtime.slice(0, -1) : runtime;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ const win = window as unknown as Record<string, unknown>;
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
delete win.__TERMIX_BASE_PATH__;
|
delete win.__TERMIX_BASE_PATH__;
|
||||||
|
document.querySelector('meta[name="termix-base-path"]')?.remove();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getBasePath", () => {
|
describe("getBasePath", () => {
|
||||||
@@ -18,6 +19,15 @@ describe("getBasePath", () => {
|
|||||||
expect(getBasePath()).toBe("/termix");
|
expect(getBasePath()).toBe("/termix");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses the CSP-safe runtime meta value when present", () => {
|
||||||
|
const meta = document.createElement("meta");
|
||||||
|
meta.name = "termix-base-path";
|
||||||
|
meta.content = "/gateway/termix";
|
||||||
|
document.head.append(meta);
|
||||||
|
|
||||||
|
expect(getBasePath()).toBe("/gateway/termix");
|
||||||
|
});
|
||||||
|
|
||||||
it("strips a trailing slash from the runtime override", () => {
|
it("strips a trailing slash from the runtime override", () => {
|
||||||
win.__TERMIX_BASE_PATH__ = "/termix/";
|
win.__TERMIX_BASE_PATH__ = "/termix/";
|
||||||
expect(getBasePath()).toBe("/termix");
|
expect(getBasePath()).toBe("/termix");
|
||||||
|
|||||||
Reference in New Issue
Block a user