diff --git a/src/main/__test__/router.test.ts b/src/main/__test__/router.test.ts index 134de337a7..2edd10cc81 100644 --- a/src/main/__test__/router.test.ts +++ b/src/main/__test__/router.test.ts @@ -3,7 +3,9 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { Router } from "../router"; +import type { LensApiRequest } from "../router"; +import staticFileRouteInjectable from "../routes/static-file-route.injectable"; +import { getDiForUnitTesting } from "../getDiForUnitTesting"; jest.mock("electron", () => ({ app: { @@ -22,19 +24,31 @@ jest.mock("electron", () => ({ })); describe("Router", () => { + let handleStaticFile: (request: LensApiRequest) => Promise; + + beforeEach(async () => { + const di = getDiForUnitTesting({ doGeneralOverrides: true }); + + await di.runSetups(); + + handleStaticFile = di.inject(staticFileRouteInjectable).handler; + }); + it("blocks path traversal attacks", async () => { const response: any = { statusCode: 200, end: jest.fn(), }; - await (Router as any).handleStaticFile({ + const request = { params: { path: "../index.ts", }, response, raw: {}, - }); + } as LensApiRequest; + + await handleStaticFile(request); expect(response.statusCode).toEqual(404); }); @@ -46,17 +60,20 @@ describe("Router", () => { setHeader: jest.fn(), end: jest.fn(), }; + const req: any = { url: "", }; - await (Router as any).handleStaticFile({ + const request = { params: { path: "router.test.ts", }, response, raw: { req }, - }); + } as LensApiRequest; + + await handleStaticFile(request); expect(response.statusCode).toEqual(200); }); diff --git a/src/main/router.ts b/src/main/router.ts index 2dc48d15c5..8e23c2d6c3 100644 --- a/src/main/router.ts +++ b/src/main/router.ts @@ -6,12 +6,8 @@ import Call from "@hapi/call"; import Subtext from "@hapi/subtext"; import type http from "http"; -import type httpProxy from "http-proxy"; import path from "path"; -import { readFile } from "fs-extra"; import type { Cluster } from "../common/cluster/cluster"; -import { appName, publicPath } from "../common/vars"; -import logger from "./logger"; export interface RouterRequestOpts { req: http.IncomingMessage; @@ -44,34 +40,9 @@ export interface LensApiRequest

{ }; } -function getMimeType(filename: string) { - const mimeTypes: Record = { - html: "text/html", - txt: "text/plain", - css: "text/css", - gif: "image/gif", - jpg: "image/jpeg", - png: "image/png", - svg: "image/svg+xml", - js: "application/javascript", - woff2: "font/woff2", - ttf: "font/ttf", - }; - - return mimeTypes[path.extname(filename).slice(1)] || "text/plain"; -} - -interface Dependencies { - httpProxy?: httpProxy; -} - export class Router { protected router = new Call.Router(); - protected static rootPath = path.resolve(__static); - - public constructor(private dependencies: Dependencies) { - this.addRoutes(); - } + static rootPath = path.resolve(__static); public async route(cluster: Cluster, req: http.IncomingMessage, res: http.ServerResponse): Promise { const url = new URL(req.url, "http://localhost"); @@ -111,66 +82,15 @@ export class Router { }; } - protected static async handleStaticFile({ params, response }: LensApiRequest): Promise { - let filePath = params.path; - - for (let retryCount = 0; retryCount < 5; retryCount += 1) { - const asset = path.join(Router.rootPath, filePath); - const normalizedFilePath = path.resolve(asset); - - if (!normalizedFilePath.startsWith(Router.rootPath)) { - response.statusCode = 404; - - return response.end(); - } - - try { - const data = await readFile(asset); - - response.setHeader("Content-Type", getMimeType(asset)); - response.write(data); - response.end(); - } catch (err) { - if (retryCount > 5) { - logger.error("handleStaticFile:", err.toString()); - response.statusCode = 404; - - return response.end(); - } - - filePath = `${publicPath}/${appName}.html`; - } - } - } - addRoute = (route: Route) => { this.router.add({ method: route.method, path: route.path }, (request: LensApiRequest) => { route.handler(request); }); }; - - protected addRoutes() { - // Static assets - if (this.dependencies.httpProxy) { - this.router.add({ method: "get", path: "/{path*}" }, (apiReq: LensApiRequest) => { - const { req, res } = apiReq.raw; - - if (req.url === "/" || !req.url.startsWith("/build/")) { - req.url = `${publicPath}/${appName}.html`; - } - - this.dependencies.httpProxy.web(req, res, { - target: "http://127.0.0.1:8080", - }); - }); - } else { - this.router.add({ method: "get", path: "/{path*}" }, Router.handleStaticFile); - } - } } export interface Route { path: string; method: "get" | "post" | "put" | "patch" | "delete"; - handler: (request: LensApiRequest) => Promise; + handler: (request: LensApiRequest) => void | Promise; } diff --git a/src/main/router/router.injectable.ts b/src/main/router/router.injectable.ts index 2d9ff0d1fe..9ec78191a5 100644 --- a/src/main/router/router.injectable.ts +++ b/src/main/router/router.injectable.ts @@ -4,8 +4,6 @@ */ import { getInjectable, getInjectionToken } from "@ogre-tools/injectable"; import { Router, Route } from "../router"; -import isDevelopmentInjectable from "../../common/vars/is-development.injectable"; -import httpProxy from "http-proxy"; export const routeInjectionToken = getInjectionToken({ id: "route-injection-token", @@ -15,12 +13,7 @@ const routerInjectable = getInjectable({ id: "router", instantiate: (di) => { - const isDevelopment = di.inject(isDevelopmentInjectable); - const proxy = isDevelopment ? httpProxy.createProxy() : undefined; - - const router = new Router({ - httpProxy: proxy, - }); + const router = new Router(); const routes = di.injectMany(routeInjectionToken); diff --git a/src/main/routes/static-file-route.injectable.ts b/src/main/routes/static-file-route.injectable.ts new file mode 100644 index 0000000000..5074abb6c7 --- /dev/null +++ b/src/main/routes/static-file-route.injectable.ts @@ -0,0 +1,110 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type { LensApiRequest, Route } from "../router"; +import logger from "../logger"; +import { routeInjectionToken } from "../router/router.injectable"; +import { + appName, + publicPath, +} from "../../common/vars"; +import path from "path"; +import readFileInjectable from "../../common/fs/read-file.injectable"; +import isDevelopmentInjectable from "../../common/vars/is-development.injectable"; +import httpProxy from "http-proxy"; +import { Router } from "../router"; + +function getMimeType(filename: string) { + const mimeTypes: Record = { + html: "text/html", + txt: "text/plain", + css: "text/css", + gif: "image/gif", + jpg: "image/jpeg", + png: "image/png", + svg: "image/svg+xml", + js: "application/javascript", + woff2: "font/woff2", + ttf: "font/ttf", + }; + + return mimeTypes[path.extname(filename).slice(1)] || "text/plain"; +} + +interface ProductionDependencies { + readFile: (path: string) => Promise; +} + +const handleStaticFileInProduction = + ({ readFile }: ProductionDependencies) => + async ({ params, response }: LensApiRequest): Promise => { + let filePath = params.path; + + for (let retryCount = 0; retryCount < 5; retryCount += 1) { + const asset = path.join(Router.rootPath, filePath); + const normalizedFilePath = path.resolve(asset); + + if (!normalizedFilePath.startsWith(Router.rootPath)) { + response.statusCode = 404; + + return response.end(); + } + + try { + const data = await readFile(asset); + + response.setHeader("Content-Type", getMimeType(asset)); + response.write(data); + response.end(); + } catch (err) { + if (retryCount > 5) { + logger.error("handleStaticFile:", err.toString()); + response.statusCode = 404; + + return response.end(); + } + + filePath = `${publicPath}/${appName}.html`; + } + } + }; + +interface DevelopmentDependencies { + proxy: httpProxy; +} + +const handleStaticFileInDevelopment = + ({ proxy }: DevelopmentDependencies) => + (apiReq: LensApiRequest) => { + const { req, res } = apiReq.raw; + + if (req.url === "/" || !req.url.startsWith("/build/")) { + req.url = `${publicPath}/${appName}.html`; + } + + proxy.web(req, res, { + target: "http://127.0.0.1:8080", + }); + }; + +const staticFileRouteInjectable = getInjectable({ + id: "static-file-route", + + instantiate: (di): Route => { + const isDevelopment = di.inject(isDevelopmentInjectable); + + return { + method: "get", + path: `/{path*}`, + handler: isDevelopment + ? handleStaticFileInDevelopment({ proxy: httpProxy.createProxy() }) + : handleStaticFileInProduction({ readFile: di.inject(readFileInjectable) }), + }; + }, + + injectionToken: routeInjectionToken, +}); + +export default staticFileRouteInjectable;