diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh
index c7099c74..853e4bbc 100644
--- a/docker/entrypoint.sh
+++ b/docker/entrypoint.sh
@@ -172,7 +172,14 @@ if [ -n "$BASE_PATH" ]; then
echo "Injecting BASE_PATH: $BASE_PATH"
# Strip trailing slash for use as a path prefix
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
find /app/html -name "sw.js" -exec sed -i "s|__TERMIX_SW_BASE_PATH__|$CLEAN_BASE_PATH|g" {} \;
else
diff --git a/docker/nginx-https.conf b/docker/nginx-https.conf
index 689c7ed6..ca244a90 100644
--- a/docker/nginx-https.conf
+++ b/docker/nginx-https.conf
@@ -161,6 +161,10 @@ http {
root /app/html;
index index.html index.htm;
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 X-Content-Type-Options nosniff always;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always;
diff --git a/docker/nginx.conf b/docker/nginx.conf
index 9140ac32..f6acb72e 100644
--- a/docker/nginx.conf
+++ b/docker/nginx.conf
@@ -142,6 +142,10 @@ http {
root /app/html;
index index.html index.htm;
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 Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always;
try_files $uri $uri/ /index.html;
diff --git a/index.html b/index.html
index f3f0c8fd..9de5da6e 100644
--- a/index.html
+++ b/index.html
@@ -9,6 +9,7 @@
/>
+
-
diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts
index d4caec28..f8bd58e7 100644
--- a/src/backend/database/database.ts
+++ b/src/backend/database/database.ts
@@ -73,7 +73,7 @@ const __dirname = path.dirname(__filename);
const app = express();
-app.set("trust proxy", true);
+app.set("trust proxy", "loopback");
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
@@ -259,9 +259,8 @@ async function fetchGitHubAPI(
}
}
-app.use(bodyParser.json({ limit: "1gb" }));
-app.use(bodyParser.urlencoded({ limit: "1gb", extended: true }));
-app.use(bodyParser.raw({ limit: "5gb", type: "application/octet-stream" }));
+app.use(bodyParser.json({ limit: "2mb" }));
+app.use(bodyParser.urlencoded({ limit: "2mb", extended: true }));
app.use(cookieParser());
app.use((_req, res, next) => {
res.setHeader("Cache-Control", "no-store");
diff --git a/src/backend/database/routes/desktop-auto-session.ts b/src/backend/database/routes/desktop-auto-session.ts
index d1ffa95b..af3d786b 100644
--- a/src/backend/database/routes/desktop-auto-session.ts
+++ b/src/backend/database/routes/desktop-auto-session.ts
@@ -33,6 +33,11 @@ export function extractBearerOrCookieToken(req: Request): string | 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.
*
diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts
index 26763fbc..158601b0 100644
--- a/src/backend/database/routes/users.ts
+++ b/src/backend/database/routes/users.ts
@@ -22,6 +22,7 @@ import { deleteUserAndRelatedData } from "./delete-user-data.js";
import {
isLoopbackRequest,
extractBearerOrCookieToken,
+ isNativeTokenExportRequest,
resolveDesktopAutoSessionUser,
} from "./desktop-auto-session.js";
import { shouldShowDonationModal } from "./donation-modal-utils.js";
@@ -2168,7 +2169,7 @@ router.post(
* /users/me/token:
* get:
* 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:
* - Users
* responses:
@@ -2183,8 +2184,16 @@ router.post(
* type: string
* 401:
* description: Not authenticated.
+ * 403:
+ * description: Token export is not available to browser clients.
*/
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:
// Bearer header (see auth-manager.ts's createAuthMiddleware) -- this must
// check both too, or a request that only carried the header (e.g. the
diff --git a/src/backend/hosts/docker/index.ts b/src/backend/hosts/docker/index.ts
index 9619825b..bacf4831 100644
--- a/src/backend/hosts/docker/index.ts
+++ b/src/backend/hosts/docker/index.ts
@@ -20,11 +20,14 @@ import {
const sshLogger = logger;
const app = express();
+app.set("trust proxy", "loopback");
app.use(createCompressionMiddleware());
app.use(createCorsMiddleware(["GET", "POST", "PUT", "DELETE", "OPTIONS"]));
app.use(cookieParser());
+const authManager = AuthManager.getInstance();
+app.use(authManager.createAuthMiddleware());
app.use(express.json({ limit: "100mb" }));
app.use(express.urlencoded({ limit: "100mb", extended: true }));
app.use((_req, res, next) => {
@@ -32,9 +35,6 @@ app.use((_req, res, next) => {
next();
});
-const authManager = AuthManager.getInstance();
-app.use(authManager.createAuthMiddleware());
-
registerDockerSshRoutes(app);
registerDockerContainerRoutes(app, {
diff --git a/src/backend/hosts/file-manager/index.ts b/src/backend/hosts/file-manager/index.ts
index f7cdf52b..00f6ddd0 100644
--- a/src/backend/hosts/file-manager/index.ts
+++ b/src/backend/hosts/file-manager/index.ts
@@ -117,10 +117,13 @@ function assertResolvedHost(
}
const app = express();
+app.set("trust proxy", "loopback");
app.use(createCompressionMiddleware());
app.use(createCorsMiddleware(["GET", "POST", "PUT", "DELETE", "OPTIONS"]));
app.use(cookieParser());
+const authManager = AuthManager.getInstance();
+app.use(authManager.createAuthMiddleware());
app.use(express.json({ limit: "1gb" }));
app.use(express.urlencoded({ limit: "1gb", extended: true }));
app.use(express.raw({ limit: "5gb", type: "application/octet-stream" }));
@@ -129,9 +132,6 @@ app.use((_req, res, next) => {
next();
});
-const authManager = AuthManager.getInstance();
-app.use(authManager.createAuthMiddleware());
-
const sshSessions: Record = {};
const pendingTOTPSessions: Record = {};
// Keyed by "sessionId:path" to prevent concurrent requests for the same path
diff --git a/src/backend/hosts/metrics/index.ts b/src/backend/hosts/metrics/index.ts
index 941bdae4..26320a1b 100644
--- a/src/backend/hosts/metrics/index.ts
+++ b/src/backend/hosts/metrics/index.ts
@@ -1003,6 +1003,7 @@ function validateHostId(
}
const app = express();
+app.set("trust proxy", "loopback");
app.use(createCompressionMiddleware());
app.use(createCorsMiddleware());
app.use(cookieParser());
diff --git a/src/backend/hosts/tmux/index.ts b/src/backend/hosts/tmux/index.ts
index ba0901c5..ffa6b70d 100644
--- a/src/backend/hosts/tmux/index.ts
+++ b/src/backend/hosts/tmux/index.ts
@@ -326,6 +326,7 @@ async function collectPaneMetrics(
// Express app
const app = express();
+app.set("trust proxy", "loopback");
const authManager = AuthManager.getInstance();
app.use(createCompressionMiddleware());
diff --git a/src/backend/hosts/tunnel/index.ts b/src/backend/hosts/tunnel/index.ts
index b760fb96..5d44eef3 100644
--- a/src/backend/hosts/tunnel/index.ts
+++ b/src/backend/hosts/tunnel/index.ts
@@ -26,6 +26,7 @@ import { initializeAutoStartTunnels } from "./manager.js";
const authManager = AuthManager.getInstance();
const app = express();
+app.set("trust proxy", "loopback");
app.use(createCompressionMiddleware());
app.use(createCorsMiddleware(["GET", "POST", "PUT", "DELETE", "OPTIONS"]));
app.use(cookieParser());
diff --git a/src/backend/services/dashboard.ts b/src/backend/services/dashboard.ts
index dc94f312..2810e3ee 100644
--- a/src/backend/services/dashboard.ts
+++ b/src/backend/services/dashboard.ts
@@ -15,6 +15,7 @@ import {
import { DataCrypto } from "../utils/data-crypto.js";
const app = express();
+app.set("trust proxy", "loopback");
const authManager = AuthManager.getInstance();
const serverStartTime = Date.now();
diff --git a/src/backend/services/homepage.ts b/src/backend/services/homepage.ts
index de61e0b9..c67eac66 100644
--- a/src/backend/services/homepage.ts
+++ b/src/backend/services/homepage.ts
@@ -11,6 +11,7 @@ import { homepagePingRouter } from "../database/routes/homepage-ping-routes.js";
import { homepageProxyRouter } from "../database/routes/homepage-proxy-routes.js";
const app = express();
+app.set("trust proxy", "loopback");
const authManager = AuthManager.getInstance();
const PORT = 30012;
diff --git a/src/backend/tests/database/routes/desktop-auto-session.test.ts b/src/backend/tests/database/routes/desktop-auto-session.test.ts
index 94ff8377..af963e13 100644
--- a/src/backend/tests/database/routes/desktop-auto-session.test.ts
+++ b/src/backend/tests/database/routes/desktop-auto-session.test.ts
@@ -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" });
diff --git a/src/backend/tests/utils/audit-logger.test.ts b/src/backend/tests/utils/audit-logger.test.ts
index 08282b01..b515154b 100644
--- a/src/backend/tests/utils/audit-logger.test.ts
+++ b/src/backend/tests/utils/audit-logger.test.ts
@@ -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", () => {
diff --git a/src/backend/tests/utils/audit-username.test.ts b/src/backend/tests/utils/audit-username.test.ts
index db5e6b17..1eb0ab85 100644
--- a/src/backend/tests/utils/audit-username.test.ts
+++ b/src/backend/tests/utils/audit-username.test.ts
@@ -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",
});
});
diff --git a/src/backend/tests/utils/cors-config.test.ts b/src/backend/tests/utils/cors-config.test.ts
new file mode 100644
index 00000000..f3831e66
--- /dev/null
+++ b/src/backend/tests/utils/cors-config.test.ts
@@ -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 = {}): 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,
+ );
+ });
+});
diff --git a/src/backend/tests/utils/request-origin.test.ts b/src/backend/tests/utils/request-origin.test.ts
index 55c36394..b5e8280a 100644
--- a/src/backend/tests/utils/request-origin.test.ts
+++ b/src/backend/tests/utils/request-origin.test.ts
@@ -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", () => {
diff --git a/src/backend/utils/cors-config.ts b/src/backend/utils/cors-config.ts
index 63a8058b..fdbc6392 100644
--- a/src/backend/utils/cors-config.ts
+++ b/src/backend/utils/cors-config.ts
@@ -14,13 +14,18 @@ function getAllowedOrigins(): string[] {
.filter(Boolean);
}
-function isLocalRequest(req: Request): boolean {
- const remoteAddr = req.socket?.remoteAddress || req.ip || "";
- return (
- remoteAddr === "127.0.0.1" ||
- remoteAddr === "::1" ||
- remoteAddr === "::ffff:127.0.0.1"
- );
+export function isCorsOriginAllowed(
+ req: Request,
+ origin: string | undefined,
+): boolean {
+ if (!origin) return true;
+ 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(
@@ -44,23 +49,7 @@ export function createCorsMiddleware(
return (req: Request, res: Response, next: NextFunction) => {
const handler = cors({
origin: (origin, callback) => {
- // No origin = same-origin or non-browser request (curl, internal service calls)
- 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);
-
+ if (isCorsOriginAllowed(req, origin)) return callback(null, true);
callback(new Error("Not allowed by CORS"));
},
credentials: true,
diff --git a/src/backend/utils/request-origin.ts b/src/backend/utils/request-origin.ts
index 71d457d5..3ab2e78c 100644
--- a/src/backend/utils/request-origin.ts
+++ b/src/backend/utils/request-origin.ts
@@ -64,15 +64,8 @@ export function normalizeBasePath(value: unknown): string {
return basePath.replace(/\/+$/, "");
}
-/**
- * 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).
- */
+/** Real client IP after Express has applied its configured proxy trust policy. */
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;
return req.socket?.remoteAddress ?? "unknown";
diff --git a/src/ui/lib/base-path.ts b/src/ui/lib/base-path.ts
index a06aad52..5168242a 100644
--- a/src/ui/lib/base-path.ts
+++ b/src/ui/lib/base-path.ts
@@ -1,6 +1,10 @@
export function getBasePath(): string {
- const runtime = (window as unknown as Record)
- .__TERMIX_BASE_PATH__ as string | undefined;
+ const runtime =
+ document
+ .querySelector('meta[name="termix-base-path"]')
+ ?.content.trim() ||
+ ((window as unknown as Record).__TERMIX_BASE_PATH__ as
+ string | undefined);
if (runtime) {
return runtime.endsWith("/") ? runtime.slice(0, -1) : runtime;
}
diff --git a/src/ui/tests/lib/base-path.test.ts b/src/ui/tests/lib/base-path.test.ts
index 65ad00a4..8431cb26 100644
--- a/src/ui/tests/lib/base-path.test.ts
+++ b/src/ui/tests/lib/base-path.test.ts
@@ -5,6 +5,7 @@ const win = window as unknown as Record;
afterEach(() => {
delete win.__TERMIX_BASE_PATH__;
+ document.querySelector('meta[name="termix-base-path"]')?.remove();
});
describe("getBasePath", () => {
@@ -18,6 +19,15 @@ describe("getBasePath", () => {
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", () => {
win.__TERMIX_BASE_PATH__ = "/termix/";
expect(getBasePath()).toBe("/termix");