diff --git a/.changeset/modern-actors-warn.md b/.changeset/modern-actors-warn.md new file mode 100644 index 0000000000..1654201c3a --- /dev/null +++ b/.changeset/modern-actors-warn.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-gateway-backend': minor +--- + +Added hop count tracking to prevent proxy loops. The gateway now tracks the number of proxy hops using the `backstage-gateway-hops` header and rejects requests that exceed 3 hops with a 508 Loop Detected error. diff --git a/plugins/gateway-backend/src/plugin.test.ts b/plugins/gateway-backend/src/plugin.test.ts index 078fde044e..b7619e3dfc 100644 --- a/plugins/gateway-backend/src/plugin.test.ts +++ b/plugins/gateway-backend/src/plugin.test.ts @@ -161,6 +161,21 @@ describe('gateway', () => { expect(data).toEqual({ bar: true }); }); + it('should detect looped requests', async () => { + const response = await fetch( + 'http://localhost:7777/api/nonexistent-plugin/foo', + ); + expect(response.status).toBe(508); + + const data = await response.json(); + expect(data).toEqual({ + error: { + name: 'LoopDetectedError', + message: 'Maximum proxy hop count exceeded (3)', + }, + }); + }); + it('should close the response for sse connections', async () => { const eventSource = new EventSource( 'http://localhost:7777/api/external-plugin/endpoint-sse', diff --git a/plugins/gateway-backend/src/router.ts b/plugins/gateway-backend/src/router.ts index 8263fb27ab..f04bd3ee58 100644 --- a/plugins/gateway-backend/src/router.ts +++ b/plugins/gateway-backend/src/router.ts @@ -23,9 +23,13 @@ import { createProxyMiddleware } from 'http-proxy-middleware'; import { context } from '@opentelemetry/api'; import { getRPCMetadata } from '@opentelemetry/core'; +const MAX_HOPS = 3; +const HOPS_HEADER = 'backstage-gateway-hops'; + export async function createRouter({ discovery, instanceMeta, + logger, }: { discovery: DiscoveryService; instanceMeta: RootInstanceMetadataService; @@ -41,6 +45,12 @@ export async function createRouter({ return discovery.getBaseUrl(pluginId); }, on: { + proxyReq(proxyReq, req: Request<{ pluginId: string }>) { + const currentHops = + parseInt(req.headers[HOPS_HEADER] as string, 10) || 0; + + proxyReq.setHeader(HOPS_HEADER, currentHops + 1); + }, proxyRes(proxyRes, _req, res) { // https://github.com/chimurai/http-proxy-middleware/discussions/765 proxyRes.on('close', () => { @@ -62,6 +72,20 @@ export async function createRouter({ return; } + const currentHops = parseInt(req.headers[HOPS_HEADER] as string, 10) || 0; + if (currentHops >= MAX_HOPS) { + logger.warn( + `Proxy loop detected for plugin '${req.params.pluginId}': request exceeded maximum hop count (${currentHops})`, + ); + res.status(508).json({ + error: { + name: 'LoopDetectedError', + message: `Maximum proxy hop count exceeded (${currentHops})`, + }, + }); + return; + } + const rpcMetadata = getRPCMetadata(context.active()); if (rpcMetadata) { rpcMetadata.route = req.baseUrl;