Skip to content

Commit 444e198

Browse files
fix(proxy-sigv4-backend): prevent host override (#27)
Signed-off-by: Alec Jacobs <charles.jacobs@segment.com>
1 parent a0beb0a commit 444e198

4 files changed

Lines changed: 560 additions & 6 deletions

File tree

plugins/proxy-sigv4-backend/package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,13 @@
4545
"@backstage/backend-defaults": "^0.11.0",
4646
"@backstage/backend-test-utils": "^1.6.0",
4747
"@backstage/cli": "^0.33.0",
48+
"@backstage/test-utils": "^1.7.9",
4849
"@smithy/types": "^3.3.0",
4950
"@types/aws4": "^1.11.6",
50-
"@types/express": "^4.17.20"
51+
"@types/express": "^4.17.20",
52+
"@types/supertest": "^7.1.0",
53+
"msw": "^1.0.0",
54+
"supertest": "^7.1.0"
5155
},
5256
"files": [
5357
"dist",

plugins/proxy-sigv4-backend/src/service/router.test.ts

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
1+
import express from 'express';
2+
import { rest } from 'msw';
3+
import { setupServer } from 'msw/node';
4+
import request from 'supertest';
5+
16
import { mockServices } from '@backstage/backend-test-utils';
7+
import { registerMswTestHooks } from '@backstage/test-utils';
28

39
import {
410
buildMiddleware,
511
createRouter,
12+
joinUrl,
613
normalizeRouteConfig,
714
normalizeRoutePath,
815
credentialsNeedRefresh,
@@ -18,6 +25,28 @@ const mockTemporaryCredentials = jest.fn().mockResolvedValue({
1825
secretAccessKey: 'SECRET_ACCESS_KEY',
1926
});
2027

28+
// SSRF_EXAMPLES provides a list of SSRF attack examples and their expected normalized paths
29+
const SSRF_EXAMPLES = [
30+
['//attacker.example.com/', ''],
31+
['%2F/attacker.example.com/', '%2F/attacker.example.com/'],
32+
['%2F%2Fattacker.example.com/', '%2F%2Fattacker.example.com/'],
33+
['\\attacker.example.com/', 'attacker.example.com/'],
34+
['%5C%5Cattacker.example.com/', '%5C%5Cattacker.example.com/'],
35+
['/%2Fattacker.example.com/', '%2Fattacker.example.com/'],
36+
['//trusted.internal@attacker.example.com/', ''],
37+
[
38+
'%2F%2Ftrusted.internal%40attacker.example.com/',
39+
'%2F%2Ftrusted.internal%40attacker.example.com/',
40+
],
41+
['%252F%252Fattacker.example.com/', '%252F%252Fattacker.example.com/'],
42+
[
43+
'\uFF0F\uFF0Fattacker.example.com/',
44+
'%EF%BC%8F%EF%BC%8Fattacker.example.com/',
45+
],
46+
['/%09/attacker.example.com/', '%09/attacker.example.com/'],
47+
['/%0A/attacker.example.com/', '%0A/attacker.example.com/'],
48+
];
49+
2150
jest.mock('@aws-sdk/credential-providers', () => ({
2251
fromNodeProviderChain: jest
2352
.fn()
@@ -129,6 +158,68 @@ describe('normalizeRouteConfig', () => {
129158
});
130159
});
131160

161+
describe('joinUrl', () => {
162+
it('joins a request path onto the base URL', () => {
163+
expect(joinUrl(new URL('https://example.com'), '/foo').toString()).toBe(
164+
'https://example.com/foo',
165+
);
166+
});
167+
168+
it('preserves a path prefix on the base URL', () => {
169+
expect(joinUrl(new URL('https://example.com/api'), '/foo').toString()).toBe(
170+
'https://example.com/api/foo',
171+
);
172+
});
173+
174+
it('collapses a trailing slash on the base URL', () => {
175+
expect(
176+
joinUrl(new URL('https://example.com/api/'), '/foo').toString(),
177+
).toBe('https://example.com/api/foo');
178+
});
179+
180+
it('preserves the query string from the request path', () => {
181+
expect(
182+
joinUrl(new URL('https://example.com'), '/foo?q=1&r=2').toString(),
183+
).toBe('https://example.com/foo?q=1&r=2');
184+
});
185+
186+
it('rejects a host hijack via protocol-relative path', () => {
187+
const joined = joinUrl(new URL('https://example.com'), '//evil.com/foo');
188+
expect(joined.host).toBe('example.com');
189+
expect(joined.toString()).toBe('https://example.com/foo');
190+
});
191+
192+
it('rejects a host hijack via absolute URL in the request path', () => {
193+
const joined = joinUrl(
194+
new URL('https://example.com'),
195+
'https://evil.com/foo',
196+
);
197+
expect(joined.host).toBe('example.com');
198+
expect(joined.toString()).toBe('https://example.com/foo');
199+
});
200+
201+
it('preserves the configured protocol when the base is http', () => {
202+
expect(joinUrl(new URL('http://example.com'), '/foo').toString()).toBe(
203+
'http://example.com/foo',
204+
);
205+
});
206+
207+
it('handles a root request path', () => {
208+
expect(joinUrl(new URL('https://example.com/api'), '/').toString()).toBe(
209+
'https://example.com/api/',
210+
);
211+
});
212+
213+
it.each(SSRF_EXAMPLES)(
214+
'handles a request path that looks like an SSRF attempt: %s',
215+
(path, expectedPath) => {
216+
const joined = joinUrl(new URL('https://example.com'), path);
217+
expect(joined.host).toBe('example.com');
218+
expect(joined.toString()).toBe(`https://example.com/${expectedPath}`);
219+
},
220+
);
221+
});
222+
132223
describe('credentialsNeedRefresh', () => {
133224
beforeEach(() => {
134225
jest.useFakeTimers().setSystemTime(new Date('2024-05-05T12:00:00Z'));
@@ -180,6 +271,10 @@ describe('credentialsNeedRefresh', () => {
180271
describe('buildMiddleware', () => {
181272
const logger = mockServices.rootLogger();
182273

274+
afterEach(() => {
275+
jest.useRealTimers();
276+
});
277+
183278
it('resolves a middleware-like function', async () => {
184279
const mw = await buildMiddleware({
185280
logger,
@@ -366,4 +461,163 @@ describe('createRouter', () => {
366461
expect(router).toBeDefined();
367462
});
368463
});
464+
465+
describe('proxying requests', () => {
466+
const app = express();
467+
const server = setupServer();
468+
registerMswTestHooks(server);
469+
470+
beforeEach(async () => {
471+
const config = mockServices.rootConfig({
472+
data: {
473+
backend: {
474+
baseUrl: 'https://example.com:7007',
475+
listen: {
476+
port: 7007,
477+
},
478+
},
479+
proxysigv4: {
480+
'/test': 'https://example.com',
481+
},
482+
},
483+
});
484+
const router = await createRouter({
485+
config,
486+
logger,
487+
});
488+
app.use(router);
489+
});
490+
491+
it('proxies requests and returns response from target service', async () => {
492+
server.use(
493+
rest.get('https://example.com', (_req, res, ctx) => {
494+
return res(
495+
ctx.status(200),
496+
ctx.json({ message: 'Hello from target!' }),
497+
);
498+
}),
499+
);
500+
501+
const response = await await request(app)
502+
.get('/test/')
503+
.set('x-msw-bypass', 'true');
504+
expect(response.status).toBe(200);
505+
expect(response.body).toEqual({ message: 'Hello from target!' });
506+
});
507+
508+
it('proxies requests and forwards params', async () => {
509+
expect.assertions(3);
510+
server.use(
511+
rest.get('https://example.com', (req, res, ctx) => {
512+
expect(req.url.searchParams.get('param')).toBe('value');
513+
return res(
514+
ctx.status(200),
515+
ctx.json({ message: 'Hello from target!' }),
516+
);
517+
}),
518+
);
519+
520+
const response = await await request(app)
521+
.get('/test/?param=value')
522+
.set('x-msw-bypass', 'true');
523+
expect(response.status).toBe(200);
524+
expect(response.body).toEqual({ message: 'Hello from target!' });
525+
});
526+
527+
it('proxies handles deep paths', async () => {
528+
expect.assertions(3);
529+
server.use(
530+
rest.get('https://example.com/and/nested/paths', (req, res, ctx) => {
531+
expect(req.url.searchParams.get('param')).toBe('value');
532+
return res(
533+
ctx.status(200),
534+
ctx.json({ message: 'Hello from target!' }),
535+
);
536+
}),
537+
);
538+
539+
const response = await await request(app)
540+
.get('/test/and/nested/paths?param=value')
541+
.set('x-msw-bypass', 'true');
542+
expect(response.status).toBe(200);
543+
expect(response.body).toEqual({ message: 'Hello from target!' });
544+
});
545+
546+
it('does not allow for ssrf', async () => {
547+
server.use(
548+
rest.get('https://example.com/', (_req, res, ctx) => {
549+
return res(
550+
ctx.status(200),
551+
ctx.json({ message: 'Hello from target!' }),
552+
);
553+
}),
554+
);
555+
556+
const response = await await request(app)
557+
.get('/test////other.domain.com')
558+
.set('x-msw-bypass', 'true');
559+
expect(response.status).toBe(200);
560+
expect(response.body).toEqual({ message: 'Hello from target!' });
561+
});
562+
563+
it.each(SSRF_EXAMPLES)(
564+
'does not allow for ssrf: %s',
565+
async (path, expectedPath) => {
566+
server.use(
567+
rest.get('https://example.com/*', (req, res, ctx) => {
568+
return res(
569+
ctx.status(404),
570+
ctx.json({ message: `Naughty path! ${req.url.pathname}` }),
571+
);
572+
}),
573+
);
574+
575+
const response = await await request(app)
576+
.get(`/test/${path}`)
577+
.set('x-msw-bypass', 'true');
578+
expect(response.status).toBe(404);
579+
expect(response.body).toEqual({
580+
message: `Naughty path! /${expectedPath}`,
581+
});
582+
},
583+
);
584+
585+
it('normalizes backslashes in the request path', async () => {
586+
server.use(
587+
rest.get('https://example.com/127.0.0.1:3007', (_req, res, ctx) => {
588+
return res(
589+
ctx.status(200),
590+
ctx.json({ message: 'Hello from target!' }),
591+
);
592+
}),
593+
);
594+
595+
const response = await await request(app)
596+
.get('/test/\\127.0.0.1:3007')
597+
.set('x-msw-bypass', 'true');
598+
599+
expect(response.status).toBe(200);
600+
expect(response.body).toEqual({ message: 'Hello from target!' });
601+
});
602+
603+
it('allows valid query params', async () => {
604+
expect.assertions(3);
605+
server.use(
606+
rest.get('https://example.com/', (req, res, ctx) => {
607+
expect(req.url.searchParams.get('q')).toBe('///some.other.domain');
608+
return res(
609+
ctx.status(200),
610+
ctx.json({ message: 'Hello from target!' }),
611+
);
612+
}),
613+
);
614+
615+
const response = await await request(app)
616+
.get('/test?q=///some.other.domain')
617+
.set('x-msw-bypass', 'true');
618+
619+
expect(response.status).toBe(200);
620+
expect(response.body).toEqual({ message: 'Hello from target!' });
621+
});
622+
});
369623
});

plugins/proxy-sigv4-backend/src/service/router.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,24 @@ export const credentialsNeedRefresh = (
119119
credentials.expiration.getTime() - Date.now() <
120120
CREDENTIAL_NEED_REFRESH_BUFFER;
121121

122+
/**
123+
* Joins a request path onto a configured target URL while pinning protocol
124+
* and host to the target. Only `.pathname` and `.search` are read from the
125+
* parsed request URL, so an attacker-supplied `//evil.com/...` or
126+
* `https://evil.com/...` cannot redirect the upstream call.
127+
*
128+
* @internal
129+
*/
130+
export function joinUrl(base: URL, requestPath: string): URL {
131+
const incoming = new URL(requestPath, 'http://placeholder.invalid');
132+
const basePath = base.pathname.replace(/\/+$/, '');
133+
const incomingPath = incoming.pathname.replace(/^\/+/, '/');
134+
const joined = new URL(base.toString());
135+
joined.pathname = basePath + incomingPath;
136+
joined.search = incoming.search;
137+
return joined;
138+
}
139+
122140
/** @internal */
123141
export async function buildMiddleware(
124142
options: MiddlewareOptions,
@@ -189,14 +207,14 @@ export async function buildMiddleware(
189207
) => {
190208
try {
191209
const requestHeaders = filterHeaders(req.headers as HeadersMap);
192-
const targetUrl = new URL(req.url, target);
210+
const targetUrl = joinUrl(new URL(target), req.url);
193211

194212
// request is provided to aws4.sign() and mutated in place for new headers
195213
const request: any = {
196214
method: req.method,
197215
protocol: targetUrl.protocol,
198216
host: targetUrl.host,
199-
path: req.url, // path + search
217+
path: targetUrl.pathname + targetUrl.search,
200218
headers: requestHeaders,
201219
service: service,
202220
region: region,

0 commit comments

Comments
 (0)