proxy-backend: require auth by default

Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
Fredrik Adelöw
2024-05-06 13:26:24 +02:00
parent ddb68c5fed
commit 88480e4ef2
10 changed files with 503 additions and 36 deletions
+43
View File
@@ -0,0 +1,43 @@
---
'@backstage/plugin-proxy-backend': minor
---
**BREAKING**: The proxy backend plugin is now protected by Backstage auth, by
default. Unless specifically configured (see below), all proxy endpoints will
reject requests immediately unless a valid Backstage user or service token is
passed along with the request. This aligns the proxy with how other Backstage
backends behave out of the box, and serves to protect your upstreams from
unauthorized access.
A proxy configuration section can now look as follows:
```yaml
proxy:
endpoints:
'/pagerduty':
target: https://api.pagerduty.com
credentials: require # NEW!
headers:
Authorization: Token token=${PAGERDUTY_TOKEN}
```
There are three possible `credentials` settings at this point:
- `require`: Callers must provide Backstage user or service credentials with
each request. The credentials are not forwarded to the proxy target.
- `forward`: Callers must provide Backstage user or service credentials with
each request, and those credentials are forwarded to the proxy target.
- `dangerously-allow-unauthenticated`: No Backstage credentials are required to
access this proxy target. The target can still apply its own credentials
checks, but the proxy will not help block non-Backstage-blessed callers. If
you also add `allowedHeaders: ['Authorization']` to an endpoint configuration,
then the Backstage token (if provided) WILL be forwarded.
The value `dangerously-allow-unauthenticated` was the old default.
The value `require` is the new default, so requests that were previously
permitted may now start resulting in `401 Unauthorized` responses.
If you have proxy endpoints that require unauthenticated access still, please
add `credentials: dangerously-allow-unauthenticated` to their declarations in
your app-config.
@@ -447,6 +447,7 @@ unregistering
unregistration
untracked
upsert
upstreams
upvote
URIs
url
+17 -6
View File
@@ -18,12 +18,7 @@ The plugin is already added to a default Backstage project.
In `packages/backend/src/index.ts`:
```ts
const proxyEnv = useHotMemoize(module, () => createEnv('proxy'));
const service = createServiceBuilder(module)
.loadConfig(configReader)
/** ... other routers ... */
.addRouter('/proxy', await proxy(proxyEnv));
backend.add(import('@backstage/plugin-proxy-backend/alpha'));
```
## Configuration
@@ -40,6 +35,7 @@ proxy:
/simple-example: http://simple.example.com:8080
'/larger-example/v1':
target: http://larger.example.com:8080/svc.v1
credentials: require
headers:
Authorization: ${EXAMPLE_AUTH_HEADER}
# ...or interpolating a value into part of a string,
@@ -56,6 +52,19 @@ backend requests to `/api/proxy/simple-example/...` and
The value inside each route is either a simple URL string, or an object on the
format accepted by
[http-proxy-middleware](https://www.npmjs.com/package/http-proxy-middleware).
Additionally, it has an optional `credentials` key which can have the following
values:
- `require`: Callers must provide Backstage user or service credentials with
each request. The credentials are not forwarded to the proxy target. This is
the default.
- `forward`: Callers must provide Backstage user or service credentials with
each request, and those credentials are forwarded to the proxy target.
- `dangerously-allow-unauthenticated`: No Backstage credentials are required to
access this proxy target. The target can still apply its own credentials
checks, but the proxy will not help block non-Backstage-blessed callers. If
you also add `allowedHeaders: ['Authorization']` to an endpoint configuration,
then the Backstage token (if provided) WILL be forwarded.
If the value is a string, it is assumed to correspond to:
@@ -64,6 +73,7 @@ target: <the string>
changeOrigin: true
pathRewrite:
'^<url prefix><the string>/': '/'
credentials: require
```
When the target is an object, it is given verbatim to `http-proxy-middleware`
@@ -76,6 +86,7 @@ except with the following caveats for convenience:
`'^/api/proxy/larger-example/v1/': '/'` is added. That means that a request to
`/api/proxy/larger-example/v1/some/path` will be translated to a request to
`http://larger.example.com:8080/svc.v1/some/path`.
- If `credentials` is not specified, it is set to `require`.
There are also additional settings:
+44
View File
@@ -71,6 +71,28 @@ export interface Config {
* and headers that are set by the proxy will be forwarded.
*/
allowedHeaders?: string[];
/**
* The credentials policy to apply.
*
* @remarks
*
* The values are as follows:
*
* - 'require': Callers must provide Backstage user or service
* credentials with each request. The credentials are not
* forwarded to the proxy target.
* - 'forward': Callers must provide Backstage user or service
* credentials with each request, and those credentials are
* forwarded to the proxy target.
* - 'dangerously-allow-unauthenticated': No Backstage credentials
* are required to access this proxy target. The target can still
* apply its own credentials checks, but the proxy will not help
* block non-Backstage-blessed callers.
*/
credentials?:
| 'require'
| 'forward'
| 'dangerously-allow-unauthenticated';
};
};
} & {
@@ -121,6 +143,28 @@ export interface Config {
* and headers that are set by the proxy will be forwarded.
*/
allowedHeaders?: string[];
/**
* The credentials policy to apply.
*
* @remarks
*
* The values are as follows:
*
* - 'require': Callers must provide Backstage user or service
* credentials with each request. The credentials are not
* forwarded to the proxy target.
* - 'forward': Callers must provide Backstage user or service
* credentials with each request, and those credentials are
* forwarded to the proxy target.
* - 'dangerously-allow-unauthenticated': No Backstage credentials
* are required to access this proxy target. The target can still
* apply its own credentials checks, but the proxy will not help
* block non-Backstage-blessed callers.
*/
credentials?:
| 'require'
| 'forward'
| 'dangerously-allow-unauthenticated';
};
};
}
+5
View File
@@ -52,6 +52,7 @@
"@backstage/backend-common": "workspace:^",
"@backstage/backend-plugin-api": "workspace:^",
"@backstage/config": "workspace:^",
"@backstage/types": "workspace:^",
"@types/express": "^4.17.6",
"express": "^4.17.1",
"express-promise-router": "^4.1.0",
@@ -64,14 +65,18 @@
"yup": "^1.0.0"
},
"devDependencies": {
"@backstage/backend-defaults": "workspace:^",
"@backstage/backend-test-utils": "workspace:^",
"@backstage/cli": "workspace:^",
"@backstage/config-loader": "workspace:^",
"@backstage/errors": "workspace:^",
"@types/http-proxy-middleware": "^1.0.0",
"@types/supertest": "^2.0.8",
"@types/uuid": "^9.0.0",
"@types/yup": "^0.32.0",
"msw": "^1.0.0",
"node-fetch": "^2.6.7",
"portfinder": "^1.0.32",
"supertest": "^6.1.3"
},
"configSchema": "config.d.ts"
+6 -11
View File
@@ -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();
}
});
});
+76 -18
View File
@@ -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) {
+5
View File
@@ -6468,11 +6468,14 @@ __metadata:
resolution: "@backstage/plugin-proxy-backend@workspace:plugins/proxy-backend"
dependencies:
"@backstage/backend-common": "workspace:^"
"@backstage/backend-defaults": "workspace:^"
"@backstage/backend-plugin-api": "workspace:^"
"@backstage/backend-test-utils": "workspace:^"
"@backstage/cli": "workspace:^"
"@backstage/config": "workspace:^"
"@backstage/config-loader": "workspace:^"
"@backstage/errors": "workspace:^"
"@backstage/types": "workspace:^"
"@types/express": ^4.17.6
"@types/http-proxy-middleware": ^1.0.0
"@types/supertest": ^2.0.8
@@ -6483,6 +6486,8 @@ __metadata:
http-proxy-middleware: ^2.0.0
morgan: ^1.10.0
msw: ^1.0.0
node-fetch: ^2.6.7
portfinder: ^1.0.32
supertest: ^6.1.3
uuid: ^9.0.0
winston: ^3.2.1