Skip to content

Commit ff6b4c2

Browse files
waleedlatif1claude
andcommitted
fix(sap_s4hana): catch hex-form IPv4-mapped IPv6 in SSRF check
The WHATWG URL parser normalizes IPv4-mapped IPv6 addresses to hex form (e.g. [::ffff:169.254.169.254] → [::ffff:a9fe:a9fe]), which slipped past the dotted-decimal-only extractor. Decode the trailing two 16-bit hex groups back into IPv4 octets and run them through isPrivateIPv4. Also add isPrivateOrLoopbackIPv6 so pure IPv6 loopback (::, ::1), unique local addresses (fc00::/7), and link-local (fe80::/10) cannot be reached. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent de70b89 commit ff6b4c2

1 file changed

Lines changed: 25 additions & 2 deletions

File tree

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

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

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -205,16 +205,36 @@ function isPrivateIPv4(host: string): boolean {
205205
function extractIPv4MappedHost(host: string): string | null {
206206
const stripped = host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host
207207
const lower = stripped.toLowerCase()
208-
const prefixes = ['::ffff:', '::']
209-
for (const prefix of prefixes) {
208+
for (const prefix of ['::ffff:', '::']) {
210209
if (lower.startsWith(prefix)) {
211210
const candidate = lower.slice(prefix.length)
212211
if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(candidate)) return candidate
213212
}
214213
}
214+
const hexMatch = lower.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/)
215+
if (hexMatch) {
216+
const high = Number.parseInt(hexMatch[1] as string, 16)
217+
const low = Number.parseInt(hexMatch[2] as string, 16)
218+
if (high >= 0 && high <= 0xffff && low >= 0 && low <= 0xffff) {
219+
const a = (high >> 8) & 0xff
220+
const b = high & 0xff
221+
const c = (low >> 8) & 0xff
222+
const d = low & 0xff
223+
return `${a}.${b}.${c}.${d}`
224+
}
225+
}
215226
return null
216227
}
217228

229+
function isPrivateOrLoopbackIPv6(host: string): boolean {
230+
const stripped = host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host
231+
const lower = stripped.toLowerCase()
232+
if (lower === '::' || lower === '::1') return true
233+
if (/^fc[0-9a-f]{2}:/.test(lower) || /^fd[0-9a-f]{2}:/.test(lower)) return true
234+
if (lower.startsWith('fe80:')) return true
235+
return false
236+
}
237+
218238
function checkExternalUrlSafety(
219239
rawUrl: string,
220240
label: string
@@ -239,6 +259,9 @@ function checkExternalUrlSafety(
239259
if (mapped && isPrivateIPv4(mapped)) {
240260
return { ok: false, message: `${label} host is not allowed (IPv4-mapped private range)` }
241261
}
262+
if (isPrivateOrLoopbackIPv6(host)) {
263+
return { ok: false, message: `${label} host is not allowed (IPv6 private/loopback)` }
264+
}
242265
return { ok: true, url: parsed }
243266
}
244267

0 commit comments

Comments
 (0)