1+ import express from 'express' ;
2+ import { rest } from 'msw' ;
3+ import { setupServer } from 'msw/node' ;
4+ import request from 'supertest' ;
5+
16import { mockServices } from '@backstage/backend-test-utils' ;
7+ import { registerMswTestHooks } from '@backstage/test-utils' ;
28
39import {
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+
2150jest . 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+
132223describe ( 'credentialsNeedRefresh' , ( ) => {
133224 beforeEach ( ( ) => {
134225 jest . useFakeTimers ( ) . setSystemTime ( new Date ( '2024-05-05T12:00:00Z' ) ) ;
@@ -180,6 +271,10 @@ describe('credentialsNeedRefresh', () => {
180271describe ( '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} ) ;
0 commit comments