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");