Files
simstudioai--sim/apps/sim/lib/api/contracts/tools/sap.ts
T
wehub-resource-sync d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

276 lines
8.6 KiB
TypeScript

import { z } from 'zod'
import { genericToolResponseSchema } from '@/lib/api/contracts/tools/shared'
import { defineRouteContract } from '@/lib/api/contracts/types'
const sapHttpMethodSchema = z.enum(['GET', 'POST', 'PATCH', 'PUT', 'DELETE', 'MERGE'])
const sapDeploymentTypeSchema = z.enum(['cloud_public', 'cloud_private', 'on_premise'])
const sapAuthTypeSchema = z.enum(['oauth_client_credentials', 'basic'])
const sapServiceNameSchema = z
.string()
.min(1, 'service is required')
.regex(
/^[A-Z][A-Z0-9_]*(;v=\d+)?$/,
'service must be an uppercase OData service name optionally suffixed with ";v=NNNN" (e.g., API_BUSINESS_PARTNER, API_OUTBOUND_DELIVERY_SRV;v=0002)'
)
const sapServicePathSchema = z
.string()
.min(1, 'path is required')
.refine(
(path) =>
!path.split(/[/\\]/).some((segment) => segment === '..' || segment === '.') &&
!path.includes('?') &&
!path.includes('#') &&
!/%(?:2[eEfF]|5[cC]|3[fF]|23)/.test(path),
{
message:
'path must not contain ".." or "." segments, "?", "#", or percent-encoded path/query/fragment characters',
}
)
const sapSubdomainSchema = z
.string()
.regex(
/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/i,
'subdomain must contain only letters, digits, and hyphens (1-63 chars)'
)
const FORBIDDEN_SAP_HOSTS = new Set([
'localhost',
'0.0.0.0',
'127.0.0.1',
'169.254.169.254',
'metadata.google.internal',
'metadata',
'[::1]',
'[::]',
'[::ffff:127.0.0.1]',
'[fd00:ec2::254]',
])
function isPrivateIPv4(host: string): boolean {
const match = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/)
if (!match) return false
const octets = match.slice(1, 5).map(Number) as [number, number, number, number]
if (octets.some((octet) => octet < 0 || octet > 255)) return false
const [a, b] = octets
if (a === 10) return true
if (a === 172 && b >= 16 && b <= 31) return true
if (a === 192 && b === 168) return true
if (a === 127) return true
if (a === 169 && b === 254) return true
if (a === 0) return true
return false
}
function extractIPv4MappedHost(host: string): string | null {
const stripped = host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host
const lower = stripped.toLowerCase()
for (const prefix of ['::ffff:', '::']) {
if (lower.startsWith(prefix)) {
const candidate = lower.slice(prefix.length)
if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(candidate)) return candidate
}
}
const hexMatch = lower.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/)
if (hexMatch) {
const high = Number.parseInt(hexMatch[1] as string, 16)
const low = Number.parseInt(hexMatch[2] as string, 16)
if (high >= 0 && high <= 0xffff && low >= 0 && low <= 0xffff) {
const a = (high >> 8) & 0xff
const b = high & 0xff
const c = (low >> 8) & 0xff
const d = low & 0xff
return `${a}.${b}.${c}.${d}`
}
}
return null
}
function isPrivateOrLoopbackIPv6(host: string): boolean {
const stripped = host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host
const lower = stripped.toLowerCase()
if (lower === '::' || lower === '::1') return true
if (/^fc[0-9a-f]{2}:/.test(lower) || /^fd[0-9a-f]{2}:/.test(lower)) return true
if (lower.startsWith('fe80:')) return true
return false
}
export function checkSapExternalUrlSafety(
rawUrl: string,
label: string
): { ok: true; url: URL } | { ok: false; message: string } {
let parsed: URL
try {
parsed = new URL(rawUrl)
} catch {
return { ok: false, message: `${label} must be a valid URL` }
}
if (parsed.protocol !== 'https:') {
return { ok: false, message: `${label} must use https://` }
}
const host = parsed.hostname.toLowerCase()
if (FORBIDDEN_SAP_HOSTS.has(host) || FORBIDDEN_SAP_HOSTS.has(`[${host}]`)) {
return { ok: false, message: `${label} host is not allowed` }
}
if (isPrivateIPv4(host)) {
return { ok: false, message: `${label} host is not allowed (private/loopback range)` }
}
const mapped = extractIPv4MappedHost(host)
if (mapped && isPrivateIPv4(mapped)) {
return { ok: false, message: `${label} host is not allowed (IPv4-mapped private range)` }
}
if (isPrivateOrLoopbackIPv6(host)) {
return { ok: false, message: `${label} host is not allowed (IPv6 private/loopback)` }
}
return { ok: true, url: parsed }
}
export function assertSafeSapExternalUrl(rawUrl: string, label: string): URL {
const result = checkSapExternalUrlSafety(rawUrl, label)
if (!result.ok) throw new Error(result.message)
return result.url
}
export const sapS4HanaProxyBodySchema = z
.object({
deploymentType: sapDeploymentTypeSchema.default('cloud_public'),
authType: sapAuthTypeSchema.default('oauth_client_credentials'),
subdomain: sapSubdomainSchema.optional(),
region: z
.string()
.regex(/^[a-z]{2,4}\d{1,3}$/i, 'region must be an SAP BTP region code (e.g., eu10, us30)')
.optional(),
baseUrl: z.string().optional(),
tokenUrl: z.string().optional(),
clientId: z.string().optional(),
clientSecret: z.string().optional(),
username: z.string().optional(),
password: z.string().optional(),
service: sapServiceNameSchema,
path: sapServicePathSchema,
method: sapHttpMethodSchema.default('GET'),
query: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(),
body: z.unknown().optional(),
ifMatch: z.string().optional(),
})
.superRefine((req, ctx) => {
if (req.deploymentType === 'cloud_public') {
if (!req.subdomain) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['subdomain'],
message: 'subdomain is required for cloud_public deployment',
})
}
if (!req.region) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['region'],
message: 'region is required for cloud_public deployment',
})
}
if (req.authType !== 'oauth_client_credentials') {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['authType'],
message: 'cloud_public deployment only supports oauth_client_credentials',
})
}
if (!req.clientId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['clientId'],
message: 'clientId is required',
})
}
if (!req.clientSecret) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['clientSecret'],
message: 'clientSecret is required',
})
}
return
}
if (!req.baseUrl) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['baseUrl'],
message: 'baseUrl is required for cloud_private and on_premise deployments',
})
} else {
const baseUrlCheck = checkSapExternalUrlSafety(req.baseUrl, 'baseUrl')
if (!baseUrlCheck.ok) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['baseUrl'],
message: baseUrlCheck.message,
})
}
}
if (req.authType === 'oauth_client_credentials') {
if (!req.tokenUrl) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['tokenUrl'],
message: 'tokenUrl is required for OAuth on cloud_private/on_premise',
})
} else {
const tokenUrlCheck = checkSapExternalUrlSafety(req.tokenUrl, 'tokenUrl')
if (!tokenUrlCheck.ok) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['tokenUrl'],
message: tokenUrlCheck.message,
})
}
}
if (!req.clientId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['clientId'],
message: 'clientId is required for OAuth',
})
}
if (!req.clientSecret) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['clientSecret'],
message: 'clientSecret is required for OAuth',
})
}
return
}
if (!req.username) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['username'],
message: 'username is required for Basic auth',
})
}
if (!req.password) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['password'],
message: 'password is required for Basic auth',
})
}
})
export type SapS4HanaProxyRequest = z.infer<typeof sapS4HanaProxyBodySchema>
export const sapS4HanaProxyContract = defineRouteContract({
method: 'POST',
path: '/api/tools/sap_s4hana/proxy',
body: sapS4HanaProxyBodySchema,
response: {
mode: 'json',
schema: genericToolResponseSchema,
},
})