Skip to content

Commit 6cfb465

Browse files
waleedlatif1claude
andcommitted
fix(sap_s4hana): address PR review comments
- Validate baseUrl/tokenUrl in Zod schema and at runtime to prevent SSRF (https-only, deny loopback/link-local/cloud-metadata hosts) - Cap proxy token cache at 500 entries with LRU eviction - Add 30s timeout to outbound token, CSRF, and OData fetches - Make parseJsonInput return T | undefined so missing input is type-safe - Reset authType when deploymentType changes and surface OAuth fields whenever auth is not basic, so cloud_public users always see clientId/ clientSecret after switching from a basic-auth private deployment - Reject OData service names that are not uppercase identifiers and paths containing ".." or "." traversal segments Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 7fc1a33 commit 6cfb465

3 files changed

Lines changed: 103 additions & 11 deletions

File tree

apps/sim/app/api/tools/sap_s4hana/proxy/route.ts

Lines changed: 96 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,21 @@ const HttpMethod = z.enum(['GET', 'POST', 'PATCH', 'PUT', 'DELETE', 'MERGE'])
1414
const DeploymentType = z.enum(['cloud_public', 'cloud_private', 'on_premise'])
1515
const 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-Z0-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+
1732
const 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

129162
const TOKEN_CACHE = new Map<string, CachedToken>()
163+
const TOKEN_CACHE_MAX_ENTRIES = 500
130164
const 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

132209
function 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+
141228
async 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

235324
function 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()

apps/sim/blocks/blocks/sap_s4hana.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -697,6 +697,7 @@ export const SapS4HanaBlock: BlockConfig<SapProxyResponse> = {
697697
value: () => 'oauth_client_credentials',
698698
condition: { field: 'deploymentType', value: ['cloud_private', 'on_premise'] },
699699
required: { field: 'deploymentType', value: ['cloud_private', 'on_premise'] },
700+
dependsOn: ['deploymentType'],
700701
},
701702

702703
// Cloud Public: subdomain + region (SAP BTP UAA pattern)
@@ -783,17 +784,17 @@ export const SapS4HanaBlock: BlockConfig<SapProxyResponse> = {
783784
type: 'short-input',
784785
placeholder: 'sb-...!b1234',
785786
password: true,
786-
condition: { field: 'authType', value: 'oauth_client_credentials' },
787-
required: { field: 'authType', value: 'oauth_client_credentials' },
787+
condition: { field: 'authType', value: 'basic', not: true },
788+
required: { field: 'authType', value: 'basic', not: true },
788789
},
789790
{
790791
id: 'clientSecret',
791792
title: 'OAuth Client Secret',
792793
type: 'short-input',
793794
placeholder: 'Client secret from Communication Arrangement',
794795
password: true,
795-
condition: { field: 'authType', value: 'oauth_client_credentials' },
796-
required: { field: 'authType', value: 'oauth_client_credentials' },
796+
condition: { field: 'authType', value: 'basic', not: true },
797+
required: { field: 'authType', value: 'basic', not: true },
797798
},
798799

799800
// Basic credentials (only surfaced on Private/On-Prem + Basic auth)

apps/sim/tools/sap_s4hana/utils.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,9 @@ export function buildEntityQuery(opts: {
4545
return query
4646
}
4747

48-
export function parseJsonInput<T = unknown>(input: unknown, fieldName: string): T {
48+
export function parseJsonInput<T = unknown>(input: unknown, fieldName: string): T | undefined {
4949
if (input === undefined || input === null || input === '') {
50-
return undefined as unknown as T
50+
return undefined
5151
}
5252
if (typeof input === 'object') return input as T
5353
if (typeof input !== 'string') {

0 commit comments

Comments
 (0)