proxy-backend: require auth by default
Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
@@ -19,7 +19,7 @@ import {
|
||||
createBackendPlugin,
|
||||
coreServices,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { createRouter } from './service/router';
|
||||
import { createRouterInternal } from './service/router';
|
||||
|
||||
/**
|
||||
* The proxy backend plugin.
|
||||
@@ -37,16 +37,11 @@ export default createBackendPlugin({
|
||||
httpRouter: coreServices.httpRouter,
|
||||
},
|
||||
async init({ config, discovery, logger, httpRouter }) {
|
||||
httpRouter.use(
|
||||
await createRouter({
|
||||
config,
|
||||
discovery,
|
||||
logger: loggerToWinstonLogger(logger),
|
||||
}),
|
||||
);
|
||||
httpRouter.addAuthPolicy({
|
||||
allow: 'unauthenticated',
|
||||
path: '/',
|
||||
await createRouterInternal({
|
||||
config,
|
||||
discovery,
|
||||
logger: loggerToWinstonLogger(logger),
|
||||
httpRouterService: httpRouter,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -27,7 +27,7 @@ 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
|
||||
// http-proxy-middleware module used in the main test file
|
||||
|
||||
describe('createRouter reloadable configuration', () => {
|
||||
const server = setupServer(
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
/*
|
||||
* Copyright 2024 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 { createBackend } from '@backstage/backend-defaults';
|
||||
import {
|
||||
mockServices,
|
||||
setupRequestMockHandlers,
|
||||
} from '@backstage/backend-test-utils';
|
||||
import { ResponseError } from '@backstage/errors';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { rest } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
import fetch from 'node-fetch';
|
||||
import portFinder from 'portfinder';
|
||||
|
||||
// this test is stored in its own file to work around the mocked
|
||||
// http-proxy-middleware module used in the main test file
|
||||
|
||||
describe('credentials', () => {
|
||||
const worker = setupServer();
|
||||
setupRequestMockHandlers(worker);
|
||||
|
||||
it('handles all valid credentials settings', async () => {
|
||||
const host = 'localhost';
|
||||
const port = await portFinder.getPortPromise();
|
||||
const baseUrl = `http://${host}:${port}`;
|
||||
|
||||
const config = {
|
||||
backend: {
|
||||
baseUrl,
|
||||
listen: { host, port },
|
||||
auth: {
|
||||
externalAccess: [
|
||||
{
|
||||
type: 'static',
|
||||
options: {
|
||||
token: 'static-token',
|
||||
subject: 'static-subject',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
proxy: {
|
||||
endpoints: {
|
||||
'/simple': 'http://target.com',
|
||||
'/default': {
|
||||
target: 'http://target.com',
|
||||
},
|
||||
'/require': {
|
||||
target: 'http://target.com',
|
||||
credentials: 'require',
|
||||
},
|
||||
'/forward': {
|
||||
target: 'http://target.com',
|
||||
credentials: 'forward',
|
||||
},
|
||||
'/dangerously-allow-unauthenticated--no-forward': {
|
||||
target: 'http://target.com',
|
||||
credentials: 'dangerously-allow-unauthenticated',
|
||||
},
|
||||
'/dangerously-allow-unauthenticated--with-forward': {
|
||||
target: 'http://target.com',
|
||||
credentials: 'dangerously-allow-unauthenticated',
|
||||
allowedHeaders: ['Authorization'],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
worker.use(
|
||||
rest.all(`${baseUrl}/*`, req => req.passthrough()),
|
||||
rest.get('http://target.com/*', (req, res, ctx) => {
|
||||
const auth = req.headers.get('authorization');
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({ payload: { forwardedAuthorization: auth ?? false } }),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
async function call(options: {
|
||||
endpoint: string;
|
||||
authorization: string | false;
|
||||
}): Promise<JsonObject> {
|
||||
const { endpoint, authorization } = options;
|
||||
return fetch(`${baseUrl}/api/proxy/${endpoint}/just-some-path`, {
|
||||
headers: authorization ? { Authorization: authorization } : {},
|
||||
}).then(async res => {
|
||||
if (!res.ok) {
|
||||
throw await ResponseError.fromResponse(res);
|
||||
}
|
||||
return res.json();
|
||||
});
|
||||
}
|
||||
|
||||
// Create an actual backend instead of a test backend, because we want to
|
||||
// use the real HTTP server that provides the protection middleware etc. A
|
||||
// bit harder to test, but at least we can use static external access tokens
|
||||
// for it.
|
||||
const backend = createBackend();
|
||||
backend.add(import('../alpha'));
|
||||
backend.add(mockServices.rootConfig.factory({ data: config }));
|
||||
backend.add(mockServices.rootLogger.factory());
|
||||
await backend.start();
|
||||
|
||||
try {
|
||||
// simple credentials config
|
||||
await expect(
|
||||
call({ endpoint: 'simple', authorization: false }),
|
||||
).rejects.toMatchObject({
|
||||
body: {
|
||||
error: {
|
||||
message: 'Missing credentials',
|
||||
name: 'AuthenticationError',
|
||||
},
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
call({ endpoint: 'simple', authorization: 'Bearer static-token' }),
|
||||
).resolves.toMatchObject({
|
||||
payload: {
|
||||
forwardedAuthorization: false,
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
call({ endpoint: 'simple', authorization: 'Bearer not-valid' }),
|
||||
).rejects.toMatchObject({
|
||||
body: {
|
||||
error: {
|
||||
message: 'Illegal token',
|
||||
name: 'AuthenticationError',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// default credentials config
|
||||
await expect(
|
||||
call({ endpoint: 'default', authorization: false }),
|
||||
).rejects.toMatchObject({
|
||||
body: {
|
||||
error: {
|
||||
message: 'Missing credentials',
|
||||
name: 'AuthenticationError',
|
||||
},
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
call({ endpoint: 'default', authorization: 'Bearer static-token' }),
|
||||
).resolves.toMatchObject({
|
||||
payload: {
|
||||
forwardedAuthorization: false,
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
call({ endpoint: 'default', authorization: 'Bearer not-valid' }),
|
||||
).rejects.toMatchObject({
|
||||
body: {
|
||||
error: {
|
||||
message: 'Illegal token',
|
||||
name: 'AuthenticationError',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// require credentials config
|
||||
await expect(
|
||||
call({ endpoint: 'require', authorization: false }),
|
||||
).rejects.toMatchObject({
|
||||
body: {
|
||||
error: {
|
||||
message: 'Missing credentials',
|
||||
name: 'AuthenticationError',
|
||||
},
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
call({ endpoint: 'require', authorization: 'Bearer static-token' }),
|
||||
).resolves.toMatchObject({
|
||||
payload: {
|
||||
forwardedAuthorization: false,
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
call({ endpoint: 'require', authorization: 'Bearer not-valid' }),
|
||||
).rejects.toMatchObject({
|
||||
body: {
|
||||
error: {
|
||||
message: 'Illegal token',
|
||||
name: 'AuthenticationError',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// forward credentials config
|
||||
await expect(
|
||||
call({ endpoint: 'forward', authorization: false }),
|
||||
).rejects.toMatchObject({
|
||||
body: {
|
||||
error: {
|
||||
message: 'Missing credentials',
|
||||
name: 'AuthenticationError',
|
||||
},
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
call({ endpoint: 'forward', authorization: 'Bearer static-token' }),
|
||||
).resolves.toMatchObject({
|
||||
payload: {
|
||||
forwardedAuthorization: 'Bearer static-token',
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
call({ endpoint: 'forward', authorization: 'Bearer not-valid' }),
|
||||
).rejects.toMatchObject({
|
||||
body: {
|
||||
error: {
|
||||
message: 'Illegal token',
|
||||
name: 'AuthenticationError',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// dangerously-allow-unauthenticated credentials config, no forwarding
|
||||
await expect(
|
||||
call({
|
||||
endpoint: 'dangerously-allow-unauthenticated--no-forward',
|
||||
authorization: false,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
payload: {
|
||||
forwardedAuthorization: false,
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
call({
|
||||
endpoint: 'dangerously-allow-unauthenticated--no-forward',
|
||||
authorization: 'Bearer static-token',
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
payload: {
|
||||
forwardedAuthorization: false,
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
call({
|
||||
endpoint: 'dangerously-allow-unauthenticated--no-forward',
|
||||
authorization: 'Bearer not-valid',
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
payload: {
|
||||
forwardedAuthorization: false,
|
||||
},
|
||||
});
|
||||
|
||||
// dangerously-allow-unauthenticated credentials config, with forwarding
|
||||
await expect(
|
||||
call({
|
||||
endpoint: 'dangerously-allow-unauthenticated--with-forward',
|
||||
authorization: false,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
payload: {
|
||||
forwardedAuthorization: false,
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
call({
|
||||
endpoint: 'dangerously-allow-unauthenticated--with-forward',
|
||||
authorization: 'Bearer static-token',
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
payload: {
|
||||
forwardedAuthorization: 'Bearer static-token',
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
call({
|
||||
endpoint: 'dangerously-allow-unauthenticated--with-forward',
|
||||
authorization: 'Bearer not-valid-for-backstage-but-valid-for-target',
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
payload: {
|
||||
forwardedAuthorization:
|
||||
'Bearer not-valid-for-backstage-but-valid-for-target',
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await backend.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
import { Logger } from 'winston';
|
||||
import http from 'http';
|
||||
import { PluginEndpointDiscovery } from '@backstage/backend-common';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { HttpRouterService } from '@backstage/backend-plugin-api';
|
||||
|
||||
// A list of headers that are always forwarded to the proxy targets.
|
||||
const safeForwardHeaders = [
|
||||
@@ -71,9 +73,37 @@ export function buildMiddleware(
|
||||
route: string,
|
||||
config: string | ProxyConfig,
|
||||
reviveConsumedRequestBodies?: boolean,
|
||||
httpRouterService?: HttpRouterService,
|
||||
): RequestHandler {
|
||||
const fullConfig =
|
||||
typeof config === 'string' ? { target: config } : { ...config };
|
||||
let fullConfig: ProxyConfig;
|
||||
let credentialsPolicy: string;
|
||||
if (typeof config === 'string') {
|
||||
fullConfig = { target: config };
|
||||
credentialsPolicy = 'require';
|
||||
} else {
|
||||
const { credentials, ...rest } = config as any;
|
||||
fullConfig = rest;
|
||||
credentialsPolicy = credentials ?? 'require';
|
||||
}
|
||||
|
||||
const credentialsPolicyCandidates = [
|
||||
'require',
|
||||
'forward',
|
||||
'dangerously-allow-unauthenticated',
|
||||
];
|
||||
if (!credentialsPolicyCandidates.includes(credentialsPolicy)) {
|
||||
const valid = credentialsPolicyCandidates.map(c => `'${c}'`).join(', ');
|
||||
throw new Error(
|
||||
`Unknown credentials policy '${credentialsPolicy}' for proxy route '${route}'; expected one of ${valid}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (credentialsPolicy === 'dangerously-allow-unauthenticated') {
|
||||
httpRouterService?.addAuthPolicy({
|
||||
path: route,
|
||||
allow: 'unauthenticated',
|
||||
});
|
||||
}
|
||||
|
||||
// Validate that target is a valid URL.
|
||||
const targetType = typeof fullConfig.target;
|
||||
@@ -144,6 +174,10 @@ export function buildMiddleware(
|
||||
].map(h => h.toLocaleLowerCase()),
|
||||
);
|
||||
|
||||
if (credentialsPolicy === 'forward') {
|
||||
requestHeaderAllowList.add('authorization');
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -194,13 +228,15 @@ export function buildMiddleware(
|
||||
return createProxyMiddleware(filter, fullConfig);
|
||||
}
|
||||
|
||||
function readProxyConfig(config: Config, logger: Logger): unknown {
|
||||
const endpoints = config.getOptionalConfig('proxy.endpoints')?.get();
|
||||
function readProxyConfig(config: Config, logger: Logger): JsonObject {
|
||||
const endpoints = config
|
||||
.getOptionalConfig('proxy.endpoints')
|
||||
?.get<JsonObject>();
|
||||
if (endpoints) {
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
const root = config.getOptionalConfig('proxy')?.get();
|
||||
const root = config.getOptionalConfig('proxy')?.get<JsonObject>();
|
||||
if (!root) {
|
||||
return {};
|
||||
}
|
||||
@@ -220,25 +256,37 @@ function readProxyConfig(config: Config, logger: Logger): unknown {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* Creates a new
|
||||
* {@link https://expressjs.com/en/api.html#router | "express router"} that
|
||||
* proxies each target configured under the `proxy.endpoints` key of the config.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* Example configuration:
|
||||
*
|
||||
* ```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}
|
||||
*```
|
||||
* endpoints:
|
||||
* # Option 1: Simple URL String
|
||||
* simple-example: http://simple.example.com:8080
|
||||
* # Option 2: `http-proxy-middleware` compatible object
|
||||
* '/larger-example/v1':
|
||||
* 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<express.Router> {
|
||||
return createRouterInternal(options);
|
||||
}
|
||||
|
||||
export async function createRouterInternal(
|
||||
options: RouterOptions & { httpRouterService?: HttpRouterService },
|
||||
): Promise<express.Router> {
|
||||
const router = Router();
|
||||
let currentRouter = Router();
|
||||
@@ -261,7 +309,13 @@ export async function createRouter(
|
||||
const { pathname: pathPrefix } = new URL(externalUrl);
|
||||
|
||||
const proxyConfig = readProxyConfig(options.config, options.logger);
|
||||
configureMiddlewares(proxyOptions, currentRouter, pathPrefix, proxyConfig);
|
||||
configureMiddlewares(
|
||||
proxyOptions,
|
||||
currentRouter,
|
||||
pathPrefix,
|
||||
proxyConfig,
|
||||
options.httpRouterService,
|
||||
);
|
||||
router.use((...args) => currentRouter(...args));
|
||||
|
||||
if (options.config.subscribe) {
|
||||
@@ -279,11 +333,13 @@ export async function createRouter(
|
||||
currentRouter,
|
||||
pathPrefix,
|
||||
newProxyConfig,
|
||||
options.httpRouterService,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
options.httpRouterService?.use(router);
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -296,6 +352,7 @@ function configureMiddlewares(
|
||||
router: express.Router,
|
||||
pathPrefix: string,
|
||||
proxyConfig: any,
|
||||
httpRouterService?: HttpRouterService,
|
||||
) {
|
||||
Object.entries<any>(proxyConfig).forEach(([route, proxyRouteConfig]) => {
|
||||
try {
|
||||
@@ -307,6 +364,7 @@ function configureMiddlewares(
|
||||
route,
|
||||
proxyRouteConfig,
|
||||
options.reviveConsumedRequestBodies,
|
||||
httpRouterService,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
|
||||
Reference in New Issue
Block a user