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 export const sapS4HanaProxyContract = defineRouteContract({ method: 'POST', path: '/api/tools/sap_s4hana/proxy', body: sapS4HanaProxyBodySchema, response: { mode: 'json', schema: genericToolResponseSchema, }, })