Skip to content

Commit de70b89

Browse files
waleedlatif1claude
andcommitted
fix(sap_s4hana): allow versioned service names; tighten proxy SSRF defenses
- Permit ";v=NNNN" suffix on ServiceName regex so the four delivery tools (API_OUTBOUND_DELIVERY_SRV;v=0002, API_INBOUND_DELIVERY_SRV;v=0002) pass schema validation - Restrict subdomain to RFC 1123 label characters and region to lowercase alphanumeric short codes; run the constructed cloud_public host through assertSafeExternalUrl so a crafted subdomain (e.g. "evil.com#") cannot redirect requests carrying SAP credentials - Block RFC-1918 (10/8, 172.16/12, 192.168/16), 127/8, 169.254/16, and 0.0.0.0 via isPrivateIPv4, plus IPv4-mapped IPv6 variants (::ffff:10.0.0.1, ::10.0.0.1) so private internal hosts cannot be reached from baseUrl, tokenUrl, or the resolved cloud_public URL Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 6cfb465 commit de70b89

1 file changed

Lines changed: 50 additions & 7 deletions

File tree

  • apps/sim/app/api/tools/sap_s4hana/proxy

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

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ const ServiceName = z
1818
.string()
1919
.min(1, 'service is required')
2020
.regex(
21-
/^[A-Z][A-Z0-9_]*$/,
22-
'service must be an uppercase OData service name (e.g., API_BUSINESS_PARTNER)'
21+
/^[A-Z][A-Z0-9_]*(;v=\d+)?$/,
22+
'service must be an uppercase OData service name optionally suffixed with ";v=NNNN" (e.g., API_BUSINESS_PARTNER, API_OUTBOUND_DELIVERY_SRV;v=0002)'
2323
)
2424

2525
const ServicePath = z
@@ -29,12 +29,22 @@ const ServicePath = z
2929
message: 'path must not contain ".." or "." segments',
3030
})
3131

32+
const Subdomain = z
33+
.string()
34+
.regex(
35+
/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/i,
36+
'subdomain must contain only letters, digits, and hyphens (1-63 chars)'
37+
)
38+
3239
const ProxyRequestSchema = z
3340
.object({
3441
deploymentType: DeploymentType.default('cloud_public'),
3542
authType: AuthType.default('oauth_client_credentials'),
36-
subdomain: z.string().optional(),
37-
region: z.string().optional(),
43+
subdomain: Subdomain.optional(),
44+
region: z
45+
.string()
46+
.regex(/^[a-z]{2,4}\d{1,3}$/i, 'region must be an SAP BTP region code (e.g., eu10, us30)')
47+
.optional(),
3848
baseUrl: z.string().optional(),
3949
tokenUrl: z.string().optional(),
4050
clientId: z.string().optional(),
@@ -177,6 +187,34 @@ const FORBIDDEN_HOSTS = new Set([
177187
'[fd00:ec2::254]',
178188
])
179189

190+
function isPrivateIPv4(host: string): boolean {
191+
const match = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/)
192+
if (!match) return false
193+
const octets = match.slice(1, 5).map(Number) as [number, number, number, number]
194+
if (octets.some((o) => o < 0 || o > 255)) return false
195+
const [a, b] = octets
196+
if (a === 10) return true
197+
if (a === 172 && b >= 16 && b <= 31) return true
198+
if (a === 192 && b === 168) return true
199+
if (a === 127) return true
200+
if (a === 169 && b === 254) return true
201+
if (a === 0) return true
202+
return false
203+
}
204+
205+
function extractIPv4MappedHost(host: string): string | null {
206+
const stripped = host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host
207+
const lower = stripped.toLowerCase()
208+
const prefixes = ['::ffff:', '::']
209+
for (const prefix of prefixes) {
210+
if (lower.startsWith(prefix)) {
211+
const candidate = lower.slice(prefix.length)
212+
if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(candidate)) return candidate
213+
}
214+
}
215+
return null
216+
}
217+
180218
function checkExternalUrlSafety(
181219
rawUrl: string,
182220
label: string
@@ -194,8 +232,12 @@ function checkExternalUrlSafety(
194232
if (FORBIDDEN_HOSTS.has(host) || FORBIDDEN_HOSTS.has(`[${host}]`)) {
195233
return { ok: false, message: `${label} host is not allowed` }
196234
}
197-
if (host.startsWith('169.254.')) {
198-
return { ok: false, message: `${label} host is not allowed (link-local)` }
235+
if (isPrivateIPv4(host)) {
236+
return { ok: false, message: `${label} host is not allowed (private/loopback range)` }
237+
}
238+
const mapped = extractIPv4MappedHost(host)
239+
if (mapped && isPrivateIPv4(mapped)) {
240+
return { ok: false, message: `${label} host is not allowed (IPv4-mapped private range)` }
199241
}
200242
return { ok: true, url: parsed }
201243
}
@@ -326,7 +368,8 @@ function resolveHost(req: ProxyRequest): string {
326368
const trimmed = req.baseUrl.replace(/\/+$/, '')
327369
return assertSafeExternalUrl(trimmed, 'baseUrl').toString().replace(/\/+$/, '')
328370
}
329-
return `https://${req.subdomain}-api.s4hana.ondemand.com`
371+
const constructed = `https://${req.subdomain}-api.s4hana.ondemand.com`
372+
return assertSafeExternalUrl(constructed, 'subdomain').toString().replace(/\/+$/, '')
330373
}
331374

332375
function buildOdataUrl(req: ProxyRequest, pathOverride?: string): string {

0 commit comments

Comments
 (0)