@@ -14,6 +14,21 @@ const HttpMethod = z.enum(['GET', 'POST', 'PATCH', 'PUT', 'DELETE', 'MERGE'])
1414const DeploymentType = z . enum ( [ 'cloud_public' , 'cloud_private' , 'on_premise' ] )
1515const AuthType = z . enum ( [ 'oauth_client_credentials' , 'basic' ] )
1616
17+ const ServiceName = z
18+ . string ( )
19+ . min ( 1 , 'service is required' )
20+ . regex (
21+ / ^ [ A - Z ] [ A - Z 0 - 9 _ ] * $ / ,
22+ 'service must be an uppercase OData service name (e.g., API_BUSINESS_PARTNER)'
23+ )
24+
25+ const ServicePath = z
26+ . string ( )
27+ . min ( 1 , 'path is required' )
28+ . refine ( ( p ) => ! p . split ( / [ / \\ ] / ) . some ( ( seg ) => seg === '..' || seg === '.' ) , {
29+ message : 'path must not contain ".." or "." segments' ,
30+ } )
31+
1732const ProxyRequestSchema = z
1833 . object ( {
1934 deploymentType : DeploymentType . default ( 'cloud_public' ) ,
@@ -26,8 +41,8 @@ const ProxyRequestSchema = z
2641 clientSecret : z . string ( ) . optional ( ) ,
2742 username : z . string ( ) . optional ( ) ,
2843 password : z . string ( ) . optional ( ) ,
29- service : z . string ( ) . min ( 1 , 'service is required' ) ,
30- path : z . string ( ) . min ( 1 , 'path is required' ) ,
44+ service : ServiceName ,
45+ path : ServicePath ,
3146 method : HttpMethod . default ( 'GET' ) ,
3247 query : z . record ( z . union ( [ z . string ( ) , z . number ( ) , z . boolean ( ) ] ) ) . optional ( ) ,
3348 body : z . unknown ( ) . optional ( ) ,
@@ -77,6 +92,15 @@ const ProxyRequestSchema = z
7792 path : [ 'baseUrl' ] ,
7893 message : 'baseUrl is required for cloud_private and on_premise deployments' ,
7994 } )
95+ } else {
96+ const baseUrlCheck = checkExternalUrlSafety ( req . baseUrl , 'baseUrl' )
97+ if ( ! baseUrlCheck . ok ) {
98+ ctx . addIssue ( {
99+ code : z . ZodIssueCode . custom ,
100+ path : [ 'baseUrl' ] ,
101+ message : baseUrlCheck . message ,
102+ } )
103+ }
80104 }
81105 if ( req . authType === 'oauth_client_credentials' ) {
82106 if ( ! req . tokenUrl ) {
@@ -85,6 +109,15 @@ const ProxyRequestSchema = z
85109 path : [ 'tokenUrl' ] ,
86110 message : 'tokenUrl is required for OAuth on cloud_private/on_premise' ,
87111 } )
112+ } else {
113+ const tokenUrlCheck = checkExternalUrlSafety ( req . tokenUrl , 'tokenUrl' )
114+ if ( ! tokenUrlCheck . ok ) {
115+ ctx . addIssue ( {
116+ code : z . ZodIssueCode . custom ,
117+ path : [ 'tokenUrl' ] ,
118+ message : tokenUrlCheck . message ,
119+ } )
120+ }
88121 }
89122 if ( ! req . clientId ) {
90123 ctx . addIssue ( {
@@ -127,7 +160,51 @@ interface CachedToken {
127160}
128161
129162const TOKEN_CACHE = new Map < string , CachedToken > ( )
163+ const TOKEN_CACHE_MAX_ENTRIES = 500
130164const TOKEN_SAFETY_WINDOW_MS = 60_000
165+ const OUTBOUND_FETCH_TIMEOUT_MS = 30_000
166+
167+ const FORBIDDEN_HOSTS = new Set ( [
168+ 'localhost' ,
169+ '0.0.0.0' ,
170+ '127.0.0.1' ,
171+ '169.254.169.254' ,
172+ 'metadata.google.internal' ,
173+ 'metadata' ,
174+ '[::1]' ,
175+ '[::]' ,
176+ '[::ffff:127.0.0.1]' ,
177+ '[fd00:ec2::254]' ,
178+ ] )
179+
180+ function checkExternalUrlSafety (
181+ rawUrl : string ,
182+ label : string
183+ ) : { ok : true ; url : URL } | { ok : false ; message : string } {
184+ let parsed : URL
185+ try {
186+ parsed = new URL ( rawUrl )
187+ } catch {
188+ return { ok : false , message : `${ label } must be a valid URL` }
189+ }
190+ if ( parsed . protocol !== 'https:' ) {
191+ return { ok : false , message : `${ label } must use https://` }
192+ }
193+ const host = parsed . hostname . toLowerCase ( )
194+ if ( FORBIDDEN_HOSTS . has ( host ) || FORBIDDEN_HOSTS . has ( `[${ host } ]` ) ) {
195+ return { ok : false , message : `${ label } host is not allowed` }
196+ }
197+ if ( host . startsWith ( '169.254.' ) ) {
198+ return { ok : false , message : `${ label } host is not allowed (link-local)` }
199+ }
200+ return { ok : true , url : parsed }
201+ }
202+
203+ function assertSafeExternalUrl ( rawUrl : string , label : string ) : URL {
204+ const result = checkExternalUrlSafety ( rawUrl , label )
205+ if ( ! result . ok ) throw new Error ( result . message )
206+ return result . url
207+ }
131208
132209function resolveTokenUrl ( req : ProxyRequest ) : string {
133210 if ( req . tokenUrl ) return req . tokenUrl
@@ -138,14 +215,24 @@ function tokenCacheKey(req: ProxyRequest): string {
138215 return `${ resolveTokenUrl ( req ) } ::${ req . clientId ?? '' } `
139216}
140217
218+ function rememberToken ( key : string , token : CachedToken ) : void {
219+ if ( TOKEN_CACHE . has ( key ) ) TOKEN_CACHE . delete ( key )
220+ TOKEN_CACHE . set ( key , token )
221+ while ( TOKEN_CACHE . size > TOKEN_CACHE_MAX_ENTRIES ) {
222+ const oldestKey = TOKEN_CACHE . keys ( ) . next ( ) . value
223+ if ( oldestKey === undefined ) break
224+ TOKEN_CACHE . delete ( oldestKey )
225+ }
226+ }
227+
141228async function fetchAccessToken ( req : ProxyRequest , requestId : string ) : Promise < string > {
142229 const cacheKey = tokenCacheKey ( req )
143230 const cached = TOKEN_CACHE . get ( cacheKey )
144231 if ( cached && cached . expiresAt - TOKEN_SAFETY_WINDOW_MS > Date . now ( ) ) {
145232 return cached . accessToken
146233 }
147234
148- const tokenUrl = resolveTokenUrl ( req )
235+ const tokenUrl = assertSafeExternalUrl ( resolveTokenUrl ( req ) , 'tokenUrl' ) . toString ( )
149236 const basic = Buffer . from ( `${ req . clientId } :${ req . clientSecret } ` ) . toString ( 'base64' )
150237
151238 const response = await fetch ( tokenUrl , {
@@ -156,6 +243,7 @@ async function fetchAccessToken(req: ProxyRequest, requestId: string): Promise<s
156243 Accept : 'application/json' ,
157244 } ,
158245 body : 'grant_type=client_credentials' ,
246+ signal : AbortSignal . timeout ( OUTBOUND_FETCH_TIMEOUT_MS ) ,
159247 } )
160248
161249 if ( ! response . ok ) {
@@ -174,7 +262,7 @@ async function fetchAccessToken(req: ProxyRequest, requestId: string): Promise<s
174262 }
175263
176264 const expiresInMs = ( data . expires_in ?? 3600 ) * 1000
177- TOKEN_CACHE . set ( cacheKey , {
265+ rememberToken ( cacheKey , {
178266 accessToken : data . access_token ,
179267 expiresAt : Date . now ( ) + expiresInMs ,
180268 } )
@@ -218,6 +306,7 @@ async function fetchCsrf(
218306 Accept : 'application/xml' ,
219307 'X-CSRF-Token' : 'Fetch' ,
220308 } ,
309+ signal : AbortSignal . timeout ( OUTBOUND_FETCH_TIMEOUT_MS ) ,
221310 } )
222311
223312 if ( ! response . ok ) {
@@ -234,7 +323,8 @@ async function fetchCsrf(
234323
235324function resolveHost ( req : ProxyRequest ) : string {
236325 if ( req . baseUrl ) {
237- return req . baseUrl . replace ( / \/ + $ / , '' )
326+ const trimmed = req . baseUrl . replace ( / \/ + $ / , '' )
327+ return assertSafeExternalUrl ( trimmed , 'baseUrl' ) . toString ( ) . replace ( / \/ + $ / , '' )
238328 }
239329 return `https://${ req . subdomain } -api.s4hana.ondemand.com`
240330}
@@ -293,6 +383,7 @@ async function callOdata(
293383 method : req . method ,
294384 headers,
295385 body : hasBody ? JSON . stringify ( req . body ) : undefined ,
386+ signal : AbortSignal . timeout ( OUTBOUND_FETCH_TIMEOUT_MS ) ,
296387 } )
297388
298389 const raw = await response . text ( )
0 commit comments