From 14114f066cef8c0f9fea66754f2337db01edf75a Mon Sep 17 00:00:00 2001 From: Theofanis Petkos Date: Mon, 19 Jun 2023 18:26:19 +0100 Subject: [PATCH] Replace MustCompile with Compile for non-hardcoded regexes (#238) * Add release yaml to workflows Signed-off-by: thepetk * Remove autogeneration of release notes Signed-off-by: thepetk * Replace MustCompile for dynamic strings Signed-off-by: thepetk * Add expressjs test resource Signed-off-by: thepetk * Add test case for regexp dynamic strings error handling Signed-off-by: thepetk * Remove release yaml from non related issue Signed-off-by: thepetk * Update count of tests Signed-off-by: thepetk --------- Signed-off-by: thepetk --- .../javascript/nodejs/express_detector.go | 5 +- .../javascript/nodejs/nodejs_detector.go | 5 +- go/test/apis/component_recognizer_test.go | 6 +- resources/projects/expressjs/config.d.ts | 65 +++ resources/projects/expressjs/package.json | 56 ++ resources/projects/expressjs/src/index.ts | 24 + resources/projects/expressjs/src/plugin.ts | 57 +++ resources/projects/expressjs/src/run.ts | 33 ++ .../projects/expressjs/src/service/index.ts | 18 + .../src/service/router.config.test.ts | 115 +++++ .../expressjs/src/service/router.test.ts | 480 ++++++++++++++++++ .../projects/expressjs/src/service/router.ts | 274 ++++++++++ .../expressjs/src/service/standaloneServer.ts | 61 +++ .../projects/expressjs/src/setupTests.ts | 17 + 14 files changed, 1213 insertions(+), 3 deletions(-) create mode 100644 resources/projects/expressjs/config.d.ts create mode 100644 resources/projects/expressjs/package.json create mode 100644 resources/projects/expressjs/src/index.ts create mode 100644 resources/projects/expressjs/src/plugin.ts create mode 100644 resources/projects/expressjs/src/run.ts create mode 100644 resources/projects/expressjs/src/service/index.ts create mode 100644 resources/projects/expressjs/src/service/router.config.test.ts create mode 100644 resources/projects/expressjs/src/service/router.test.ts create mode 100644 resources/projects/expressjs/src/service/router.ts create mode 100644 resources/projects/expressjs/src/service/standaloneServer.ts create mode 100644 resources/projects/expressjs/src/setupTests.ts diff --git a/go/pkg/apis/enricher/framework/javascript/nodejs/express_detector.go b/go/pkg/apis/enricher/framework/javascript/nodejs/express_detector.go index 0a158245..4f6760c7 100644 --- a/go/pkg/apis/enricher/framework/javascript/nodejs/express_detector.go +++ b/go/pkg/apis/enricher/framework/javascript/nodejs/express_detector.go @@ -66,7 +66,10 @@ func (e ExpressDetector) DoPortsDetection(component *model.Component, ctx *conte func getPortGroup(content string, matchIndexes []int, portPlaceholder string) string { contentBeforeMatch := content[0:matchIndexes[0]] - re := regexp.MustCompile(`(let|const|var)\s+` + portPlaceholder + `\s*=\s*([^;]*)`) + re, err := regexp.Compile(`(let|const|var)\s+` + portPlaceholder + `\s*=\s*([^;]*)`) + if err != nil { + return "" + } return utils.FindPotentialPortGroup(re, contentBeforeMatch, 2) } diff --git a/go/pkg/apis/enricher/framework/javascript/nodejs/nodejs_detector.go b/go/pkg/apis/enricher/framework/javascript/nodejs/nodejs_detector.go index 5c192988..c5e1086e 100644 --- a/go/pkg/apis/enricher/framework/javascript/nodejs/nodejs_detector.go +++ b/go/pkg/apis/enricher/framework/javascript/nodejs/nodejs_detector.go @@ -47,7 +47,10 @@ func getPortFromScript(root string, getScript packageScriptFunc, regexes []strin } for _, regex := range regexes { - re := regexp.MustCompile(regex) + re, err := regexp.Compile(regex) + if err != nil { + continue + } port := utils.FindPortSubmatch(re, getScript(packageJson), 1) if port != -1 { return port diff --git a/go/test/apis/component_recognizer_test.go b/go/test/apis/component_recognizer_test.go index 434898da..2c88b923 100644 --- a/go/test/apis/component_recognizer_test.go +++ b/go/test/apis/component_recognizer_test.go @@ -92,6 +92,10 @@ func TestComponentDetectionOnAngular(t *testing.T) { isComponentsInProject(t, "angularjs", 1, "typescript", "angularjs") } +func TestComponentDetectionOnExpress(t *testing.T) { + isComponentsInProject(t, "expressjs", 1, "javascript", "expressjs") +} + func TestComponentDetectionOnNextJs(t *testing.T) { isComponentsInProject(t, "nextjs-app", 1, "typescript", "nextjs-app") } @@ -178,7 +182,7 @@ func updateContent(filePath string, data []byte) error { func TestComponentDetectionMultiProjects(t *testing.T) { components := getComponentsFromProject(t, "") - nComps := 32 + nComps := 33 if len(components) != nComps { t.Errorf("Expected " + strconv.Itoa(nComps) + " components but found " + strconv.Itoa(len(components))) } diff --git a/resources/projects/expressjs/config.d.ts b/resources/projects/expressjs/config.d.ts new file mode 100644 index 00000000..410ff86a --- /dev/null +++ b/resources/projects/expressjs/config.d.ts @@ -0,0 +1,65 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export interface Config { + /** + * A list of forwarding-proxies. Each key is a route to match, + * below the prefix that the proxy plugin is mounted on. It must + * start with a '/'. + */ + proxy?: { + [key: string]: + | string + | { + /** + * Target of the proxy. Url string to be parsed with the url module. + */ + target: string; + /** + * Object with extra headers to be added to target requests. + */ + headers?: Partial<{ + /** @visibility secret */ + Authorization: string; + /** @visibility secret */ + authorization: string; + /** @visibility secret */ + 'X-Api-Key': string; + /** @visibility secret */ + 'x-api-key': string; + [key: string]: string; + }>; + /** + * Changes the origin of the host header to the target URL. Default: true. + */ + changeOrigin?: boolean; + /** + * Rewrite target's url path. Object-keys will be used as RegExp to match paths. + * If pathRewrite is not specified, it is set to a single rewrite that removes the entire prefix and route. + */ + pathRewrite?: { [regexp: string]: string }; + /** + * Limit the forwarded HTTP methods, for example allowedMethods: ['GET'] to enforce read-only access. + */ + allowedMethods?: string[]; + /** + * Limit the forwarded HTTP methods. By default, only the headers that are considered safe for CORS + * and headers that are set by the proxy will be forwarded. + */ + allowedHeaders?: string[]; + }; + }; +} diff --git a/resources/projects/expressjs/package.json b/resources/projects/expressjs/package.json new file mode 100644 index 00000000..a55768ff --- /dev/null +++ b/resources/projects/expressjs/package.json @@ -0,0 +1,56 @@ +{ + "name": "expressjs", + "description": "test resources", + "version": "0.2.40-next.2", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts", + "alphaTypes": "dist/index.alpha.d.ts" + }, + "backstage": { + "role": "backend-plugin" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/proxy-backend" + }, + "keywords": [ + "backstage" + ], + "dependencies": { + "@backstage/backend-common": "workspace:^", + "@backstage/backend-plugin-api": "workspace:^", + "@backstage/config": "workspace:^", + "@types/express": "^4.17.6", + "express": "^4.17.1", + "express-promise-router": "^4.1.0", + "http-proxy-middleware": "^2.0.0", + "morgan": "^1.10.0", + "uuid": "^8.0.0", + "winston": "^3.2.1", + "yaml": "^2.0.0", + "yn": "^4.0.0", + "yup": "^0.32.9" + }, + "devDependencies": { + "@backstage/cli": "workspace:^", + "@types/http-proxy-middleware": "^0.19.3", + "@types/supertest": "^2.0.8", + "@types/uuid": "^8.0.0", + "@types/yup": "^0.29.13", + "msw": "^1.0.0", + "supertest": "^6.1.3" + }, + "files": [ + "dist", + "config.d.ts", + "alpha" + ], + "configSchema": "config.d.ts" +} diff --git a/resources/projects/expressjs/src/index.ts b/resources/projects/expressjs/src/index.ts new file mode 100644 index 00000000..2658a557 --- /dev/null +++ b/resources/projects/expressjs/src/index.ts @@ -0,0 +1,24 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * A Backstage backend plugin that helps you set up proxy endpoints in the backend + * + * @packageDocumentation + */ + +export * from './service'; +export { proxyPlugin } from './plugin'; diff --git a/resources/projects/expressjs/src/plugin.ts b/resources/projects/expressjs/src/plugin.ts new file mode 100644 index 00000000..66eeb4a7 --- /dev/null +++ b/resources/projects/expressjs/src/plugin.ts @@ -0,0 +1,57 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { loggerToWinstonLogger } from '@backstage/backend-common'; +import { + createBackendPlugin, + coreServices, +} from '@backstage/backend-plugin-api'; +import { createRouter } from './service/router'; + +/** + * The proxy backend plugin. + * + * @alpha + */ +export const proxyPlugin = createBackendPlugin( + (options?: { + skipInvalidProxies?: boolean; + reviveConsumedRequestBodies?: boolean; + }) => ({ + pluginId: 'proxy', + register(env) { + env.registerInit({ + deps: { + config: coreServices.config, + discovery: coreServices.discovery, + logger: coreServices.logger, + httpRouter: coreServices.httpRouter, + }, + async init({ config, discovery, logger, httpRouter }) { + httpRouter.use( + await createRouter({ + config, + discovery, + logger: loggerToWinstonLogger(logger), + skipInvalidProxies: options?.skipInvalidProxies, + reviveConsumedRequestBodies: options?.reviveConsumedRequestBodies, + }), + ); + }, + }); + }, + }), +); diff --git a/resources/projects/expressjs/src/run.ts b/resources/projects/expressjs/src/run.ts new file mode 100644 index 00000000..0a3ed2b7 --- /dev/null +++ b/resources/projects/expressjs/src/run.ts @@ -0,0 +1,33 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getRootLogger } from '@backstage/backend-common'; +import yn from 'yn'; +import { startStandaloneServer } from './service/standaloneServer'; + +const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7007; +const enableCors = yn(process.env.PLUGIN_CORS, { default: false }); +const logger = getRootLogger(); + +startStandaloneServer({ port, enableCors, logger }).catch(err => { + logger.error(err); + process.exit(1); +}); + +process.on('SIGINT', () => { + logger.info('CTRL+C pressed; exiting.'); + process.exit(0); +}); diff --git a/resources/projects/expressjs/src/service/index.ts b/resources/projects/expressjs/src/service/index.ts new file mode 100644 index 00000000..31d50951 --- /dev/null +++ b/resources/projects/expressjs/src/service/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export type { RouterOptions } from './router'; +export { createRouter } from './router'; diff --git a/resources/projects/expressjs/src/service/router.config.test.ts b/resources/projects/expressjs/src/service/router.config.test.ts new file mode 100644 index 00000000..5f90f847 --- /dev/null +++ b/resources/projects/expressjs/src/service/router.config.test.ts @@ -0,0 +1,115 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getVoidLogger, SingleHostDiscovery } from '@backstage/backend-common'; +import { ConfigReader } from '@backstage/config'; +import express from 'express'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import request from 'supertest'; +import { createRouter } from './router'; + +// this test is stored in its own file to work around the mocked +// http-proxy-middleware module used in the rest of the tests + +describe('createRouter reloadable configuration', () => { + const server = setupServer( + rest.get('https://non-existing-example.com/', (req, res, ctx) => + res( + ctx.status(200), + ctx.json({ + url: req.url.toString(), + headers: req.headers.all(), + }), + ), + ), + ); + + beforeAll(() => + server.listen({ + onUnhandledRequest: ({ headers }, print) => { + if (headers.get('User-Agent') === 'supertest') { + return; + } + print.error(); + }, + }), + ); + + afterAll(() => server.close()); + afterEach(() => server.resetHandlers()); + + it('should be able to observe the config', async () => { + const logger = getVoidLogger(); + + // Grab the subscriber function and use mutable config data to mock a config file change + let subscriber: () => void; + const mutableConfigData: any = { + backend: { + baseUrl: 'http://localhost:7007', + listen: { + port: 7007, + }, + }, + proxy: { + '/test': { + target: 'https://non-existing-example.com', + pathRewrite: { + '.*': '/', + }, + }, + }, + }; + + const mockConfig = Object.assign(new ConfigReader(mutableConfigData), { + subscribe: (s: () => void) => { + subscriber = s; + return { unsubscribe: () => {} }; + }, + }); + + const discovery = SingleHostDiscovery.fromConfig(mockConfig); + const router = await createRouter({ + config: mockConfig, + logger, + discovery, + }); + expect(router).toBeDefined(); + + const app = express(); + app.use(router); + + const agent = request.agent(app); + // this is set to let msw pass test requests through the mock server + agent.set('User-Agent', 'supertest'); + + const response1 = await agent.get('/test'); + + expect(response1.status).toEqual(200); + + mutableConfigData.proxy['/test2'] = { + target: 'https://non-existing-example.com', + pathRewrite: { + '.*': '/', + }, + }; + subscriber!(); + + const response2 = await agent.get('/test2'); + + expect(response2.status).toEqual(200); + }); +}); diff --git a/resources/projects/expressjs/src/service/router.test.ts b/resources/projects/expressjs/src/service/router.test.ts new file mode 100644 index 00000000..9e8c909d --- /dev/null +++ b/resources/projects/expressjs/src/service/router.test.ts @@ -0,0 +1,480 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getVoidLogger, SingleHostDiscovery } from '@backstage/backend-common'; +import { ConfigReader } from '@backstage/config'; +import { Request, Response } from 'express'; +import * as http from 'http'; +import { + createProxyMiddleware, + fixRequestBody, + Options, +} from 'http-proxy-middleware'; +import { buildMiddleware, createRouter } from './router'; + +jest.mock('http-proxy-middleware', () => ({ + createProxyMiddleware: jest.fn(() => () => undefined), + fixRequestBody: jest.fn(), +})); + +const mockCreateProxyMiddleware = createProxyMiddleware as jest.MockedFunction< + typeof createProxyMiddleware +>; + +describe('createRouter', () => { + describe('where all proxy config are valid', () => { + const logger = getVoidLogger(); + const config = new ConfigReader({ + backend: { + baseUrl: 'https://example.com:7007', + listen: { + port: 7007, + }, + }, + proxy: { + '/test': { + target: 'https://example.com', + headers: { + Authorization: 'Bearer supersecret', + }, + }, + }, + }); + const discovery = SingleHostDiscovery.fromConfig(config); + + beforeEach(() => { + mockCreateProxyMiddleware.mockClear(); + }); + + it('works', async () => { + const router = await createRouter({ + config, + logger, + discovery, + }); + expect(router).toBeDefined(); + }); + + it('revives request bodies when set', async () => { + const router = await createRouter({ + config, + logger, + discovery, + reviveConsumedRequestBodies: true, + }); + expect(router).toBeDefined(); + + expect( + mockCreateProxyMiddleware.mock.calls[0][1]?.onProxyReq, + ).toBeDefined(); + expect(mockCreateProxyMiddleware.mock.calls[0][1]?.onProxyReq).toEqual( + fixRequestBody, + ); + }); + + it('does not revive request bodies when not set', async () => { + const router = await createRouter({ + config, + logger, + discovery, + }); + expect(router).toBeDefined(); + + expect( + mockCreateProxyMiddleware.mock.calls[0][1]?.onProxyReq, + ).not.toBeDefined(); + }); + }); + + describe('where buildMiddleware would fail', () => { + it('throws an error if skip failures is not set', async () => { + const logger = getVoidLogger(); + logger.warn = jest.fn(); + const config = new ConfigReader({ + backend: { + baseUrl: 'https://example.com:7007', + listen: { + port: 7007, + }, + }, + // no target would cause the buildMiddleware to fail + proxy: { + '/test': { + headers: { + Authorization: 'Bearer supersecret', + }, + }, + }, + }); + const discovery = SingleHostDiscovery.fromConfig(config); + await expect( + createRouter({ + config, + logger, + discovery, + }), + ).rejects.toThrow(new Error('Proxy target must be a string')); + }); + + it('works if skip failures is set', async () => { + const logger = getVoidLogger(); + logger.warn = jest.fn(); + const config = new ConfigReader({ + backend: { + baseUrl: 'https://example.com:7007', + listen: { + port: 7007, + }, + }, + // no target would cause the buildMiddleware to fail + proxy: { + '/test': { + headers: { + Authorization: 'Bearer supersecret', + }, + }, + }, + }); + const discovery = SingleHostDiscovery.fromConfig(config); + const router = await createRouter({ + config, + logger, + discovery, + skipInvalidProxies: true, + }); + expect((logger.warn as jest.Mock).mock.calls[0][0]).toEqual( + 'skipped configuring /test due to Proxy target must be a string', + ); + expect(router).toBeDefined(); + }); + }); +}); + +describe('buildMiddleware', () => { + const logger = getVoidLogger(); + + beforeEach(() => { + mockCreateProxyMiddleware.mockClear(); + }); + + it('accepts strings prefixed by /', async () => { + buildMiddleware('/proxy', logger, '/test', 'http://mocked'); + + expect(createProxyMiddleware).toHaveBeenCalledTimes(1); + + const [filter, fullConfig] = mockCreateProxyMiddleware.mock.calls[0] as [ + (pathname: string, req: Partial) => boolean, + Options, + ]; + expect(filter('', { method: 'GET', headers: {} })).toBe(true); + expect(filter('', { method: 'POST', headers: {} })).toBe(true); + expect(filter('', { method: 'PUT', headers: {} })).toBe(true); + expect(filter('', { method: 'PATCH', headers: {} })).toBe(true); + expect(filter('', { method: 'DELETE', headers: {} })).toBe(true); + + expect(fullConfig.pathRewrite).toEqual({ '^/proxy/test/?': '/' }); + expect(fullConfig.changeOrigin).toBe(true); + expect(fullConfig.logProvider!(logger)).toBe(logger); + }); + + it('accepts routes not prefixed with / when path is not suffixed with /', async () => { + buildMiddleware('/proxy', logger, 'test', 'http://mocked'); + + expect(createProxyMiddleware).toHaveBeenCalledTimes(1); + + const [filter, fullConfig] = mockCreateProxyMiddleware.mock.calls[0] as [ + (pathname: string, req: Partial) => boolean, + Options, + ]; + expect(filter('', { method: 'GET', headers: {} })).toBe(true); + expect(filter('', { method: 'POST', headers: {} })).toBe(true); + expect(filter('', { method: 'PUT', headers: {} })).toBe(true); + expect(filter('', { method: 'PATCH', headers: {} })).toBe(true); + expect(filter('', { method: 'DELETE', headers: {} })).toBe(true); + + expect(fullConfig.pathRewrite).toEqual({ '^/proxy/test/?': '/' }); + expect(fullConfig.changeOrigin).toBe(true); + expect(fullConfig.logProvider!(logger)).toBe(logger); + }); + + it('accepts routes prefixed with / when path is suffixed with /', async () => { + buildMiddleware('/proxy/', logger, '/test', 'http://mocked'); + + expect(createProxyMiddleware).toHaveBeenCalledTimes(1); + + const [filter, fullConfig] = mockCreateProxyMiddleware.mock.calls[0] as [ + (pathname: string, req: Partial) => boolean, + Options, + ]; + expect(filter('', { method: 'GET', headers: {} })).toBe(true); + expect(filter('', { method: 'POST', headers: {} })).toBe(true); + expect(filter('', { method: 'PUT', headers: {} })).toBe(true); + expect(filter('', { method: 'PATCH', headers: {} })).toBe(true); + expect(filter('', { method: 'DELETE', headers: {} })).toBe(true); + + expect(fullConfig.pathRewrite).toEqual({ '^/proxy/test/?': '/' }); + expect(fullConfig.changeOrigin).toBe(true); + expect(fullConfig.logProvider!(logger)).toBe(logger); + }); + + it('limits allowedMethods', async () => { + buildMiddleware('/proxy', logger, '/test', { + target: 'http://mocked', + allowedMethods: ['GET', 'DELETE'], + }); + + expect(createProxyMiddleware).toHaveBeenCalledTimes(1); + + const [filter, fullConfig] = mockCreateProxyMiddleware.mock.calls[0] as [ + (pathname: string, req: Partial) => boolean, + Options, + ]; + expect(filter('', { method: 'GET', headers: {} })).toBe(true); + expect(filter('', { method: 'POST', headers: {} })).toBe(false); + expect(filter('', { method: 'PUT', headers: {} })).toBe(false); + expect(filter('', { method: 'PATCH', headers: {} })).toBe(false); + expect(filter('', { method: 'DELETE', headers: {} })).toBe(true); + + expect(fullConfig.pathRewrite).toEqual({ '^/proxy/test/?': '/' }); + expect(fullConfig.changeOrigin).toBe(true); + expect(fullConfig.logProvider!(logger)).toBe(logger); + }); + + it('permits default headers', async () => { + buildMiddleware('/proxy', logger, '/test', { + target: 'http://mocked', + }); + + expect(createProxyMiddleware).toHaveBeenCalledTimes(1); + + const [filter] = mockCreateProxyMiddleware.mock.calls[0] as [ + (pathname: string, req: Partial) => boolean, + ]; + + const testHeaders = { + 'cache-control': 'mocked', + 'content-language': 'mocked', + 'content-length': 'mocked', + 'content-type': 'mocked', + expires: 'mocked', + 'last-modified': 'mocked', + pragma: 'mocked', + host: 'mocked', + accept: 'mocked', + 'accept-language': 'mocked', + 'user-agent': 'mocked', + cookie: 'mocked', + } as Partial; + const expectedHeaders = { + ...testHeaders, + } as Partial; + delete expectedHeaders.cookie; + + expect(testHeaders).toBeDefined(); + expect(expectedHeaders).toBeDefined(); + expect(testHeaders).not.toEqual(expectedHeaders); + expect(filter).toBeDefined(); + + filter!('', { method: 'GET', headers: testHeaders }); + + expect(testHeaders).toEqual(expectedHeaders); + }); + + it('permits default and configured headers', async () => { + buildMiddleware('/proxy', logger, '/test', { + target: 'http://mocked', + headers: { + Authorization: 'my-token', + }, + }); + + expect(createProxyMiddleware).toHaveBeenCalledTimes(1); + + const [filter] = mockCreateProxyMiddleware.mock.calls[0] as [ + (pathname: string, req: Partial) => boolean, + ]; + + const testHeaders = { + authorization: 'mocked', + cookie: 'mocked', + } as Partial; + const expectedHeaders = { + ...testHeaders, + } as Partial; + delete expectedHeaders.cookie; + + expect(testHeaders).toBeDefined(); + expect(expectedHeaders).toBeDefined(); + expect(testHeaders).not.toEqual(expectedHeaders); + expect(filter).toBeDefined(); + + filter!('', { method: 'GET', headers: testHeaders }); + + expect(testHeaders).toEqual(expectedHeaders); + }); + + it('permits configured headers', async () => { + buildMiddleware('/proxy', logger, '/test', { + target: 'http://mocked', + allowedHeaders: ['authorization', 'cookie'], + }); + + expect(createProxyMiddleware).toHaveBeenCalledTimes(1); + + const [filter] = mockCreateProxyMiddleware.mock.calls[0] as [ + (pathname: string, req: Partial) => boolean, + ]; + + const testHeaders = { + authorization: 'mocked', + cookie: 'mocked', + 'x-auth-request-user': 'mocked', + } as Partial; + const expectedHeaders = { + ...testHeaders, + } as Partial; + delete expectedHeaders['x-auth-request-user']; + + expect(testHeaders).toBeDefined(); + expect(expectedHeaders).toBeDefined(); + expect(testHeaders).not.toEqual(expectedHeaders); + expect(filter).toBeDefined(); + + filter!('', { method: 'GET', headers: testHeaders }); + + expect(testHeaders).toEqual(expectedHeaders); + }); + + it('responds default headers', async () => { + buildMiddleware('/proxy', logger, '/test', { + target: 'http://mocked', + }); + + expect(createProxyMiddleware).toHaveBeenCalledTimes(1); + + const config = mockCreateProxyMiddleware.mock.calls[0][1] as Options; + + const testClientResponse = { + headers: { + 'cache-control': 'value', + 'content-language': 'value', + 'content-length': 'value', + 'content-type': 'value', + expires: 'value', + 'last-modified': 'value', + pragma: 'value', + 'set-cookie': ['value'], + }, + } as Partial; + + expect(config).toBeDefined(); + expect(config.onProxyRes).toBeDefined(); + + config.onProxyRes!( + testClientResponse as http.IncomingMessage, + {} as Request, + {} as Response, + ); + + expect(Object.keys(testClientResponse.headers!)).toEqual([ + 'cache-control', + 'content-language', + 'content-length', + 'content-type', + 'expires', + 'last-modified', + 'pragma', + ]); + }); + + it('responds configured headers', async () => { + buildMiddleware('/proxy', logger, '/test', { + target: 'http://mocked', + allowedHeaders: ['set-cookie'], + }); + + expect(createProxyMiddleware).toHaveBeenCalledTimes(1); + + const config = mockCreateProxyMiddleware.mock.calls[0][1] as Options; + + const testClientResponse = { + headers: { + 'set-cookie': [], + 'x-auth-request-user': 'asd', + }, + } as Partial; + + expect(config).toBeDefined(); + expect(config.onProxyRes).toBeDefined(); + + config.onProxyRes!( + testClientResponse as http.IncomingMessage, + {} as Request, + {} as Response, + ); + + expect(Object.keys(testClientResponse.headers!)).toEqual(['set-cookie']); + }); + + it('revives request body when configured', async () => { + buildMiddleware( + '/proxy', + logger, + '/test', + { + target: 'http://mocked', + }, + true, + ); + + expect(createProxyMiddleware).toHaveBeenCalledTimes(1); + + const config = mockCreateProxyMiddleware.mock.calls[0][1] as Options; + + expect(config).toBeDefined(); + expect(config.onProxyReq).toBeDefined(); + + config.onProxyReq!( + {} as http.ClientRequest, + {} as Request, + {} as Response, + {}, + ); + expect(fixRequestBody).toHaveBeenCalledTimes(1); + }); + + it('does not revive request body when not configured', async () => { + buildMiddleware('/proxy', logger, '/test', { + target: 'http://mocked', + }); + + expect(createProxyMiddleware).toHaveBeenCalledTimes(1); + + const config = mockCreateProxyMiddleware.mock.calls[0][1] as Options; + + expect(config).toBeDefined(); + expect(config.onProxyReq).not.toBeDefined(); + }); + + it('rejects malformed target URLs', async () => { + expect(() => + buildMiddleware('/proxy', logger, '/test', 'backstage.io'), + ).toThrow(/Proxy target is not a valid URL/); + expect(() => + buildMiddleware('/proxy', logger, '/test', { target: 'backstage.io' }), + ).toThrow(/Proxy target is not a valid URL/); + }); +}); diff --git a/resources/projects/expressjs/src/service/router.ts b/resources/projects/expressjs/src/service/router.ts new file mode 100644 index 00000000..69129656 --- /dev/null +++ b/resources/projects/expressjs/src/service/router.ts @@ -0,0 +1,274 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Config } from '@backstage/config'; +import express from 'express'; +import Router from 'express-promise-router'; +import { + createProxyMiddleware, + fixRequestBody, + Options, + RequestHandler, +} from 'http-proxy-middleware'; +import { Logger } from 'winston'; +import http from 'http'; +import { PluginEndpointDiscovery } from '@backstage/backend-common'; + +// A list of headers that are always forwarded to the proxy targets. +const safeForwardHeaders = [ + // https://fetch.spec.whatwg.org/#cors-safelisted-request-header + 'cache-control', + 'content-language', + 'content-length', + 'content-type', + 'expires', + 'last-modified', + 'pragma', + + // host is overridden by default. if changeOrigin is configured to false, + // we assume this is a intentional and should also be forwarded. + 'host', + + // other headers that we assume to be ok + 'accept', + 'accept-language', + 'user-agent', +]; + +/** @public */ +export interface RouterOptions { + logger: Logger; + config: Config; + discovery: PluginEndpointDiscovery; + skipInvalidProxies?: boolean; + reviveConsumedRequestBodies?: boolean; +} + +export interface ProxyConfig extends Options { + allowedMethods?: string[]; + allowedHeaders?: string[]; + reviveRequestBody?: boolean; +} + +// Creates a proxy middleware, possibly with defaults added on top of the +// given config. +export function buildMiddleware( + pathPrefix: string, + logger: Logger, + route: string, + config: string | ProxyConfig, + reviveConsumedRequestBodies?: boolean, +): RequestHandler { + const fullConfig = + typeof config === 'string' ? { target: config } : { ...config }; + + // Validate that target is a valid URL. + if (typeof fullConfig.target !== 'string') { + throw new Error(`Proxy target must be a string`); + } + try { + // eslint-disable-next-line no-new + new URL(fullConfig.target! as string); + } catch { + throw new Error( + `Proxy target is not a valid URL: ${fullConfig.target ?? ''}`, + ); + } + + // Default is to do a path rewrite that strips out the proxy's path prefix + // and the rest of the route. + if (fullConfig.pathRewrite === undefined) { + let routeWithSlash = route.endsWith('/') ? route : `${route}/`; + + if (!pathPrefix.endsWith('/') && !routeWithSlash.startsWith('/')) { + // Need to insert a / between pathPrefix and routeWithSlash + routeWithSlash = `/${routeWithSlash}`; + } else if (pathPrefix.endsWith('/') && routeWithSlash.startsWith('/')) { + // Never expect this to happen at this point in time as + // pathPrefix is set using `getExternalBaseUrl` which "Returns the + // external HTTP base backend URL for a given plugin, + // **without a trailing slash.**". But in case this changes in future, we + // need to drop a / on either pathPrefix or routeWithSlash + routeWithSlash = routeWithSlash.substring(1); + } + + // The ? makes the slash optional for the rewrite, so that a base path without an ending slash + // will also be matched (e.g. '/sample' and then requesting just '/api/proxy/sample' without an + // ending slash). Otherwise the target gets called with the full '/api/proxy/sample' path + // appended. + fullConfig.pathRewrite = { + [`^${pathPrefix}${routeWithSlash}?`]: '/', + }; + } + + // Default is to update the Host header to the target + if (fullConfig.changeOrigin === undefined) { + fullConfig.changeOrigin = true; + } + + // Attach the logger to the proxy config + fullConfig.logProvider = () => logger; + // http-proxy-middleware uses this log level to check if it should log the + // requests that it proxies. Setting this to the most verbose log level + // ensures that it always logs these requests. Our logger ends up deciding + // if the logs are displayed or not. + fullConfig.logLevel = 'debug'; + + // Only return the allowed HTTP headers to not forward unwanted secret headers + const requestHeaderAllowList = new Set( + [ + // allow all safe headers + ...safeForwardHeaders, + + // allow all headers that are set by the proxy + ...((fullConfig.headers && Object.keys(fullConfig.headers)) || []), + + // allow all configured headers + ...(fullConfig.allowedHeaders || []), + ].map(h => h.toLocaleLowerCase()), + ); + + // Use the custom middleware filter to do two things: + // 1. Remove any headers not in the allow list to stop them being forwarded + // 2. Only permit the allowed HTTP methods if configured + // + // We are filtering the proxy request headers here rather than in + // `onProxyReq` because when global-agent is enabled then `onProxyReq` + // fires _after_ the agent has already sent the headers to the proxy + // target, causing a ERR_HTTP_HEADERS_SENT crash + const filter = (_pathname: string, req: http.IncomingMessage): boolean => { + const headerNames = Object.keys(req.headers); + headerNames.forEach(h => { + if (!requestHeaderAllowList.has(h.toLocaleLowerCase())) { + delete req.headers[h]; + } + }); + + return fullConfig?.allowedMethods?.includes(req.method!) ?? true; + }; + // Makes http-proxy-middleware logs look nicer and include the mount path + filter.toString = () => route; + + // Only forward the allowed HTTP headers to not forward unwanted secret headers + const responseHeaderAllowList = new Set( + [ + // allow all safe headers + ...safeForwardHeaders, + + // allow all configured headers + ...(fullConfig.allowedHeaders || []), + ].map(h => h.toLocaleLowerCase()), + ); + + // only forward the allowed headers in backend->client + fullConfig.onProxyRes = (proxyRes: http.IncomingMessage) => { + const headerNames = Object.keys(proxyRes.headers); + + headerNames.forEach(h => { + if (!responseHeaderAllowList.has(h.toLocaleLowerCase())) { + delete proxyRes.headers[h]; + } + }); + }; + + if (reviveConsumedRequestBodies) { + fullConfig.onProxyReq = fixRequestBody; + } + + return createProxyMiddleware(filter, fullConfig); +} + +/** + * Creates a new {@link https://expressjs.com/en/api.html#router | "express router"} that proxy each target configured under the `proxy` key of the config + * @example + * ```ts + * let router = await createRouter({logger, config, discovery}); + * ``` + * @config + * ```yaml + * proxy: + * simple-example: http://simple.example.com:8080 # Opt 1 Simple URL String + * '/larger-example/v1': # Opt 2 `http-proxy-middleware` compatible object + * target: http://larger.example.com:8080/svc.v1 + * headers: + * Authorization: Bearer ${EXAMPLE_AUTH_TOKEN} + *``` + * @see https://backstage.io/docs/plugins/proxying + * @public + */ +export async function createRouter( + options: RouterOptions, +): Promise { + const router = Router(); + let currentRouter = Router(); + + const externalUrl = await options.discovery.getExternalBaseUrl('proxy'); + const { pathname: pathPrefix } = new URL(externalUrl); + + const proxyConfig = options.config.getOptional('proxy') ?? {}; + configureMiddlewares(options, currentRouter, pathPrefix, proxyConfig); + router.use((...args) => currentRouter(...args)); + + if (options.config.subscribe) { + let currentKey = JSON.stringify(proxyConfig); + + options.config.subscribe(() => { + const newProxyConfig = options.config.getOptional('proxy') ?? {}; + const newKey = JSON.stringify(newProxyConfig); + + if (currentKey !== newKey) { + currentKey = newKey; + currentRouter = Router(); + configureMiddlewares( + options, + currentRouter, + pathPrefix, + newProxyConfig, + ); + } + }); + } + + return router; +} + +function configureMiddlewares( + options: RouterOptions, + router: express.Router, + pathPrefix: string, + proxyConfig: any, +) { + Object.entries(proxyConfig).forEach(([route, proxyRouteConfig]) => { + try { + router.use( + route, + buildMiddleware( + pathPrefix, + options.logger, + route, + proxyRouteConfig, + options.reviveConsumedRequestBodies, + ), + ); + } catch (e) { + if (options.skipInvalidProxies) { + options.logger.warn(`skipped configuring ${route} due to ${e.message}`); + } else { + throw e; + } + } + }); +} diff --git a/resources/projects/expressjs/src/service/standaloneServer.ts b/resources/projects/expressjs/src/service/standaloneServer.ts new file mode 100644 index 00000000..3a840198 --- /dev/null +++ b/resources/projects/expressjs/src/service/standaloneServer.ts @@ -0,0 +1,61 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + createServiceBuilder, + loadBackendConfig, + SingleHostDiscovery, +} from '@backstage/backend-common'; +import { Server } from 'http'; +import { Logger } from 'winston'; +import { createRouter } from './router'; + +export interface ServerOptions { + port: number; + enableCors: boolean; + logger: Logger; +} + +export async function startStandaloneServer( + options: ServerOptions, +): Promise { + const logger = options.logger.child({ service: 'proxy-backend' }); + + logger.debug('Creating application...'); + + const config = await loadBackendConfig({ logger, argv: process.argv }); + const discovery = SingleHostDiscovery.fromConfig(config); + const router = await createRouter({ + config, + logger, + discovery, + }); + let service = createServiceBuilder(module) + .setPort(options.port) + .addRouter('/proxy', router); + if (options.enableCors) { + service = service.enableCors({ origin: 'http://localhost:3000' }); + } + + logger.debug('Starting application server...'); + + return await service.start().catch(err => { + logger.error(err); + process.exit(1); + }); +} + +module.hot?.accept(); diff --git a/resources/projects/expressjs/src/setupTests.ts b/resources/projects/expressjs/src/setupTests.ts new file mode 100644 index 00000000..d3232290 --- /dev/null +++ b/resources/projects/expressjs/src/setupTests.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export {};