From abb64b8211c9a13cb5a4379828ddf1937495678b Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos <157128530+BillLeoutsakosvl346@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:16:05 -0700 Subject: [PATCH 01/15] feat(oracle-fusion): add shared integration foundation --- apps/docs/components/icons.tsx | 3 + apps/sim/components/icons.tsx | 3 + .../descriptors.test.ts | 48 ++++ .../client-credential-accounts/descriptors.ts | 68 +++++ .../minters/oracle-fusion.test.ts | 92 +++++++ .../minters/oracle-fusion.ts | 68 +++++ .../client-credential-accounts/server.test.ts | 27 ++ .../client-credential-accounts/server.ts | 3 + .../service-account-provider-ids.test.ts | 3 + .../service-account-secret.test.ts | 60 ++++- .../lib/credentials/service-account-secret.ts | 2 +- .../lib/internal/oracle-fusion/client.test.ts | 249 ++++++++++++++++++ apps/sim/lib/internal/oracle-fusion/client.ts | 229 ++++++++++++++++ apps/sim/lib/internal/oracle-fusion/errors.ts | 10 + .../internal/oracle-fusion/protocol.test.ts | 132 ++++++++++ .../lib/internal/oracle-fusion/protocol.ts | 165 ++++++++++++ apps/sim/lib/oauth/credential-service.test.ts | 64 ++++- apps/sim/lib/oauth/credential-service.ts | 16 +- 18 files changed, 1235 insertions(+), 7 deletions(-) create mode 100644 apps/sim/lib/credentials/client-credential-accounts/minters/oracle-fusion.test.ts create mode 100644 apps/sim/lib/credentials/client-credential-accounts/minters/oracle-fusion.ts create mode 100644 apps/sim/lib/internal/oracle-fusion/client.test.ts create mode 100644 apps/sim/lib/internal/oracle-fusion/client.ts create mode 100644 apps/sim/lib/internal/oracle-fusion/errors.ts create mode 100644 apps/sim/lib/internal/oracle-fusion/protocol.test.ts create mode 100644 apps/sim/lib/internal/oracle-fusion/protocol.ts diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index 0c0c8783e1b..5366c3b7681 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -9287,6 +9287,9 @@ export function NetSuiteIcon(props: SVGProps) { ) } +/** Oracle's red oval, shared by Oracle product integrations. */ +export const OracleIcon = NetSuiteIcon + export function WizaIcon(props: SVGProps) { return ( diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index 0c0c8783e1b..5366c3b7681 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -9287,6 +9287,9 @@ export function NetSuiteIcon(props: SVGProps) { ) } +/** Oracle's red oval, shared by Oracle product integrations. */ +export const OracleIcon = NetSuiteIcon + export function WizaIcon(props: SVGProps) { return ( diff --git a/apps/sim/lib/credentials/client-credential-accounts/descriptors.test.ts b/apps/sim/lib/credentials/client-credential-accounts/descriptors.test.ts index 5ea3e35e79d..d705f1df092 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/descriptors.test.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/descriptors.test.ts @@ -7,6 +7,8 @@ import { getClientCredentialAccountDescriptor, NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID, normalizeNetSuiteSuiteTalkOrigin, + normalizeOracleFusionApplicationOrigin, + ORACLE_FUSION_SERVICE_ACCOUNT_PROVIDER_ID, partitionClientCredentialFields, resolveClientCredentialAuthMethod, resolveSalesforceAuthMethod, @@ -19,6 +21,9 @@ const salesforce = getClientCredentialAccountDescriptor(SALESFORCE_SERVICE_ACCOU const box = getClientCredentialAccountDescriptor(BOX_SERVICE_ACCOUNT_PROVIDER_ID)! const zohoDesk = getClientCredentialAccountDescriptor(ZOHO_DESK_SERVICE_ACCOUNT_PROVIDER_ID)! const netSuite = getClientCredentialAccountDescriptor(NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID)! +const oracleFusion = getClientCredentialAccountDescriptor( + ORACLE_FUSION_SERVICE_ACCOUNT_PROVIDER_ID +)! const ids = (fields: { id: string }[]) => fields.map((field) => field.id) @@ -51,6 +56,17 @@ describe('partitionClientCredentialFields', () => { multiline: true, }) }) + + it('reuses the existing fields for an Oracle Fusion integration user', () => { + const { visible, required } = partitionClientCredentialFields(oracleFusion, undefined) + expect(ids(visible)).toEqual(['orgId', 'clientId', 'clientSecret']) + expect(ids(required)).toEqual(['orgId', 'clientId', 'clientSecret']) + expect(oracleFusion.fields).toEqual([ + expect.objectContaining({ id: 'orgId', label: 'Fusion Applications URL', secret: false }), + expect.objectContaining({ id: 'clientId', label: 'Integration username', secret: false }), + expect.objectContaining({ id: 'clientSecret', label: 'Password', secret: true }), + ]) + }) }) describe('Salesforce, which offers two grants', () => { @@ -109,6 +125,38 @@ describe('normalizeNetSuiteSuiteTalkOrigin', () => { }) }) +describe('normalizeOracleFusionApplicationOrigin', () => { + it.each([ + [' https://VISION.fa.us2.oraclecloud.com/ ', 'https://vision.fa.us2.oraclecloud.com'], + ['https://acme-prod.fa.ocs.oraclecloud.com', 'https://acme-prod.fa.ocs.oraclecloud.com'], + [ + 'https://pod.fa.eu-frankfurt-1.oraclecloud.com', + 'https://pod.fa.eu-frankfurt-1.oraclecloud.com', + ], + ])('normalizes the supported application origin %j', (value, expected) => { + expect(normalizeOracleFusionApplicationOrigin(value)).toBe(expected) + }) + + it.each([ + 'http://vision.fa.us2.oraclecloud.com', + 'https://vision.fa.us2.oraclecloud.com/path', + 'https://vision.fa.us2.oraclecloud.com:443', + 'https://vision.fa.us2.oraclecloud.com:8443', + 'https://user@vision.fa.us2.oraclecloud.com', + 'https://user:password@vision.fa.us2.oraclecloud.com', + 'https://vision.fa.us2.oraclecloud.com?tenant=other', + 'https://vision.fa.us2.oraclecloud.com#fragment', + 'https://vision.fa.us2.oraclecloud.com.evil.example', + 'https://vision.fa.us2.oraclecloud.co', + 'https://fusion.example.com', + 'https://fa.us2.oraclecloud.com', + 'https://-vision.fa.us2.oraclecloud.com', + 'https://vision.fa.-us2.oraclecloud.com', + ])('rejects the noncanonical Fusion Applications URL %j', (value) => { + expect(normalizeOracleFusionApplicationOrigin(value)).toBeUndefined() + }) +}) + describe('resolveClientCredentialAuthMethod', () => { it('returns undefined for a provider that declares no method selector', () => { expect(resolveClientCredentialAuthMethod(box, 'jwt_bearer')).toBeUndefined() diff --git a/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts b/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts index dcd4aa26f7d..26d522329e2 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts @@ -111,6 +111,7 @@ export const BOX_SERVICE_ACCOUNT_PROVIDER_ID = 'box-service-account' as const export const SALESFORCE_SERVICE_ACCOUNT_PROVIDER_ID = 'salesforce-service-account' as const export const ZOHO_DESK_SERVICE_ACCOUNT_PROVIDER_ID = 'zoho-desk-service-account' as const export const NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID = 'netsuite-service-account' as const +export const ORACLE_FUSION_SERVICE_ACCOUNT_PROVIDER_ID = 'oracle-fusion-service-account' as const export type ClientCredentialAccountProviderId = | typeof ZOOM_SERVICE_ACCOUNT_PROVIDER_ID @@ -118,6 +119,7 @@ export type ClientCredentialAccountProviderId = | typeof SALESFORCE_SERVICE_ACCOUNT_PROVIDER_ID | typeof ZOHO_DESK_SERVICE_ACCOUNT_PROVIDER_ID | typeof NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID + | typeof ORACLE_FUSION_SERVICE_ACCOUNT_PROVIDER_ID /** * Exact account-specific SuiteTalk origin accepted by NetSuite's OAuth and @@ -154,6 +156,39 @@ export function normalizeNetSuiteSuiteTalkOrigin(rawUrl: string): string | undef } } +/** Canonical Oracle-assigned Fusion Applications origin used by product REST APIs. */ +export const ORACLE_FUSION_APPLICATION_ORIGIN_REGEX = + /^https:\/\/[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.fa\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.oraclecloud\.com$/ + +/** + * Normalizes a Fusion Applications URL to its authoritative HTTPS origin. + * Explicit ports are rejected even when they match HTTPS's default port so a + * saved credential can never silently broaden the accepted endpoint shape. + */ +export function normalizeOracleFusionApplicationOrigin(rawUrl: string): string | undefined { + try { + const trimmed = rawUrl.trim() + const authority = /^https:\/\/([^/?#]+)/i.exec(trimmed)?.[1] + if (!authority || authority.includes(':')) return undefined + const parsed = new URL(trimmed) + if ( + parsed.protocol !== 'https:' || + parsed.port || + parsed.username || + parsed.password || + parsed.search || + parsed.hash || + (parsed.pathname !== '' && parsed.pathname !== '/') || + !ORACLE_FUSION_APPLICATION_ORIGIN_REGEX.test(parsed.origin) + ) { + return undefined + } + return parsed.origin + } catch { + return undefined + } +} + /** * Allowed My Domain host shapes: one org label (optionally with a * `--sandboxName` suffix), an optional partition label (sandbox, develop, @@ -531,6 +566,39 @@ export const CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS: Record< helpText: 'Use the account-specific SuiteTalk URL and the client ID, certificate ID, and private key from one OAuth 2.0 client-credentials mapping.', }, + [ORACLE_FUSION_SERVICE_ACCOUNT_PROVIDER_ID]: { + providerId: ORACLE_FUSION_SERVICE_ACCOUNT_PROVIDER_ID, + serviceLabel: 'Oracle Fusion', + connectNoun: 'integration user', + fields: [ + { + id: 'orgId', + label: 'Fusion Applications URL', + placeholder: 'https://your-environment.fa.ocs.oraclecloud.com', + secret: false, + hintPattern: ORACLE_FUSION_APPLICATION_ORIGIN_REGEX, + hintNormalize: (value) => + normalizeOracleFusionApplicationOrigin(value) ?? value.trim().toLowerCase(), + hintMessage: + 'Expected the Oracle-assigned HTTPS application URL with no path, port, credentials, query, or fragment.', + }, + { + id: 'clientId', + label: 'Integration username', + placeholder: 'Paste the integration username', + secret: false, + }, + { + id: 'clientSecret', + label: 'Password', + placeholder: 'Paste the password', + secret: true, + }, + ], + docsUrl: 'https://docs.oracle.com/en/cloud/saas/applications-common/26b/farca/Quick_Start.html', + helpText: + 'The application URL is validated when saved. Oracle authenticates the integration user on the first product request.', + }, } /** diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/oracle-fusion.test.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/oracle-fusion.test.ts new file mode 100644 index 00000000000..20fe582cd01 --- /dev/null +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/oracle-fusion.test.ts @@ -0,0 +1,92 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' +import { mintOracleFusionServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/oracle-fusion' + +const FIELDS = { + orgId: 'https://vision.fa.us2.oraclecloud.com', + clientId: 'integration-user', + clientSecret: 'password-with-symbols-!@#', +} + +describe('mintOracleFusionServiceAccountToken', () => { + it('derives an opaque Basic credential locally with a five-minute lifetime', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch') + + await expect(mintOracleFusionServiceAccountToken(FIELDS)).resolves.toEqual({ + instanceUrl: FIELDS.orgId, + accessToken: Buffer.from(`${FIELDS.clientId}:${FIELDS.clientSecret}`, 'utf8').toString( + 'base64' + ), + expiresInSeconds: 300, + identity: { + displayName: 'Oracle Fusion vision', + principal: null, + auditMetadata: { oracleFusionApplicationOrigin: FIELDS.orgId }, + storedMetadata: { applicationOrigin: FIELDS.orgId }, + }, + }) + expect(fetchSpy).not.toHaveBeenCalled() + fetchSpy.mockRestore() + }) + + it('normalizes the origin and omits connect-time identity during resolution', async () => { + await expect( + mintOracleFusionServiceAccountToken( + { ...FIELDS, orgId: ' HTTPS://VISION.FA.OCS.ORACLECLOUD.COM/ ' }, + { skipIdentity: true } + ) + ).resolves.toEqual({ + instanceUrl: 'https://vision.fa.ocs.oraclecloud.com', + accessToken: Buffer.from(`${FIELDS.clientId}:${FIELDS.clientSecret}`, 'utf8').toString( + 'base64' + ), + expiresInSeconds: 300, + }) + }) + + it.each([ + 'http://vision.fa.us2.oraclecloud.com', + 'https://vision.fa.us2.oraclecloud.com/path', + 'https://vision.fa.us2.oraclecloud.com:443', + 'https://user:password@vision.fa.us2.oraclecloud.com', + 'https://vision.fa.us2.oraclecloud.com?tenant=other', + 'https://vision.fa.us2.oraclecloud.com#fragment', + 'https://vision.fa.us2.oraclecloud.com.evil.example', + 'https://vanity.example.com', + ])('rejects the unsafe application URL %j without a network probe', async (orgId) => { + const fetchSpy = vi.spyOn(globalThis, 'fetch') + await expect(mintOracleFusionServiceAccountToken({ ...FIELDS, orgId })).rejects.toMatchObject({ + code: 'site_not_found', + status: 400, + }) + expect(fetchSpy).not.toHaveBeenCalled() + fetchSpy.mockRestore() + }) + + it.each([ + ['', FIELDS.clientSecret], + ['user:name', FIELDS.clientSecret], + ['user\nname', FIELDS.clientSecret], + ['u'.repeat(256), FIELDS.clientSecret], + [FIELDS.clientId, ''], + [FIELDS.clientId, 'password\n'], + [FIELDS.clientId, 'p'.repeat(1025)], + ])( + 'rejects malformed local credentials without exposing them', + async (clientId, clientSecret) => { + const error = await mintOracleFusionServiceAccountToken({ + ...FIELDS, + clientId, + clientSecret, + }).catch((caught: unknown) => caught) + expect(error).toMatchObject({ code: 'invalid_credentials', status: 400 }) + const serialized = JSON.stringify(error) + if (clientId) expect(serialized).not.toContain(clientId) + if (clientSecret) expect(serialized).not.toContain(clientSecret) + const encoded = Buffer.from(`${clientId}:${clientSecret}`, 'utf8').toString('base64') + if (encoded) expect(serialized).not.toContain(encoded) + } + ) +}) diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/oracle-fusion.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/oracle-fusion.ts new file mode 100644 index 00000000000..a69e1e49f5d --- /dev/null +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/oracle-fusion.ts @@ -0,0 +1,68 @@ +import { normalizeOracleFusionApplicationOrigin } from '@/lib/credentials/client-credential-accounts/descriptors' +import type { + ClientCredentialAccountFields, + ClientCredentialAccountMintOptions, + ClientCredentialAccountMintResult, +} from '@/lib/credentials/client-credential-accounts/server' +import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' + +const BASIC_CREDENTIAL_CACHE_TTL_SECONDS = 5 * 60 +const ORACLE_FUSION_CREDENTIAL_STEP = 'oracle_fusion_credential_validation' +const USERNAME_MAX_LENGTH = 255 +const PASSWORD_MAX_LENGTH = 1024 +const CONTROL_CHARACTER = /[\u0000-\u001f\u007f]/ + +function invalidCredential(reason: string): TokenServiceAccountValidationError { + return new TokenServiceAccountValidationError('invalid_credentials', 400, { + step: ORACLE_FUSION_CREDENTIAL_STEP, + reason, + }) +} + +/** + * Resolves locally validated Oracle Basic credentials through the shared + * client-credential minter contract. Oracle does not expose a documented, + * privilege-neutral identity probe, so authentication occurs on first use. + */ +export async function mintOracleFusionServiceAccountToken( + fields: ClientCredentialAccountFields, + options?: ClientCredentialAccountMintOptions +): Promise { + const instanceUrl = normalizeOracleFusionApplicationOrigin(fields.orgId) + if (!instanceUrl) { + throw new TokenServiceAccountValidationError('site_not_found', 400, { + step: ORACLE_FUSION_CREDENTIAL_STEP, + reason: 'Fusion Applications URL must be a canonical Oracle-assigned HTTPS origin', + }) + } + + const username = fields.clientId.trim() + const password = fields.clientSecret + if (!username || username.length > USERNAME_MAX_LENGTH || CONTROL_CHARACTER.test(username)) { + throw invalidCredential('integration username is invalid') + } + if (username.includes(':')) { + throw invalidCredential('integration username must not contain a colon') + } + if (!password || password.length > PASSWORD_MAX_LENGTH || CONTROL_CHARACTER.test(password)) { + throw invalidCredential('password is invalid') + } + + const accessToken = Buffer.from(`${username}:${password}`, 'utf8').toString('base64') + const tenant = new URL(instanceUrl).hostname.split('.')[0] + return { + instanceUrl, + accessToken, + expiresInSeconds: BASIC_CREDENTIAL_CACHE_TTL_SECONDS, + ...(!options?.skipIdentity + ? { + identity: { + displayName: `Oracle Fusion ${tenant}`, + principal: null, + auditMetadata: { oracleFusionApplicationOrigin: instanceUrl }, + storedMetadata: { applicationOrigin: instanceUrl }, + }, + } + : {}), + } +} diff --git a/apps/sim/lib/credentials/client-credential-accounts/server.test.ts b/apps/sim/lib/credentials/client-credential-accounts/server.test.ts index c91df17c197..ddf42a8cdc1 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/server.test.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/server.test.ts @@ -117,4 +117,31 @@ describe('parseClientCredentialAccountSecretBlob', () => { ) ).toThrow(MALFORMED) }) + + it('requires the three reused fields for an Oracle Fusion credential blob', () => { + const oracleBlob = blob({ + providerId: 'oracle-fusion-service-account', + orgId: 'https://vision.fa.us2.oraclecloud.com', + clientId: 'integration-user', + clientSecret: 'password', + }) + expect( + parseClientCredentialAccountSecretBlob(oracleBlob, 'oracle-fusion-service-account') + ).toMatchObject({ + orgId: 'https://vision.fa.us2.oraclecloud.com', + clientId: 'integration-user', + clientSecret: 'password', + }) + + expect(() => + parseClientCredentialAccountSecretBlob( + blob({ + providerId: 'oracle-fusion-service-account', + orgId: 'https://vision.fa.us2.oraclecloud.com', + clientSecret: undefined, + }), + 'oracle-fusion-service-account' + ) + ).toThrow(MALFORMED) + }) }) diff --git a/apps/sim/lib/credentials/client-credential-accounts/server.ts b/apps/sim/lib/credentials/client-credential-accounts/server.ts index 10c829c7861..bbbaaf120b0 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/server.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/server.ts @@ -5,6 +5,7 @@ import { getClientCredentialAccountDescriptor, isClientCredentialAccountProviderId, NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID, + ORACLE_FUSION_SERVICE_ACCOUNT_PROVIDER_ID, partitionClientCredentialFields, SALESFORCE_SERVICE_ACCOUNT_PROVIDER_ID, ZOHO_DESK_SERVICE_ACCOUNT_PROVIDER_ID, @@ -12,6 +13,7 @@ import { } from '@/lib/credentials/client-credential-accounts/descriptors' import { mintBoxServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/box' import { mintNetSuiteServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/netsuite' +import { mintOracleFusionServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/oracle-fusion' import { mintSalesforceServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/salesforce' import { mintZohoDeskServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/zoho-desk' import { mintZoomServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/zoom' @@ -130,6 +132,7 @@ const CLIENT_CREDENTIAL_ACCOUNT_MINTERS: Record< [SALESFORCE_SERVICE_ACCOUNT_PROVIDER_ID]: mintSalesforceServiceAccountToken, [ZOHO_DESK_SERVICE_ACCOUNT_PROVIDER_ID]: mintZohoDeskServiceAccountToken, [NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID]: mintNetSuiteServiceAccountToken, + [ORACLE_FUSION_SERVICE_ACCOUNT_PROVIDER_ID]: mintOracleFusionServiceAccountToken, } export function getClientCredentialAccountMinter( diff --git a/apps/sim/lib/credentials/service-account-provider-ids.test.ts b/apps/sim/lib/credentials/service-account-provider-ids.test.ts index 19fa62966df..0f402a4d9da 100644 --- a/apps/sim/lib/credentials/service-account-provider-ids.test.ts +++ b/apps/sim/lib/credentials/service-account-provider-ids.test.ts @@ -16,6 +16,7 @@ describe('isServiceAccountProviderId', () => { expect(isServiceAccountProviderId('notion-service-account')).toBe(true) expect(isServiceAccountProviderId('salesforce-service-account')).toBe(true) expect(isServiceAccountProviderId('netsuite-service-account')).toBe(true) + expect(isServiceAccountProviderId('oracle-fusion-service-account')).toBe(true) }) it('is case- and whitespace-insensitive', () => { @@ -39,6 +40,7 @@ describe('getServiceAccountGatingBlockType', () => { expect(getServiceAccountGatingBlockType('notion-service-account')).toBeNull() expect(getServiceAccountGatingBlockType('google-service-account')).toBeNull() expect(getServiceAccountGatingBlockType('salesforce-service-account')).toBeNull() + expect(getServiceAccountGatingBlockType('oracle-fusion-service-account')).toBeNull() }) }) @@ -52,6 +54,7 @@ describe('getServiceAccountConnectNoun', () => { it('names the client-credential secret', () => { expect(getServiceAccountConnectNoun('zoom-service-account')).toBe('server-to-server app') expect(getServiceAccountConnectNoun('netsuite-service-account')).toBe('OAuth certificate') + expect(getServiceAccountConnectNoun('oracle-fusion-service-account')).toBe('integration user') }) it('calls a custom Slack bot a custom bot', () => { diff --git a/apps/sim/lib/credentials/service-account-secret.test.ts b/apps/sim/lib/credentials/service-account-secret.test.ts index fd11efb6b33..af88b5c2b89 100644 --- a/apps/sim/lib/credentials/service-account-secret.test.ts +++ b/apps/sim/lib/credentials/service-account-secret.test.ts @@ -43,7 +43,8 @@ vi.mock('@/lib/credentials/client-credential-accounts/server', () => ({ getClientCredentialAccountMinter: (providerId: string) => providerId === 'zoom-service-account' || providerId === 'box-service-account' || - providerId === 'netsuite-service-account' + providerId === 'netsuite-service-account' || + providerId === 'oracle-fusion-service-account' ? mockClientCredentialMinter : undefined, })) @@ -261,6 +262,63 @@ describe('verifyAndBuildServiceAccountSecret', () => { }) }) + it('encrypts only the Oracle Fusion fields and captures no unverified principal', async () => { + mockClientCredentialMinter.mockResolvedValue({ + accessToken: 'opaque-basic', + expiresInSeconds: 300, + instanceUrl: 'https://vision.fa.us2.oraclecloud.com', + identity: { + displayName: 'Oracle Fusion vision', + principal: null, + auditMetadata: { + oracleFusionApplicationOrigin: 'https://vision.fa.us2.oraclecloud.com', + }, + storedMetadata: { applicationOrigin: 'https://vision.fa.us2.oraclecloud.com' }, + }, + }) + + const result = await verifyAndBuildServiceAccountSecret('oracle-fusion-service-account', { + orgId: ' https://vision.fa.us2.oraclecloud.com/ ', + clientId: ' integration-user ', + clientSecret: ' password ', + certificateId: 'discard-me', + dataCenter: 'discard-me', + authMethod: 'discard-me', + privateKey: 'discard-me', + username: 'discard-me', + }) + + expect(mockClientCredentialMinter).toHaveBeenCalledWith({ + orgId: 'https://vision.fa.us2.oraclecloud.com/', + clientId: 'integration-user', + clientSecret: 'password', + certificateId: undefined, + dataCenter: undefined, + authMethod: undefined, + privateKey: undefined, + username: undefined, + }) + expect(result).toMatchObject({ + displayName: 'Oracle Fusion vision', + principal: null, + auditMetadata: { + oracleFusionApplicationOrigin: 'https://vision.fa.us2.oraclecloud.com', + principalKind: 'none', + }, + }) + expect(JSON.parse(result.encryptedServiceAccountKey)).toEqual({ + type: 'client_credential_account', + providerId: 'oracle-fusion-service-account', + clientId: 'integration-user', + clientSecret: 'password', + orgId: 'https://vision.fa.us2.oraclecloud.com/', + metadata: { + applicationOrigin: 'https://vision.fa.us2.oraclecloud.com', + principalKind: 'none', + }, + }) + }) + it('throws when client-credential required fields are missing, without minting', async () => { await expect( verifyAndBuildServiceAccountSecret('zoom-service-account', { diff --git a/apps/sim/lib/credentials/service-account-secret.ts b/apps/sim/lib/credentials/service-account-secret.ts index 5c35210cfe3..9bf3cd273c6 100644 --- a/apps/sim/lib/credentials/service-account-secret.ts +++ b/apps/sim/lib/credentials/service-account-secret.ts @@ -299,7 +299,7 @@ async function buildClientCredentialAccountSecret( ? fields.certificateId?.trim() || undefined : undefined, orgId: fields.orgId?.trim() ?? '', - dataCenter: fields.dataCenter?.trim() || undefined, + dataCenter: usesField('dataCenter') ? fields.dataCenter?.trim() || undefined : undefined, authMethod: resolvedAuthMethod, clientSecret: usesField('clientSecret') ? fields.clientSecret?.trim() || undefined : undefined, privateKey: usesField('privateKey') ? fields.privateKey?.trim() || undefined : undefined, diff --git a/apps/sim/lib/internal/oracle-fusion/client.test.ts b/apps/sim/lib/internal/oracle-fusion/client.test.ts new file mode 100644 index 00000000000..139425f2016 --- /dev/null +++ b/apps/sim/lib/internal/oracle-fusion/client.test.ts @@ -0,0 +1,249 @@ +/** + * @vitest-environment node + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockSecureFetch, mockSleep, mockValidateUrl } = vi.hoisted(() => ({ + mockSecureFetch: vi.fn(), + mockSleep: vi.fn(), + mockValidateUrl: vi.fn(), +})) + +vi.mock('@/lib/core/security/input-validation.server', () => ({ + secureFetchWithPinnedIP: mockSecureFetch, + validateUrlWithDNS: mockValidateUrl, +})) +vi.mock('@sim/utils/helpers', () => ({ interruptibleSleep: mockSleep })) + +import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' +import { + type OracleFusionResolvedCredential, + requestOracleFusionJson, +} from '@/lib/internal/oracle-fusion/client' +import { OracleFusionProviderError } from '@/lib/internal/oracle-fusion/errors' + +const ORIGIN = 'https://vision.fa.us2.oraclecloud.com' +const BASIC = Buffer.from('integration-user:password').toString('base64') +const CREDENTIAL: OracleFusionResolvedCredential = { + instanceUrl: ORIGIN, + accessToken: BASIC, +} + +function response(status: number, body: string, headers: Record = {}) { + return { + ok: status >= 200 && status < 300, + status, + statusText: '', + headers: { get: (name: string) => headers[name.toLowerCase()] ?? null }, + body: null, + text: vi.fn(async () => body), + json: vi.fn(async () => JSON.parse(body)), + arrayBuffer: vi.fn(async () => new TextEncoder().encode(body).buffer), + } +} + +describe('requestOracleFusionJson', () => { + beforeEach(() => { + vi.clearAllMocks() + mockValidateUrl.mockResolvedValue({ + isValid: true, + resolvedIP: '203.0.113.10', + originalHostname: 'vision.fa.us2.oraclecloud.com', + }) + mockSleep.mockResolvedValue(undefined) + mockSecureFetch.mockResolvedValue(response(200, '{"items":[]}')) + }) + + afterEach(() => vi.restoreAllMocks()) + + it.each([ + ['hcm', '/hcmRestApi/resources/11.13.18.05/workers'], + ['fscm', '/fscmRestApi/resources/11.13.18.05/invoices'], + ] as const)( + 'pins the %s API family, headers, DNS result, and GET method', + async (family, path) => { + await expect( + requestOracleFusionJson(CREDENTIAL, { + family, + path: path.split('/').at(-1)!, + query: { q: 'Name="A B"', limit: 25, expand: undefined, onlyData: true }, + }) + ).resolves.toEqual({ items: [] }) + + expect(mockValidateUrl).toHaveBeenCalledWith( + ORIGIN, + 'Fusion Applications URL', + 'configuredEndpoint', + { logDetails: false } + ) + const [url, resolvedIP, init] = mockSecureFetch.mock.calls[0] + const parsedUrl = new URL(url) + expect(parsedUrl.origin + parsedUrl.pathname).toBe(`${ORIGIN}${path}`) + expect(parsedUrl.searchParams.get('q')).toBe('Name="A B"') + expect(parsedUrl.searchParams.get('limit')).toBe('25') + expect(parsedUrl.searchParams.get('onlyData')).toBe('true') + expect(parsedUrl.searchParams.has('expand')).toBe(false) + expect(resolvedIP).toBe('203.0.113.10') + expect(init).toMatchObject({ + profile: 'configuredEndpoint', + method: 'GET', + timeout: 30_000, + maxRedirects: 0, + maxResponseBytes: 5 * 1024 * 1024, + logUrlValidationDetails: false, + headers: { + Accept: 'application/json', + Authorization: `Basic ${BASIC}`, + 'REST-Framework-Version': '9', + }, + }) + } + ) + + it.each([ + '', + '/workers', + '//evil.example/workers', + 'https://evil.example/workers', + 'workers/../users', + 'workers/./users', + 'workers\\users', + 'workers?limit=1', + 'workers#fragment', + 'workers/%2e%2e/users', + 'workers/%2Fusers', + 'workers/%5cusers', + ])('rejects the unsafe relative path %j before DNS or fetch', async (path) => { + await expect(requestOracleFusionJson(CREDENTIAL, { family: 'hcm', path })).rejects.toThrow( + /safe relative path|traversal/ + ) + expect(mockValidateUrl).not.toHaveBeenCalled() + expect(mockSecureFetch).not.toHaveBeenCalled() + }) + + it('accepts the URL-safe encoding produced for an opaque key containing a percent sign', async () => { + await expect( + requestOracleFusionJson(CREDENTIAL, { + family: 'hcm', + path: 'workers/key%252Fpart', + }) + ).resolves.toEqual({ items: [] }) + expect(new URL(mockSecureFetch.mock.calls[0][0]).pathname).toMatch(/\/workers\/key%252Fpart$/) + }) + + it('rejects a non-public DNS result before fetching', async () => { + mockValidateUrl.mockResolvedValueOnce({ isValid: false, error: 'private address' }) + await expect( + requestOracleFusionJson(CREDENTIAL, { family: 'hcm', path: 'workers' }) + ).rejects.toThrow('not a public endpoint') + expect(mockSecureFetch).not.toHaveBeenCalled() + }) + + it('retries 429, 503, and 504 at most twice and honors bounded Retry-After', async () => { + mockSecureFetch + .mockResolvedValueOnce(response(429, 'secret provider body', { 'retry-after': '90' })) + .mockResolvedValueOnce(response(503, 'secret provider body', { 'retry-after': '1' })) + .mockResolvedValueOnce(response(504, 'secret provider body')) + + await expect( + requestOracleFusionJson(CREDENTIAL, { family: 'fscm', path: 'invoices' }) + ).rejects.toMatchObject({ status: 504 }) + expect(mockSecureFetch).toHaveBeenCalledTimes(3) + expect(mockSleep).toHaveBeenNthCalledWith(1, 30_000, undefined) + expect(mockSleep).toHaveBeenNthCalledWith(2, 1_000, undefined) + }) + + it('rejects redirects without exposing their location or body', async () => { + mockSecureFetch.mockResolvedValueOnce( + response(302, `redirect ${BASIC}`, { location: 'https://evil.example' }) + ) + const error = await requestOracleFusionJson(CREDENTIAL, { + family: 'hcm', + path: 'workers', + }).catch((caught: unknown) => caught) + expect(error).toMatchObject({ status: 302 }) + expect(String(error)).not.toContain('evil.example') + expect(String(error)).not.toContain(BASIC) + }) + + it('preserves unsafe integral JSON tokens as decimal strings', async () => { + mockSecureFetch.mockResolvedValueOnce( + response( + 200, + '{"id":9007199254740993,"negative":-9007199254740993,"safe":9007199254740991,"decimal":9007199254740993.5}' + ) + ) + await expect( + requestOracleFusionJson(CREDENTIAL, { family: 'hcm', path: 'workers' }) + ).resolves.toEqual({ + id: '9007199254740993', + negative: '-9007199254740993', + safe: 9007199254740991, + decimal: 9007199254740994, + }) + }) + + it('returns fixed provider errors without credential or body reflection', async () => { + const password = 'provider-reflected-password' + const accessToken = Buffer.from(`integration-user:${password}`).toString('base64') + mockSecureFetch.mockResolvedValueOnce( + response(401, `integration-user ${password} ${accessToken}`) + ) + const error = await requestOracleFusionJson( + { ...CREDENTIAL, accessToken }, + { family: 'hcm', path: 'workers' } + ).catch((caught: unknown) => caught) + expect(error).toBeInstanceOf(OracleFusionProviderError) + expect(error).toMatchObject({ message: 'Oracle Fusion authentication failed', status: 401 }) + expect(String(error)).not.toContain('integration-user') + expect(String(error)).not.toContain(password) + expect(String(error)).not.toContain(accessToken) + }) + + it('classifies timeout, response-limit, and malformed JSON failures', async () => { + mockSecureFetch.mockRejectedValueOnce(new Error('Request timed out after 30000ms')) + await expect( + requestOracleFusionJson(CREDENTIAL, { family: 'hcm', path: 'workers' }) + ).rejects.toMatchObject({ message: 'Oracle Fusion request timed out', status: 504 }) + + mockSecureFetch.mockRejectedValueOnce( + new PayloadSizeLimitError({ label: 'response', maxBytes: 5 * 1024 * 1024 }) + ) + await expect( + requestOracleFusionJson(CREDENTIAL, { family: 'hcm', path: 'workers' }) + ).rejects.toMatchObject({ message: 'Oracle Fusion response exceeded 5 MiB', status: 502 }) + + mockSecureFetch.mockResolvedValueOnce(response(200, 'not-json')) + await expect( + requestOracleFusionJson(CREDENTIAL, { family: 'hcm', path: 'workers' }) + ).rejects.toMatchObject({ message: 'Oracle Fusion returned malformed JSON', status: 502 }) + }) + + it('preserves caller aborts and never starts the request', async () => { + const controller = new AbortController() + const reason = new DOMException('cancelled', 'AbortError') + controller.abort(reason) + await expect( + requestOracleFusionJson(CREDENTIAL, { family: 'hcm', path: 'workers' }, controller.signal) + ).rejects.toBe(reason) + expect(mockValidateUrl).not.toHaveBeenCalled() + expect(mockSecureFetch).not.toHaveBeenCalled() + }) + + it('rejects malformed Basic material and non-finite query values locally', async () => { + await expect( + requestOracleFusionJson( + { ...CREDENTIAL, accessToken: 'not basic\r\n' }, + { family: 'hcm', path: 'workers' } + ) + ).rejects.toThrow('credential is malformed') + await expect( + requestOracleFusionJson(CREDENTIAL, { + family: 'hcm', + path: 'workers', + query: { limit: Number.POSITIVE_INFINITY }, + }) + ).rejects.toThrow('query values must be finite') + expect(mockValidateUrl).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/internal/oracle-fusion/client.ts b/apps/sim/lib/internal/oracle-fusion/client.ts new file mode 100644 index 00000000000..6ccb0274739 --- /dev/null +++ b/apps/sim/lib/internal/oracle-fusion/client.ts @@ -0,0 +1,229 @@ +import { interruptibleSleep } from '@sim/utils/helpers' +import { backoffWithJitter, parseRetryAfter } from '@sim/utils/retry' +import { + type SecureFetchResponse, + secureFetchWithPinnedIP, + validateUrlWithDNS, +} from '@/lib/core/security/input-validation.server' +import { consumeOrCancelBody, isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' +import { normalizeOracleFusionApplicationOrigin } from '@/lib/credentials/client-credential-accounts/descriptors' +import { OracleFusionProviderError } from '@/lib/internal/oracle-fusion/errors' + +const REQUEST_TIMEOUT_MS = 30_000 +const RESPONSE_MAX_BYTES = 5 * 1024 * 1024 +const MAX_RETRIES = 2 +const TRANSIENT_STATUSES = new Set([429, 503, 504]) +const API_VERSION = '11.13.18.05' +const API_ROOTS = { + hcm: `/hcmRestApi/resources/${API_VERSION}`, + fscm: `/fscmRestApi/resources/${API_VERSION}`, +} as const +const UNSAFE_PATH_ENCODING = /%(?:2e|2f|5c|3f|23)/i +const ABSOLUTE_PATH = /^[a-z][a-z0-9+.-]*:/i +const CANONICAL_BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/ +const DECIMAL_INTEGER_TOKEN = /^-?\d+$/ + +interface JsonParseContext { + source?: string +} + +type JsonParseWithSource = ( + text: string, + reviver: (this: unknown, key: string, value: unknown, context?: JsonParseContext) => unknown +) => unknown + +const jsonParseWithSource = JSON.parse as JsonParseWithSource + +export type OracleFusionApiFamily = 'hcm' | 'fscm' + +export interface OracleFusionResolvedCredential { + instanceUrl: string + accessToken: string +} + +export interface OracleFusionRequest { + family: OracleFusionApiFamily + path: string + query?: Record +} + +function validateBasicCredential(accessToken: string): void { + if ( + !accessToken || + accessToken.length > 4096 || + accessToken.length % 4 !== 0 || + !CANONICAL_BASE64.test(accessToken) + ) { + throw new Error('Oracle Fusion credential is malformed') + } +} + +function validateRelativePath(path: string): void { + if ( + !path || + path !== path.trim() || + path.startsWith('/') || + path.startsWith('//') || + ABSOLUTE_PATH.test(path) || + path.includes('\\') || + path.includes('?') || + path.includes('#') || + UNSAFE_PATH_ENCODING.test(path) + ) { + throw new Error('Oracle Fusion resource path must be a safe relative path') + } + for (const segment of path.split('/')) { + if (segment === '.' || segment === '..') { + throw new Error('Oracle Fusion resource path must not contain traversal') + } + } +} + +function buildRequestUrl(origin: string, request: OracleFusionRequest): string { + validateRelativePath(request.path) + const root = API_ROOTS[request.family] + if (!root) throw new Error('Oracle Fusion API family is unsupported') + const url = new URL(`${origin}${root}/${request.path}`) + for (const [key, value] of Object.entries(request.query ?? {})) { + if (value === undefined) continue + if (typeof value === 'number' && !Number.isFinite(value)) { + throw new Error('Oracle Fusion query values must be finite') + } + url.searchParams.set(key, String(value)) + } + if (url.origin !== origin || !url.pathname.startsWith(`${root}/`)) { + throw new Error('Oracle Fusion request must remain on the credential-bound API root') + } + return url.toString() +} + +function parseOracleFusionJson(body: string): unknown { + return jsonParseWithSource(body, (_key, value, context) => { + if (typeof value !== 'number' || !Number.isInteger(value) || Number.isSafeInteger(value)) { + return value + } + const source = context?.source + return source && DECIMAL_INTEGER_TOKEN.test(source) ? source : value + }) +} + +async function waitForRetry( + attempt: number, + signal?: AbortSignal, + retryAfterMs: number | null = null +): Promise { + const delay = backoffWithJitter(attempt + 1, retryAfterMs, { + baseMs: 250, + maxMs: 30_000, + }) + await interruptibleSleep(delay, signal) + signal?.throwIfAborted() +} + +async function fetchAttempt( + url: string, + resolvedIP: string, + accessToken: string, + signal?: AbortSignal +): Promise { + return secureFetchWithPinnedIP(url, resolvedIP, { + profile: 'configuredEndpoint', + method: 'GET', + headers: { + Accept: 'application/json', + Authorization: `Basic ${accessToken}`, + 'REST-Framework-Version': '9', + }, + timeout: REQUEST_TIMEOUT_MS, + maxRedirects: 0, + maxResponseBytes: RESPONSE_MAX_BYTES, + signal, + logUrlValidationDetails: false, + }) +} + +function statusMessage(status: number): string { + if (status === 401) return 'Oracle Fusion authentication failed' + if (status === 403) return 'Oracle Fusion denied this request' + if (status === 404) return 'Oracle Fusion resource was not found' + if (status === 429) return 'Oracle Fusion rate limit exceeded' + return `Oracle Fusion request failed with HTTP ${status}` +} + +/** Executes one bounded, DNS-pinned GET against a fixed Oracle product API family. */ +export async function requestOracleFusionJson( + credential: OracleFusionResolvedCredential, + request: OracleFusionRequest, + signal?: AbortSignal +): Promise { + signal?.throwIfAborted() + const origin = normalizeOracleFusionApplicationOrigin(credential.instanceUrl) + if (!origin) { + throw new Error('Oracle Fusion credential is not bound to a canonical application URL') + } + validateBasicCredential(credential.accessToken) + const url = buildRequestUrl(origin, request) + + let validation: Awaited> + try { + validation = await validateUrlWithDNS(origin, 'Fusion Applications URL', 'configuredEndpoint', { + logDetails: false, + }) + } catch { + signal?.throwIfAborted() + throw new Error('Oracle Fusion credential application URL could not be validated') + } + signal?.throwIfAborted() + if (!validation.isValid) { + throw new Error('Oracle Fusion credential application URL is not a public endpoint') + } + + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + signal?.throwIfAborted() + let response: SecureFetchResponse + try { + response = await fetchAttempt(url, validation.resolvedIP, credential.accessToken, signal) + } catch (error) { + signal?.throwIfAborted() + if (isPayloadSizeLimitError(error)) { + throw new OracleFusionProviderError('Oracle Fusion response exceeded 5 MiB', 502) + } + if (error instanceof Error && error.message.includes('timed out')) { + throw new OracleFusionProviderError('Oracle Fusion request timed out', 504) + } + throw new OracleFusionProviderError('Could not reach Oracle Fusion', 502) + } + + if (TRANSIENT_STATUSES.has(response.status) && attempt < MAX_RETRIES) { + const retryAfterMs = parseRetryAfter(response.headers.get('retry-after'), 30_000) + await consumeOrCancelBody(response) + await waitForRetry(attempt, signal, retryAfterMs) + continue + } + + if (!response.ok) { + await consumeOrCancelBody(response) + signal?.throwIfAborted() + throw new OracleFusionProviderError(statusMessage(response.status), response.status) + } + + let body: string + try { + body = await response.text() + } catch (error) { + signal?.throwIfAborted() + if (isPayloadSizeLimitError(error)) { + throw new OracleFusionProviderError('Oracle Fusion response exceeded 5 MiB', 502) + } + throw new OracleFusionProviderError('Oracle Fusion response could not be read', 502) + } + signal?.throwIfAborted() + try { + return parseOracleFusionJson(body) + } catch { + throw new OracleFusionProviderError('Oracle Fusion returned malformed JSON', 502) + } + } + + throw new OracleFusionProviderError('Oracle Fusion retry limit was exhausted', 502) +} diff --git a/apps/sim/lib/internal/oracle-fusion/errors.ts b/apps/sim/lib/internal/oracle-fusion/errors.ts new file mode 100644 index 00000000000..c41b905e8dd --- /dev/null +++ b/apps/sim/lib/internal/oracle-fusion/errors.ts @@ -0,0 +1,10 @@ +/** Safe caller-facing failure from an Oracle Fusion product request. */ +export class OracleFusionProviderError extends Error { + constructor( + message: string, + readonly status: number + ) { + super(message) + this.name = 'OracleFusionProviderError' + } +} diff --git a/apps/sim/lib/internal/oracle-fusion/protocol.test.ts b/apps/sim/lib/internal/oracle-fusion/protocol.test.ts new file mode 100644 index 00000000000..8804098a3df --- /dev/null +++ b/apps/sim/lib/internal/oracle-fusion/protocol.test.ts @@ -0,0 +1,132 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + encodeOracleFusionPathSegment, + extractOracleFusionOpaqueKey, + parseOracleFusionCollection, + validateOracleFusionSelfLink, +} from '@/lib/internal/oracle-fusion/protocol' + +const ORIGIN = 'https://vision.fa.us2.oraclecloud.com' +const COLLECTION = '/hcmRestApi/resources/11.13.18.05/workers' + +function resource(href: unknown, links: unknown[] = []): Record { + return { links: [{ rel: 'self', href }, ...links] } +} + +describe('parseOracleFusionCollection', () => { + it('projects a valid page and calculates the next offset', () => { + expect( + parseOracleFusionCollection( + { + items: [{ id: 1 }, { id: 2 }], + count: 2, + hasMore: true, + limit: 25, + offset: 50, + totalResults: 80, + }, + (item, index) => ({ ...(item as object), index }) + ) + ).toEqual({ + items: [ + { id: 1, index: 0 }, + { id: 2, index: 1 }, + ], + count: 2, + hasMore: true, + limit: 25, + offset: 50, + totalResults: 80, + nextOffset: 52, + }) + }) + + it('accepts an empty terminal page without inventing nextOffset', () => { + expect( + parseOracleFusionCollection( + { items: [], count: 0, hasMore: false, limit: 25, offset: 0 }, + (item) => item + ) + ).toEqual({ items: [], count: 0, hasMore: false, limit: 25, offset: 0 }) + }) + + it.each([ + [null, 'must be an object'], + [{}, 'items must be an array'], + [{ items: [], count: -1, hasMore: false, limit: 25, offset: 0 }, 'count'], + [{ items: [], count: 0, hasMore: 'no', limit: 25, offset: 0 }, 'hasMore'], + [{ items: [{}], count: 0, hasMore: false, limit: 25, offset: 0 }, 'match'], + [{ items: [], count: 0, hasMore: true, limit: 25, offset: 0 }, 'empty page'], + [{ items: [], count: 0, hasMore: false, limit: 0, offset: 0 }, 'positive'], + [{ items: [{}], count: 1, hasMore: false, limit: 25, offset: 4, totalResults: 4 }, 'smaller'], + [ + { items: [{}], count: 1, hasMore: true, limit: 25, offset: Number.MAX_SAFE_INTEGER }, + 'safe integer range', + ], + ])('rejects malformed collection envelope %#', (value, message) => { + expect(() => parseOracleFusionCollection(value, (item) => item)).toThrow(message as string) + }) +}) + +describe('Oracle self links', () => { + it('accepts exactly one same-origin self link for the expected path', () => { + expect(() => + validateOracleFusionSelfLink( + resource(`${ORIGIN}${COLLECTION}/abc`), + ORIGIN, + `${COLLECTION}/abc` + ) + ).not.toThrow() + }) + + it.each([ + [{}, 'exactly one'], + [{ links: [] }, 'exactly one'], + [ + resource(`${ORIGIN}${COLLECTION}/abc`, [{ rel: 'self', href: `${ORIGIN}/duplicate` }]), + 'exactly one', + ], + [resource(123), 'malformed'], + [resource('not a URL'), 'malformed'], + [resource(`https://evil.example${COLLECTION}/abc`), 'credential-bound origin'], + [resource(`${ORIGIN}${COLLECTION}/abc?secret=value`), 'credential-bound origin'], + [resource(`${ORIGIN}${COLLECTION}/other`), 'requested resource path'], + ])('rejects missing, duplicate, malformed, or unbound self links %#', (value, message) => { + expect(() => validateOracleFusionSelfLink(value, ORIGIN, `${COLLECTION}/abc`)).toThrow( + message as string + ) + }) + + it('extracts and URL-encodes an opaque key without changing its value', () => { + const key = 'person:123,assignment=456' + const encoded = encodeOracleFusionPathSegment(key) + expect(encoded).toBe('person%3A123%2Cassignment%3D456') + expect( + extractOracleFusionOpaqueKey( + resource(`${ORIGIN}${COLLECTION}/${encoded}`), + ORIGIN, + COLLECTION + ) + ).toBe(key) + }) + + it.each(['', '.', '..', 'a/b', 'a\\b', 'a?b', 'a#b', 'a\nb', 'x'.repeat(2049)])( + 'rejects the unsafe opaque key %j', + (key) => { + expect(() => encodeOracleFusionPathSegment(key)).toThrow('safe opaque path segment') + } + ) + + it.each([ + [`${ORIGIN}/other/abc`, 'collection path'], + [`${ORIGIN}${COLLECTION}/a/b`, 'one opaque key'], + [`${ORIGIN}${COLLECTION}/a%2Fb`, 'one opaque key'], + [`${ORIGIN}${COLLECTION}/a%5Cb`, 'one opaque key'], + [`${ORIGIN}${COLLECTION}/%E0%A4%A`, 'invalid URL encoding'], + ])('rejects an unsafe opaque-key self link %j', (href, message) => { + expect(() => extractOracleFusionOpaqueKey(resource(href), ORIGIN, COLLECTION)).toThrow(message) + }) +}) diff --git a/apps/sim/lib/internal/oracle-fusion/protocol.ts b/apps/sim/lib/internal/oracle-fusion/protocol.ts new file mode 100644 index 00000000000..a6e1824a01e --- /dev/null +++ b/apps/sim/lib/internal/oracle-fusion/protocol.ts @@ -0,0 +1,165 @@ +import { normalizeOracleFusionApplicationOrigin } from '@/lib/credentials/client-credential-accounts/descriptors' + +const OPAQUE_KEY_MAX_LENGTH = 2048 +const UNSAFE_OPAQUE_KEY = /[\\/?#\u0000-\u001f\u007f]/ + +export interface OracleFusionCollection { + items: T[] + count: number + hasMore: boolean + limit: number + offset: number + totalResults?: number + nextOffset?: number +} + +function asObject(value: unknown, label: string): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`${label} must be an object`) + } + return value as Record +} + +function nonNegativeInteger(value: unknown, label: string): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) { + throw new Error(`${label} must be a non-negative safe integer`) + } + return value +} + +/** Validates and projects an Oracle collection envelope with pagination invariants. */ +export function parseOracleFusionCollection( + value: unknown, + parseItem: (item: unknown, index: number) => T +): OracleFusionCollection { + const envelope = asObject(value, 'Oracle collection') + if (!Array.isArray(envelope.items)) throw new Error('Oracle collection items must be an array') + const count = nonNegativeInteger(envelope.count, 'Oracle collection count') + const limit = nonNegativeInteger(envelope.limit, 'Oracle collection limit') + const offset = nonNegativeInteger(envelope.offset, 'Oracle collection offset') + if (limit === 0) throw new Error('Oracle collection limit must be positive') + if (typeof envelope.hasMore !== 'boolean') { + throw new Error('Oracle collection hasMore must be a boolean') + } + if (count !== envelope.items.length) { + throw new Error('Oracle collection count must match the item count') + } + if (envelope.hasMore && count === 0) { + throw new Error('Oracle collection cannot report hasMore for an empty page') + } + + const totalResults = + envelope.totalResults === undefined + ? undefined + : nonNegativeInteger(envelope.totalResults, 'Oracle collection totalResults') + const pageEnd = offset + count + if (!Number.isSafeInteger(pageEnd)) { + throw new Error('Oracle collection next offset exceeds the safe integer range') + } + if (totalResults !== undefined && totalResults < pageEnd) { + throw new Error('Oracle collection totalResults is smaller than the returned page') + } + + return { + items: envelope.items.map(parseItem), + count, + hasMore: envelope.hasMore, + limit, + offset, + ...(totalResults !== undefined ? { totalResults } : {}), + ...(envelope.hasMore ? { nextOffset: pageEnd } : {}), + } +} + +function getOnlySelfLink(value: unknown): URL { + const resource = asObject(value, 'Oracle resource') + if (!Array.isArray(resource.links)) { + throw new Error('Oracle response must include exactly one self link') + } + const selfLinks = resource.links.filter((link) => { + if (!link || typeof link !== 'object' || Array.isArray(link)) return false + return (link as Record).rel === 'self' + }) + if (selfLinks.length !== 1) { + throw new Error('Oracle response must include exactly one self link') + } + const href = (selfLinks[0] as Record).href + if (typeof href !== 'string') throw new Error('Oracle self link is malformed') + try { + return new URL(href) + } catch { + throw new Error('Oracle self link is malformed') + } +} + +function validateSelfLinkBase(link: URL, instanceUrl: string): void { + const origin = normalizeOracleFusionApplicationOrigin(instanceUrl) + if ( + !origin || + link.origin !== origin || + link.username || + link.password || + link.search || + link.hash + ) { + throw new Error('Oracle self link does not match the credential-bound origin') + } +} + +/** Requires one canonical same-origin self link for the expected resource path. */ +export function validateOracleFusionSelfLink( + value: unknown, + instanceUrl: string, + expectedPath: string +): void { + const link = getOnlySelfLink(value) + validateSelfLinkBase(link, instanceUrl) + if (!expectedPath.startsWith('/') || link.pathname !== expectedPath) { + throw new Error('Oracle response self link does not match the requested resource path') + } +} + +function validateOpaqueKey(key: string): string { + if ( + !key || + key.length > OPAQUE_KEY_MAX_LENGTH || + key === '.' || + key === '..' || + UNSAFE_OPAQUE_KEY.test(key) + ) { + throw new Error('Oracle resource key is not a safe opaque path segment') + } + return key +} + +/** Derives one opaque key from a canonical same-origin collection self link. */ +export function extractOracleFusionOpaqueKey( + value: unknown, + instanceUrl: string, + collectionPath: string +): string { + const link = getOnlySelfLink(value) + validateSelfLinkBase(link, instanceUrl) + if (!collectionPath.startsWith('/')) { + throw new Error('Oracle collection path must be absolute') + } + const prefix = `${collectionPath}/` + if (!link.pathname.startsWith(prefix)) { + throw new Error('Oracle self link does not match the requested collection path') + } + const encodedKey = link.pathname.slice(prefix.length) + if (!encodedKey || encodedKey.includes('/') || /%(?:2f|5c)/i.test(encodedKey)) { + throw new Error('Oracle self link does not contain one opaque key segment') + } + try { + return validateOpaqueKey(decodeURIComponent(encodedKey)) + } catch (error) { + if (error instanceof URIError) throw new Error('Oracle self link contains invalid URL encoding') + throw error + } +} + +/** Encodes a validated opaque Oracle resource key for one URL path segment. */ +export function encodeOracleFusionPathSegment(key: string): string { + return encodeURIComponent(validateOpaqueKey(key)) +} diff --git a/apps/sim/lib/oauth/credential-service.test.ts b/apps/sim/lib/oauth/credential-service.test.ts index 337b56aa435..9c0b553d33a 100644 --- a/apps/sim/lib/oauth/credential-service.test.ts +++ b/apps/sim/lib/oauth/credential-service.test.ts @@ -7,6 +7,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ coalesceLocally: vi.fn(), + clientCredentialMinter: vi.fn(), + decryptSecret: vi.fn(), getFreshestSlackChain: vi.fn(), getRecentTerminalError: vi.fn(), logger: { @@ -33,6 +35,15 @@ vi.mock('@/lib/concurrency/leader-lock', () => ({ withLeaderLock: mocks.withLeaderLock, })) +vi.mock('@/lib/core/security/encryption', () => ({ + decryptSecret: mocks.decryptSecret, +})) + +vi.mock('@/lib/credentials/client-credential-accounts/server', () => ({ + getClientCredentialAccountMinter: () => mocks.clientCredentialMinter, + parseClientCredentialAccountSecretBlob: (decrypted: string) => JSON.parse(decrypted), +})) + vi.mock('@/lib/oauth/instagram', () => ({ isInstagramProvider: vi.fn(() => false), shouldProactivelyRefreshInstagramToken: vi.fn(() => false), @@ -64,7 +75,10 @@ vi.mock('@/lib/oauth/terminal-errors', () => ({ markCredentialDead: vi.fn(), })) -import { resolveCredentialTokenBundle } from '@/lib/oauth/credential-service' +import { + resolveCredentialTokenBundle, + resolveServiceAccountToken, +} from '@/lib/oauth/credential-service' const RAW_CREDENTIAL_ID = 'credential-raw-secret-id' const RAW_ACCOUNT_ID = 'account-raw-secret-id' @@ -200,3 +214,51 @@ describe('resolveCredentialTokenBundle selector privacy', () => { expect(slack.logs).toContain(RAW_PROVIDER_ERROR) }) }) + +describe('resolveServiceAccountToken Oracle Fusion cache', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.coalesceLocally.mockImplementation( + async (_key: string, producer: () => Promise) => producer() + ) + mocks.decryptSecret.mockImplementation(async (encrypted: string) => ({ + decrypted: JSON.stringify({ + type: 'client_credential_account', + providerId: 'oracle-fusion-service-account', + clientId: 'integration-user', + clientSecret: encrypted, + orgId: 'https://vision.fa.us2.oraclecloud.com', + }), + })) + mocks.clientCredentialMinter.mockImplementation(async (fields: { clientSecret: string }) => ({ + accessToken: `basic-${fields.clientSecret}`, + expiresInSeconds: 300, + instanceUrl: 'https://vision.fa.us2.oraclecloud.com', + })) + }) + + it('reuses Basic material for five minutes and invalidates it on encrypted-secret rotation', async () => { + const credentialId = 'oracle-fusion-cache-test' + const providerId = 'oracle-fusion-service-account' + const encryptedV1 = 'encrypted-v1-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + const encryptedV2 = 'encrypted-v2-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' + + queueTableRows(credential, [{ encryptedServiceAccountKey: encryptedV1 }]) + await expect(resolveServiceAccountToken(credentialId, providerId)).resolves.toMatchObject({ + accessToken: `basic-${encryptedV1}`, + }) + + queueTableRows(credential, [{ encryptedServiceAccountKey: encryptedV1 }]) + await expect(resolveServiceAccountToken(credentialId, providerId)).resolves.toMatchObject({ + accessToken: `basic-${encryptedV1}`, + }) + expect(mocks.clientCredentialMinter).toHaveBeenCalledTimes(1) + + queueTableRows(credential, [{ encryptedServiceAccountKey: encryptedV2 }]) + await expect(resolveServiceAccountToken(credentialId, providerId)).resolves.toMatchObject({ + accessToken: `basic-${encryptedV2}`, + }) + expect(mocks.clientCredentialMinter).toHaveBeenCalledTimes(2) + }) +}) diff --git a/apps/sim/lib/oauth/credential-service.ts b/apps/sim/lib/oauth/credential-service.ts index 0962cf8cb56..5fb7b08744f 100644 --- a/apps/sim/lib/oauth/credential-service.ts +++ b/apps/sim/lib/oauth/credential-service.ts @@ -8,7 +8,10 @@ import { withLeaderLock } from '@/lib/concurrency/leader-lock' import { coalesceLocally } from '@/lib/concurrency/singleflight' import { env } from '@/lib/core/config/env' import { decryptSecret } from '@/lib/core/security/encryption' -import { isClientCredentialAccountProviderId } from '@/lib/credentials/client-credential-accounts/descriptors' +import { + isClientCredentialAccountProviderId, + ORACLE_FUSION_SERVICE_ACCOUNT_PROVIDER_ID, +} from '@/lib/credentials/client-credential-accounts/descriptors' import { getClientCredentialAccountMinter, parseClientCredentialAccountSecretBlob, @@ -466,11 +469,13 @@ interface FailedClientCredentialMint { /** * Per-instance cache of minted client-credential access tokens (Zoom S2S, - * Box CCG, Salesforce, NetSuite), keyed by credential id. Entries are + * Box CCG, Salesforce, NetSuite, Oracle Fusion), keyed by credential id. Entries are * served while more than {@link CLIENT_CREDENTIAL_TOKEN_MIN_TTL_MS} of * validity remains, so a hot credential mints roughly once per token TTL * (~1h for Zoom/Box/NetSuite; Salesforce reports a conservative 10-minute TTL - * because its responses never carry an expiry) per instance. + * because its responses never carry an expiry) per instance. Oracle Fusion's + * locally derived, non-expiring Basic value instead uses its complete + * five-minute synthetic lifetime. * * Every resolution re-reads the credential row (a cheap indexed PK select — * the mint is the expensive part) and validates the cached entry's secret @@ -550,7 +555,10 @@ async function resolveClientCredentialAccountToken( if ( cached && cached.secretFingerprint === secretFingerprint && - cached.expiresAtMs - Date.now() > CLIENT_CREDENTIAL_TOKEN_MIN_TTL_MS + cached.expiresAtMs - Date.now() > + (providerId === ORACLE_FUSION_SERVICE_ACCOUNT_PROVIDER_ID + ? 0 + : CLIENT_CREDENTIAL_TOKEN_MIN_TTL_MS) ) { return { accessToken: cached.accessToken, From a7ae174252dc223a930821094b0b4ecc9cc73f31 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos <157128530+BillLeoutsakosvl346@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:24:46 -0700 Subject: [PATCH 02/15] fix(oracle-fusion): harden numeric and origin parsing --- .../descriptors.test.ts | 3 ++ .../client-credential-accounts/descriptors.ts | 5 ++-- .../minters/oracle-fusion.test.ts | 2 ++ .../lib/internal/oracle-fusion/client.test.ts | 5 +++- apps/sim/lib/internal/oracle-fusion/client.ts | 28 +++++++++++++++---- 5 files changed, 35 insertions(+), 8 deletions(-) diff --git a/apps/sim/lib/credentials/client-credential-accounts/descriptors.test.ts b/apps/sim/lib/credentials/client-credential-accounts/descriptors.test.ts index d705f1df092..66d0f04042c 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/descriptors.test.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/descriptors.test.ts @@ -140,6 +140,9 @@ describe('normalizeOracleFusionApplicationOrigin', () => { it.each([ 'http://vision.fa.us2.oraclecloud.com', 'https://vision.fa.us2.oraclecloud.com/path', + 'https://vision.fa.us2.oraclecloud.com/path/..', + 'https://vision.fa.us2.oraclecloud.com/./', + 'https://vision.fa.us2.oraclecloud.com/%2e%2e/', 'https://vision.fa.us2.oraclecloud.com:443', 'https://vision.fa.us2.oraclecloud.com:8443', 'https://user@vision.fa.us2.oraclecloud.com', diff --git a/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts b/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts index 26d522329e2..7f98eb343c6 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts @@ -159,6 +159,8 @@ export function normalizeNetSuiteSuiteTalkOrigin(rawUrl: string): string | undef /** Canonical Oracle-assigned Fusion Applications origin used by product REST APIs. */ export const ORACLE_FUSION_APPLICATION_ORIGIN_REGEX = /^https:\/\/[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.fa\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.oraclecloud\.com$/ +const ORACLE_FUSION_APPLICATION_INPUT_REGEX = + /^https:\/\/[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.fa\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.oraclecloud\.com\/?$/i /** * Normalizes a Fusion Applications URL to its authoritative HTTPS origin. @@ -168,8 +170,7 @@ export const ORACLE_FUSION_APPLICATION_ORIGIN_REGEX = export function normalizeOracleFusionApplicationOrigin(rawUrl: string): string | undefined { try { const trimmed = rawUrl.trim() - const authority = /^https:\/\/([^/?#]+)/i.exec(trimmed)?.[1] - if (!authority || authority.includes(':')) return undefined + if (!ORACLE_FUSION_APPLICATION_INPUT_REGEX.test(trimmed)) return undefined const parsed = new URL(trimmed) if ( parsed.protocol !== 'https:' || diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/oracle-fusion.test.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/oracle-fusion.test.ts index 20fe582cd01..4b25b6d89d8 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/minters/oracle-fusion.test.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/oracle-fusion.test.ts @@ -49,6 +49,8 @@ describe('mintOracleFusionServiceAccountToken', () => { it.each([ 'http://vision.fa.us2.oraclecloud.com', 'https://vision.fa.us2.oraclecloud.com/path', + 'https://vision.fa.us2.oraclecloud.com/path/..', + 'https://vision.fa.us2.oraclecloud.com/%2e%2e/', 'https://vision.fa.us2.oraclecloud.com:443', 'https://user:password@vision.fa.us2.oraclecloud.com', 'https://vision.fa.us2.oraclecloud.com?tenant=other', diff --git a/apps/sim/lib/internal/oracle-fusion/client.test.ts b/apps/sim/lib/internal/oracle-fusion/client.test.ts index 139425f2016..dedde5efc5f 100644 --- a/apps/sim/lib/internal/oracle-fusion/client.test.ts +++ b/apps/sim/lib/internal/oracle-fusion/client.test.ts @@ -170,7 +170,7 @@ describe('requestOracleFusionJson', () => { mockSecureFetch.mockResolvedValueOnce( response( 200, - '{"id":9007199254740993,"negative":-9007199254740993,"safe":9007199254740991,"decimal":9007199254740993.5}' + '{"id":9007199254740993,"negative":-9007199254740993,"zeroFraction":9007199254740993.0,"exponent":9.007199254740993e15,"hugeExponent":1e999,"safe":9007199254740991,"decimal":9007199254740993.5}' ) ) await expect( @@ -178,6 +178,9 @@ describe('requestOracleFusionJson', () => { ).resolves.toEqual({ id: '9007199254740993', negative: '-9007199254740993', + zeroFraction: '9007199254740993.0', + exponent: '9.007199254740993e15', + hugeExponent: '1e999', safe: 9007199254740991, decimal: 9007199254740994, }) diff --git a/apps/sim/lib/internal/oracle-fusion/client.ts b/apps/sim/lib/internal/oracle-fusion/client.ts index 6ccb0274739..200915dc47e 100644 --- a/apps/sim/lib/internal/oracle-fusion/client.ts +++ b/apps/sim/lib/internal/oracle-fusion/client.ts @@ -21,7 +21,7 @@ const API_ROOTS = { const UNSAFE_PATH_ENCODING = /%(?:2e|2f|5c|3f|23)/i const ABSOLUTE_PATH = /^[a-z][a-z0-9+.-]*:/i const CANONICAL_BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/ -const DECIMAL_INTEGER_TOKEN = /^-?\d+$/ +const JSON_NUMBER_TOKEN = /^-?(0|[1-9]\d*)(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/ interface JsonParseContext { source?: string @@ -97,13 +97,31 @@ function buildRequestUrl(origin: string, request: OracleFusionRequest): string { return url.toString() } +function isIntegralJsonNumberToken(source: string): boolean { + const match = JSON_NUMBER_TOKEN.exec(source) + if (!match) return false + const coefficient = `${match[1]}${match[2] ?? ''}` + if (/^0+$/.test(coefficient)) return true + + const fractionDigits = match[2]?.length ?? 0 + const exponentSource = match[3] ?? '0' + const exponentDigits = exponentSource.replace(/^[+-]/, '').replace(/^0+/, '') || '0' + if (exponentDigits.length > 6) return !exponentSource.startsWith('-') + const exponent = Number(exponentSource) + const remainingFractionDigits = fractionDigits - exponent + if (remainingFractionDigits <= 0) return true + if (remainingFractionDigits > coefficient.length) return false + return coefficient + .slice(-remainingFractionDigits) + .split('') + .every((digit) => digit === '0') +} + function parseOracleFusionJson(body: string): unknown { return jsonParseWithSource(body, (_key, value, context) => { - if (typeof value !== 'number' || !Number.isInteger(value) || Number.isSafeInteger(value)) { - return value - } + if (typeof value !== 'number' || Number.isSafeInteger(value)) return value const source = context?.source - return source && DECIMAL_INTEGER_TOKEN.test(source) ? source : value + return source && isIntegralJsonNumberToken(source) ? source : value }) } From 7b669e743da44898b082d3479796011d21477085 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos <157128530+BillLeoutsakosvl346@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:33:47 -0700 Subject: [PATCH 03/15] fix(oracle-fusion): handle large integral exponents --- .../lib/internal/oracle-fusion/client.test.ts | 8 ++++ apps/sim/lib/internal/oracle-fusion/client.ts | 37 ++++++++++++++----- 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/apps/sim/lib/internal/oracle-fusion/client.test.ts b/apps/sim/lib/internal/oracle-fusion/client.test.ts index dedde5efc5f..a680b4c3473 100644 --- a/apps/sim/lib/internal/oracle-fusion/client.test.ts +++ b/apps/sim/lib/internal/oracle-fusion/client.test.ts @@ -186,6 +186,14 @@ describe('requestOracleFusionJson', () => { }) }) + it('recognizes a large negative exponent absorbed by coefficient trailing zeroes', async () => { + const token = `9007199254740993${'0'.repeat(1_000_000)}e-1000000` + mockSecureFetch.mockResolvedValueOnce(response(200, `{"id":${token}}`)) + await expect( + requestOracleFusionJson(CREDENTIAL, { family: 'hcm', path: 'workers' }) + ).resolves.toEqual({ id: token }) + }) + it('returns fixed provider errors without credential or body reflection', async () => { const password = 'provider-reflected-password' const accessToken = Buffer.from(`integration-user:${password}`).toString('base64') diff --git a/apps/sim/lib/internal/oracle-fusion/client.ts b/apps/sim/lib/internal/oracle-fusion/client.ts index 200915dc47e..2511452ceec 100644 --- a/apps/sim/lib/internal/oracle-fusion/client.ts +++ b/apps/sim/lib/internal/oracle-fusion/client.ts @@ -47,6 +47,22 @@ export interface OracleFusionRequest { query?: Record } +function compareDecimalMagnitudeToInteger(magnitude: string, value: number): number { + const normalizedMagnitude = magnitude.replace(/^0+/, '') || '0' + const integer = String(value) + if (normalizedMagnitude.length !== integer.length) { + return normalizedMagnitude.length < integer.length ? -1 : 1 + } + if (normalizedMagnitude === integer) return 0 + return normalizedMagnitude < integer ? -1 : 1 +} + +function trailingZeroCount(value: string): number { + let count = 0 + for (let index = value.length - 1; index >= 0 && value[index] === '0'; index--) count++ + return count +} + function validateBasicCredential(accessToken: string): void { if ( !accessToken || @@ -105,16 +121,17 @@ function isIntegralJsonNumberToken(source: string): boolean { const fractionDigits = match[2]?.length ?? 0 const exponentSource = match[3] ?? '0' - const exponentDigits = exponentSource.replace(/^[+-]/, '').replace(/^0+/, '') || '0' - if (exponentDigits.length > 6) return !exponentSource.startsWith('-') - const exponent = Number(exponentSource) - const remainingFractionDigits = fractionDigits - exponent - if (remainingFractionDigits <= 0) return true - if (remainingFractionDigits > coefficient.length) return false - return coefficient - .slice(-remainingFractionDigits) - .split('') - .every((digit) => digit === '0') + const exponentMagnitude = exponentSource.replace(/^[+-]/, '').replace(/^0+/, '') || '0' + const availableTrailingZeros = trailingZeroCount(coefficient) + + if (exponentSource.startsWith('-')) { + if (compareDecimalMagnitudeToInteger(exponentMagnitude, availableTrailingZeros) > 0) { + return false + } + return fractionDigits + Number(exponentMagnitude) <= availableTrailingZeros + } + if (compareDecimalMagnitudeToInteger(exponentMagnitude, fractionDigits) >= 0) return true + return fractionDigits - Number(exponentMagnitude) <= availableTrailingZeros } function parseOracleFusionJson(body: string): unknown { From b0d41e7bf99a6b25b90d30ccbb0b6214631b2ffd Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos <157128530+BillLeoutsakosvl346@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:46:29 -0700 Subject: [PATCH 04/15] fix(oracle-fusion): classify rejected redirects --- apps/sim/lib/internal/oracle-fusion/client.test.ts | 11 +++++++++++ apps/sim/lib/internal/oracle-fusion/client.ts | 8 ++++++++ 2 files changed, 19 insertions(+) diff --git a/apps/sim/lib/internal/oracle-fusion/client.test.ts b/apps/sim/lib/internal/oracle-fusion/client.test.ts index a680b4c3473..7d54966f354 100644 --- a/apps/sim/lib/internal/oracle-fusion/client.test.ts +++ b/apps/sim/lib/internal/oracle-fusion/client.test.ts @@ -166,6 +166,17 @@ describe('requestOracleFusionJson', () => { expect(String(error)).not.toContain(BASIC) }) + it('classifies redirects rejected by the pinned transport without exposing details', async () => { + mockSecureFetch.mockRejectedValueOnce(new Error('Too many redirects (max: 0)')) + const error = await requestOracleFusionJson(CREDENTIAL, { + family: 'hcm', + path: 'workers', + }).catch((caught: unknown) => caught) + expect(error).toMatchObject({ message: 'Oracle Fusion returned a redirect', status: 502 }) + expect(String(error)).not.toContain(ORIGIN) + expect(String(error)).not.toContain(BASIC) + }) + it('preserves unsafe integral JSON tokens as decimal strings', async () => { mockSecureFetch.mockResolvedValueOnce( response( diff --git a/apps/sim/lib/internal/oracle-fusion/client.ts b/apps/sim/lib/internal/oracle-fusion/client.ts index 2511452ceec..a90add979f2 100644 --- a/apps/sim/lib/internal/oracle-fusion/client.ts +++ b/apps/sim/lib/internal/oracle-fusion/client.ts @@ -185,6 +185,11 @@ function statusMessage(status: number): string { return `Oracle Fusion request failed with HTTP ${status}` } +/** `maxRedirects: 0` rejects a response with Location before returning its status. */ +function isRejectedRedirect(error: unknown): boolean { + return error instanceof Error && error.message === 'Too many redirects (max: 0)' +} + /** Executes one bounded, DNS-pinned GET against a fixed Oracle product API family. */ export async function requestOracleFusionJson( credential: OracleFusionResolvedCredential, @@ -220,6 +225,9 @@ export async function requestOracleFusionJson( response = await fetchAttempt(url, validation.resolvedIP, credential.accessToken, signal) } catch (error) { signal?.throwIfAborted() + if (isRejectedRedirect(error)) { + throw new OracleFusionProviderError('Oracle Fusion returned a redirect', 502) + } if (isPayloadSizeLimitError(error)) { throw new OracleFusionProviderError('Oracle Fusion response exceeded 5 MiB', 502) } From 623fc6eabadc4068fc39a544ad65c14223a86291 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Thu, 3 Sep 2026 17:48:50 -0700 Subject: [PATCH 05/15] fix(credentials): enforce service-account provider and kind centrally --- apps/sim/lib/oauth/token-resolution.test.ts | 169 ++++++++++++++++++ apps/sim/lib/oauth/token-resolution.ts | 48 ++++- .../lib/selectors/server/credentials.test.ts | 77 ++++++++ apps/sim/lib/selectors/server/credentials.ts | 10 ++ apps/sim/lib/selectors/server/types.ts | 2 + 5 files changed, 305 insertions(+), 1 deletion(-) diff --git a/apps/sim/lib/oauth/token-resolution.test.ts b/apps/sim/lib/oauth/token-resolution.test.ts index e06ee3c9c8d..103dd066301 100644 --- a/apps/sim/lib/oauth/token-resolution.test.ts +++ b/apps/sim/lib/oauth/token-resolution.test.ts @@ -6,8 +6,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockAuthorizeCredentialUseForAuth, mockCaptureServerEvent, + mockCredentialProviderMatchesService, mockExecuteManagedToken, mockGetCredential, + mockGetServiceConfigByProviderId, + mockGetServiceConfigByServiceId, mockGetToolMetadata, mockRecordAudit, mockRefreshTokenIfNeeded, @@ -16,8 +19,11 @@ const { } = vi.hoisted(() => ({ mockAuthorizeCredentialUseForAuth: vi.fn(), mockCaptureServerEvent: vi.fn(), + mockCredentialProviderMatchesService: vi.fn(), mockExecuteManagedToken: vi.fn(), mockGetCredential: vi.fn(), + mockGetServiceConfigByProviderId: vi.fn(), + mockGetServiceConfigByServiceId: vi.fn(), mockGetToolMetadata: vi.fn(), mockRecordAudit: vi.fn(), mockRefreshTokenIfNeeded: vi.fn(), @@ -78,7 +84,10 @@ vi.mock('@/tools/metadata', () => ({ })) vi.mock('@/lib/oauth/utils', () => ({ + credentialProviderMatchesService: mockCredentialProviderMatchesService, getCanonicalScopesForProvider: vi.fn().mockReturnValue([]), + getServiceConfigByProviderId: mockGetServiceConfigByProviderId, + getServiceConfigByServiceId: mockGetServiceConfigByServiceId, })) import { OrchestrationError } from '@/lib/core/orchestration/types' @@ -346,6 +355,12 @@ describe('resolveCredentialAccessToken', () => { beforeEach(() => { vi.clearAllMocks() mockResolveOAuthAccountId.mockResolvedValue(null) + mockCredentialProviderMatchesService.mockReturnValue(true) + mockGetServiceConfigByServiceId.mockReturnValue({ + providerId: 'google', + serviceAccountProviderId: 'google-service-account', + }) + mockGetServiceConfigByProviderId.mockReturnValue(null) authenticate.mockResolvedValue(INTERNAL_AUTH) resolveManagedPrincipal.mockResolvedValue(EXECUTOR_PRINCIPAL) mockGetToolMetadata.mockReturnValue({ @@ -411,6 +426,160 @@ describe('resolveCredentialAccessToken', () => { }) }) + it('rejects a service-account credential with no provider before authentication', async () => { + mockResolveOAuthAccountId.mockResolvedValue({ + credentialType: 'service_account', + credentialId: 'service-account-1', + workspaceId: 'ws-1', + accountId: '', + usedCredentialTable: true, + }) + mockGetToolMetadata.mockReturnValue({ + oauth: { + required: true, + provider: 'google', + credentialKind: 'service-account', + }, + }) + + await expect( + resolveCredentialAccessToken({ + requestId: 'req-1', + credentialId: 'service-account-1', + toolId: 'google_service_account_tool', + authenticate, + }) + ).resolves.toEqual({ + ok: false, + status: 403, + code: 'CREDENTIAL_PROVIDER_MISMATCH', + error: 'Credential belongs to another service', + }) + expect(authenticate).not.toHaveBeenCalled() + expect(mockResolveServiceAccountToken).not.toHaveBeenCalled() + }) + + it('rejects a service-account credential from another provider before authentication', async () => { + mockResolveOAuthAccountId.mockResolvedValue({ + credentialType: 'service_account', + credentialId: 'service-account-1', + providerId: 'atlassian-service-account', + workspaceId: 'ws-1', + accountId: '', + usedCredentialTable: true, + }) + mockGetToolMetadata.mockReturnValue({ + oauth: { + required: true, + provider: 'google', + credentialKind: 'service-account', + }, + }) + mockCredentialProviderMatchesService.mockReturnValue(false) + + await expect( + resolveCredentialAccessToken({ + requestId: 'req-1', + credentialId: 'service-account-1', + toolId: 'google_service_account_tool', + authenticate, + }) + ).resolves.toEqual({ + ok: false, + status: 403, + code: 'CREDENTIAL_PROVIDER_MISMATCH', + error: 'Credential belongs to another service', + }) + expect(authenticate).not.toHaveBeenCalled() + expect(mockResolveServiceAccountToken).not.toHaveBeenCalled() + }) + + it('accepts a non-Oracle service account whose provider matches the tool service', async () => { + mockResolveOAuthAccountId.mockResolvedValue({ + credentialType: 'service_account', + credentialId: 'service-account-1', + providerId: 'google-service-account', + workspaceId: 'ws-1', + accountId: '', + usedCredentialTable: true, + }) + mockGetToolMetadata.mockReturnValue({ + oauth: { + required: true, + provider: 'google-email', + requiredScopes: ['scope-a'], + credentialKind: 'service-account', + }, + }) + mockGetServiceConfigByServiceId.mockReturnValue(null) + mockGetServiceConfigByProviderId.mockReturnValue({ + providerId: 'google-email', + serviceAccountProviderId: 'google-service-account', + }) + mockAuthorizeCredentialUseForAuth.mockResolvedValue({ + ok: true, + requesterUserId: 'user-1', + workspaceId: 'ws-1', + }) + mockResolveServiceAccountToken.mockResolvedValue({ accessToken: 'service-account-token' }) + + await expect( + resolveCredentialAccessToken({ + requestId: 'req-1', + credentialId: 'service-account-1', + toolId: 'gmail_read', + scopes: ['scope-a'], + authenticate, + }) + ).resolves.toMatchObject({ + ok: true, + token: { accessToken: 'service-account-token', credentialType: 'service_account' }, + }) + expect(mockGetServiceConfigByProviderId).toHaveBeenCalledWith('google-email') + expect(mockResolveServiceAccountToken).toHaveBeenCalledWith( + 'service-account-1', + 'google-service-account', + ['scope-a'], + undefined + ) + }) + + it.each([ + ['an OAuth credential for a service-account-only tool', undefined, 'service-account'], + ['a service account for an OAuth-only tool', 'service_account', 'oauth'], + ])('rejects %s before authentication', async (_label, credentialType, requiredKind) => { + mockResolveOAuthAccountId.mockResolvedValue({ + ...(credentialType ? { credentialType } : {}), + credentialId: 'credential-1', + providerId: credentialType ? 'google-service-account' : undefined, + workspaceId: 'ws-1', + accountId: credentialType ? '' : 'account-1', + usedCredentialTable: true, + }) + mockGetToolMetadata.mockReturnValue({ + oauth: { + required: true, + provider: 'google', + credentialKind: requiredKind, + }, + }) + + const result = await resolveCredentialAccessToken({ + requestId: 'req-1', + credentialId: 'credential-1', + toolId: 'kind_restricted_tool', + authenticate, + }) + + expect(result).toEqual({ + ok: false, + status: 403, + code: 'CREDENTIAL_PROVIDER_MISMATCH', + error: 'Credential belongs to another service', + }) + expect(authenticate).not.toHaveBeenCalled() + }) + it('rejects a managed credential when no delegation resolver is wired', async () => { mockResolveOAuthAccountId.mockResolvedValue(MANAGED_RESOLVED) diff --git a/apps/sim/lib/oauth/token-resolution.ts b/apps/sim/lib/oauth/token-resolution.ts index dfd1f682b5b..f16b7a38548 100644 --- a/apps/sim/lib/oauth/token-resolution.ts +++ b/apps/sim/lib/oauth/token-resolution.ts @@ -24,7 +24,12 @@ import { MICROSOFT_DATAVERSE_PROVIDER_ID, } from '@/lib/oauth/microsoft-dataverse' import { extractSalesforceInstanceUrl, isSalesforceOAuthProviderId } from '@/lib/oauth/salesforce' -import { getCanonicalScopesForProvider } from '@/lib/oauth/utils' +import { + credentialProviderMatchesService, + getCanonicalScopesForProvider, + getServiceConfigByProviderId, + getServiceConfigByServiceId, +} from '@/lib/oauth/utils' import { captureServerEvent } from '@/lib/posthog/server' import { getToolMetadata } from '@/tools/metadata' import { extractZohoDeskBaseFromScope } from '@/tools/zoho_desk/host-allowlist' @@ -332,6 +337,45 @@ export interface ResolveCredentialAccessTokenInput resolveManagedPrincipal?: (credentialId: string) => Promise } +function credentialProviderMismatch(): ResolveCredentialTokenResult { + return { + ok: false, + status: 403, + code: 'CREDENTIAL_PROVIDER_MISMATCH', + error: 'Credential belongs to another service', + } +} + +function validateToolCredentialBinding( + resolved: ResolvedCredential | null, + toolId?: string +): ResolveCredentialTokenResult | null { + if (!resolved || !toolId) return null + + const oauth = getToolMetadata(toolId)?.oauth + const isServiceAccount = resolved.credentialType === 'service_account' + if ( + oauth?.credentialKind === 'service-account' + ? !isServiceAccount + : oauth?.credentialKind === 'oauth' && isServiceAccount + ) { + return credentialProviderMismatch() + } + if (!isServiceAccount) return null + + const service = oauth?.required + ? (getServiceConfigByServiceId(oauth.provider) ?? getServiceConfigByProviderId(oauth.provider)) + : null + if ( + !resolved.providerId || + !service || + !credentialProviderMatchesService(resolved.providerId, service) + ) { + return credentialProviderMismatch() + } + return null +} + /** * Authorized application dispatch behind `POST /api/auth/oauth/token`. Every server * surface that needs a credential token — the route and the in-process tool @@ -344,6 +388,8 @@ export async function resolveCredentialAccessToken( const { requestId, credentialId, toolId, auditRequest } = input const resolved = credentialId ? await resolveOAuthAccountId(credentialId) : null + const bindingError = validateToolCredentialBinding(resolved, toolId) + if (bindingError) return bindingError if (resolved?.credentialType !== 'managed_oauth' || !resolved.credentialId) { const auth = await input.authenticate() diff --git a/apps/sim/lib/selectors/server/credentials.test.ts b/apps/sim/lib/selectors/server/credentials.test.ts index 17138d2079a..518d53f3b41 100644 --- a/apps/sim/lib/selectors/server/credentials.test.ts +++ b/apps/sim/lib/selectors/server/credentials.test.ts @@ -125,6 +125,83 @@ describe('authorizeSelectorCredential', () => { expect(mocks.authorizeCredentialUse).not.toHaveBeenCalled() }) + it('rejects a fixed token for a service-account-only selector', async () => { + await expect( + authorizeSelectorCredential({ + principal, + context: { oauthCredential: 'xoxb-a' }, + scope: { kind: 'workspace', workspaceId: 'workspace-1' }, + workspaceId: 'workspace-1', + policy: { + kind: 'stored-or-fixed-token', + field: 'oauthCredential', + serviceIds: ['slack'], + tokenPrefixes: ['xoxb-'], + credentialKind: 'service-account', + }, + protectedValues: createSelectorProtectedValues(), + references: new Map(), + }) + ).rejects.toEqual(new SelectorConnectionUnavailableError()) + expect(mocks.authorizeCredentialUse).not.toHaveBeenCalled() + }) + + it.each([ + ['oauth', 'service-account'], + ['service_account', 'oauth'], + ] as const)( + 'rejects a %s credential for a %s-only selector before provider resolution', + async (credentialType, credentialKind) => { + mocks.authorizeCredentialUse.mockResolvedValue({ + ok: true, + workspaceId: 'workspace-1', + credentialOwnerUserId: 'owner-1', + resolvedCredentialId: 'credential-1', + credentialType, + }) + + await expect( + authorizeSelectorCredential({ + principal, + context: { oauthCredential: 'credential-1' }, + scope: { kind: 'workspace', workspaceId: 'workspace-1' }, + workspaceId: 'workspace-1', + policy: { ...policy, credentialKind }, + protectedValues: createSelectorProtectedValues(), + references: new Map(), + }) + ).rejects.toEqual(new SelectorConnectionUnavailableError()) + expect(mocks.credentialProviderMatchesService).not.toHaveBeenCalled() + } + ) + + it.each([ + ['oauth', 'oauth'], + ['service_account', 'service-account'], + ] as const)('accepts a matching %s credential kind', async (credentialType, credentialKind) => { + mocks.authorizeCredentialUse.mockResolvedValue({ + ok: true, + workspaceId: 'workspace-1', + credentialOwnerUserId: 'owner-1', + resolvedCredentialId: 'credential-1', + credentialType, + }) + queueTableRows(credential, [{ accountId: 'account-1', providerId: 'google' }]) + mocks.credentialProviderMatchesService.mockReturnValue(true) + + await expect( + authorizeSelectorCredential({ + principal, + context: { oauthCredential: 'credential-1' }, + scope: { kind: 'workspace', workspaceId: 'workspace-1' }, + workspaceId: 'workspace-1', + policy: { ...policy, credentialKind }, + protectedValues: createSelectorProtectedValues(), + references: new Map(), + }) + ).resolves.toMatchObject({ access: { credentialType } }) + }) + it('conceals a stored credential whose trusted provider does not match the selector service', async () => { mocks.authorizeCredentialUse.mockResolvedValue({ ok: true, diff --git a/apps/sim/lib/selectors/server/credentials.ts b/apps/sim/lib/selectors/server/credentials.ts index 2bc68c62275..5352aa21126 100644 --- a/apps/sim/lib/selectors/server/credentials.ts +++ b/apps/sim/lib/selectors/server/credentials.ts @@ -111,6 +111,9 @@ export async function authorizeSelectorCredential(input: { input.policy.kind === 'stored-or-fixed-token' && input.policy.tokenPrefixes.some((prefix) => suppliedId.startsWith(prefix)) ) { + if (input.policy.credentialKind === 'service-account') { + throw new SelectorConnectionUnavailableError() + } const reference = input.references.get(input.policy.field) if (reference && !reference.visible) { input.protectedValues.add(suppliedId, 'secret') @@ -133,6 +136,13 @@ export async function authorizeSelectorCredential(input: { if (!access.ok || access.workspaceId !== input.workspaceId) { throw new SelectorConnectionUnavailableError() } + if ( + input.policy.credentialKind === 'service-account' + ? access.credentialType !== 'service_account' + : input.policy.credentialKind === 'oauth' && access.credentialType === 'service_account' + ) { + throw new SelectorConnectionUnavailableError() + } input.protectedValues.add(access.resolvedCredentialId, 'reference') const providerId = await requireCredentialProviderBinding( diff --git a/apps/sim/lib/selectors/server/types.ts b/apps/sim/lib/selectors/server/types.ts index 4249782a7e9..5dc6283b4d3 100644 --- a/apps/sim/lib/selectors/server/types.ts +++ b/apps/sim/lib/selectors/server/types.ts @@ -34,6 +34,7 @@ export type SelectorCredentialPolicy = field: 'oauthCredential' serviceIds: readonly string[] resourceServiceId?: string + credentialKind?: 'oauth' | 'service-account' } | { kind: 'stored-or-fixed-token' @@ -41,6 +42,7 @@ export type SelectorCredentialPolicy = serviceIds: readonly string[] tokenPrefixes: readonly string[] resourceServiceId?: string + credentialKind?: 'oauth' | 'service-account' } export interface AuthorizedSelectorCredential { From a63a436b537b89a5703e8e3c830af87d2e61b28e Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Thu, 3 Sep 2026 17:54:38 -0700 Subject: [PATCH 06/15] refactor(oracle-fusion): stabilize addressing and protocol primitives --- .../lib/internal/oracle-fusion/client.test.ts | 62 ++++++---- apps/sim/lib/internal/oracle-fusion/client.ts | 86 ++------------ .../oracle-fusion/identifiers.test.ts | 73 ++++++++++++ .../lib/internal/oracle-fusion/identifiers.ts | 107 ++++++++++++++++++ .../lib/internal/oracle-fusion/paths.test.ts | 50 ++++++++ apps/sim/lib/internal/oracle-fusion/paths.ts | 66 +++++++++++ .../internal/oracle-fusion/protocol.test.ts | 82 +++++++++++--- .../lib/internal/oracle-fusion/protocol.ts | 71 +++++++++--- 8 files changed, 470 insertions(+), 127 deletions(-) create mode 100644 apps/sim/lib/internal/oracle-fusion/identifiers.test.ts create mode 100644 apps/sim/lib/internal/oracle-fusion/identifiers.ts create mode 100644 apps/sim/lib/internal/oracle-fusion/paths.test.ts create mode 100644 apps/sim/lib/internal/oracle-fusion/paths.ts diff --git a/apps/sim/lib/internal/oracle-fusion/client.test.ts b/apps/sim/lib/internal/oracle-fusion/client.test.ts index 7d54966f354..f51e3150bb7 100644 --- a/apps/sim/lib/internal/oracle-fusion/client.test.ts +++ b/apps/sim/lib/internal/oracle-fusion/client.test.ts @@ -59,13 +59,13 @@ describe('requestOracleFusionJson', () => { it.each([ ['hcm', '/hcmRestApi/resources/11.13.18.05/workers'], ['fscm', '/fscmRestApi/resources/11.13.18.05/invoices'], + ['crm', '/crmRestApi/resources/11.13.18.05/opportunities'], ] as const)( 'pins the %s API family, headers, DNS result, and GET method', async (family, path) => { await expect( requestOracleFusionJson(CREDENTIAL, { - family, - path: path.split('/').at(-1)!, + address: { family, relativePath: path.split('/').at(-1)! }, query: { q: 'Name="A B"', limit: 25, expand: undefined, onlyData: true }, }) ).resolves.toEqual({ items: [] }) @@ -114,9 +114,11 @@ describe('requestOracleFusionJson', () => { 'workers/%2Fusers', 'workers/%5cusers', ])('rejects the unsafe relative path %j before DNS or fetch', async (path) => { - await expect(requestOracleFusionJson(CREDENTIAL, { family: 'hcm', path })).rejects.toThrow( - /safe relative path|traversal/ - ) + await expect( + requestOracleFusionJson(CREDENTIAL, { + address: { family: 'hcm', relativePath: path }, + }) + ).rejects.toThrow(/resource path/) expect(mockValidateUrl).not.toHaveBeenCalled() expect(mockSecureFetch).not.toHaveBeenCalled() }) @@ -124,8 +126,7 @@ describe('requestOracleFusionJson', () => { it('accepts the URL-safe encoding produced for an opaque key containing a percent sign', async () => { await expect( requestOracleFusionJson(CREDENTIAL, { - family: 'hcm', - path: 'workers/key%252Fpart', + address: { family: 'hcm', relativePath: 'workers/key%252Fpart' }, }) ).resolves.toEqual({ items: [] }) expect(new URL(mockSecureFetch.mock.calls[0][0]).pathname).toMatch(/\/workers\/key%252Fpart$/) @@ -134,7 +135,9 @@ describe('requestOracleFusionJson', () => { it('rejects a non-public DNS result before fetching', async () => { mockValidateUrl.mockResolvedValueOnce({ isValid: false, error: 'private address' }) await expect( - requestOracleFusionJson(CREDENTIAL, { family: 'hcm', path: 'workers' }) + requestOracleFusionJson(CREDENTIAL, { + address: { family: 'hcm', relativePath: 'workers' }, + }) ).rejects.toThrow('not a public endpoint') expect(mockSecureFetch).not.toHaveBeenCalled() }) @@ -146,7 +149,9 @@ describe('requestOracleFusionJson', () => { .mockResolvedValueOnce(response(504, 'secret provider body')) await expect( - requestOracleFusionJson(CREDENTIAL, { family: 'fscm', path: 'invoices' }) + requestOracleFusionJson(CREDENTIAL, { + address: { family: 'fscm', relativePath: 'invoices' }, + }) ).rejects.toMatchObject({ status: 504 }) expect(mockSecureFetch).toHaveBeenCalledTimes(3) expect(mockSleep).toHaveBeenNthCalledWith(1, 30_000, undefined) @@ -158,8 +163,7 @@ describe('requestOracleFusionJson', () => { response(302, `redirect ${BASIC}`, { location: 'https://evil.example' }) ) const error = await requestOracleFusionJson(CREDENTIAL, { - family: 'hcm', - path: 'workers', + address: { family: 'hcm', relativePath: 'workers' }, }).catch((caught: unknown) => caught) expect(error).toMatchObject({ status: 302 }) expect(String(error)).not.toContain('evil.example') @@ -169,8 +173,7 @@ describe('requestOracleFusionJson', () => { it('classifies redirects rejected by the pinned transport without exposing details', async () => { mockSecureFetch.mockRejectedValueOnce(new Error('Too many redirects (max: 0)')) const error = await requestOracleFusionJson(CREDENTIAL, { - family: 'hcm', - path: 'workers', + address: { family: 'hcm', relativePath: 'workers' }, }).catch((caught: unknown) => caught) expect(error).toMatchObject({ message: 'Oracle Fusion returned a redirect', status: 502 }) expect(String(error)).not.toContain(ORIGIN) @@ -185,7 +188,9 @@ describe('requestOracleFusionJson', () => { ) ) await expect( - requestOracleFusionJson(CREDENTIAL, { family: 'hcm', path: 'workers' }) + requestOracleFusionJson(CREDENTIAL, { + address: { family: 'hcm', relativePath: 'workers' }, + }) ).resolves.toEqual({ id: '9007199254740993', negative: '-9007199254740993', @@ -201,7 +206,9 @@ describe('requestOracleFusionJson', () => { const token = `9007199254740993${'0'.repeat(1_000_000)}e-1000000` mockSecureFetch.mockResolvedValueOnce(response(200, `{"id":${token}}`)) await expect( - requestOracleFusionJson(CREDENTIAL, { family: 'hcm', path: 'workers' }) + requestOracleFusionJson(CREDENTIAL, { + address: { family: 'hcm', relativePath: 'workers' }, + }) ).resolves.toEqual({ id: token }) }) @@ -213,7 +220,7 @@ describe('requestOracleFusionJson', () => { ) const error = await requestOracleFusionJson( { ...CREDENTIAL, accessToken }, - { family: 'hcm', path: 'workers' } + { address: { family: 'hcm', relativePath: 'workers' } } ).catch((caught: unknown) => caught) expect(error).toBeInstanceOf(OracleFusionProviderError) expect(error).toMatchObject({ message: 'Oracle Fusion authentication failed', status: 401 }) @@ -225,19 +232,25 @@ describe('requestOracleFusionJson', () => { it('classifies timeout, response-limit, and malformed JSON failures', async () => { mockSecureFetch.mockRejectedValueOnce(new Error('Request timed out after 30000ms')) await expect( - requestOracleFusionJson(CREDENTIAL, { family: 'hcm', path: 'workers' }) + requestOracleFusionJson(CREDENTIAL, { + address: { family: 'hcm', relativePath: 'workers' }, + }) ).rejects.toMatchObject({ message: 'Oracle Fusion request timed out', status: 504 }) mockSecureFetch.mockRejectedValueOnce( new PayloadSizeLimitError({ label: 'response', maxBytes: 5 * 1024 * 1024 }) ) await expect( - requestOracleFusionJson(CREDENTIAL, { family: 'hcm', path: 'workers' }) + requestOracleFusionJson(CREDENTIAL, { + address: { family: 'hcm', relativePath: 'workers' }, + }) ).rejects.toMatchObject({ message: 'Oracle Fusion response exceeded 5 MiB', status: 502 }) mockSecureFetch.mockResolvedValueOnce(response(200, 'not-json')) await expect( - requestOracleFusionJson(CREDENTIAL, { family: 'hcm', path: 'workers' }) + requestOracleFusionJson(CREDENTIAL, { + address: { family: 'hcm', relativePath: 'workers' }, + }) ).rejects.toMatchObject({ message: 'Oracle Fusion returned malformed JSON', status: 502 }) }) @@ -246,7 +259,11 @@ describe('requestOracleFusionJson', () => { const reason = new DOMException('cancelled', 'AbortError') controller.abort(reason) await expect( - requestOracleFusionJson(CREDENTIAL, { family: 'hcm', path: 'workers' }, controller.signal) + requestOracleFusionJson( + CREDENTIAL, + { address: { family: 'hcm', relativePath: 'workers' } }, + controller.signal + ) ).rejects.toBe(reason) expect(mockValidateUrl).not.toHaveBeenCalled() expect(mockSecureFetch).not.toHaveBeenCalled() @@ -256,13 +273,12 @@ describe('requestOracleFusionJson', () => { await expect( requestOracleFusionJson( { ...CREDENTIAL, accessToken: 'not basic\r\n' }, - { family: 'hcm', path: 'workers' } + { address: { family: 'hcm', relativePath: 'workers' } } ) ).rejects.toThrow('credential is malformed') await expect( requestOracleFusionJson(CREDENTIAL, { - family: 'hcm', - path: 'workers', + address: { family: 'hcm', relativePath: 'workers' }, query: { limit: Number.POSITIVE_INFINITY }, }) ).rejects.toThrow('query values must be finite') diff --git a/apps/sim/lib/internal/oracle-fusion/client.ts b/apps/sim/lib/internal/oracle-fusion/client.ts index a90add979f2..9473d7ccc54 100644 --- a/apps/sim/lib/internal/oracle-fusion/client.ts +++ b/apps/sim/lib/internal/oracle-fusion/client.ts @@ -8,20 +8,17 @@ import { import { consumeOrCancelBody, isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { normalizeOracleFusionApplicationOrigin } from '@/lib/credentials/client-credential-accounts/descriptors' import { OracleFusionProviderError } from '@/lib/internal/oracle-fusion/errors' +import { isOracleFusionIntegralJsonNumberToken } from '@/lib/internal/oracle-fusion/identifiers' +import { + buildOracleFusionResourcePath, + type OracleFusionResourceAddress, +} from '@/lib/internal/oracle-fusion/paths' const REQUEST_TIMEOUT_MS = 30_000 const RESPONSE_MAX_BYTES = 5 * 1024 * 1024 const MAX_RETRIES = 2 const TRANSIENT_STATUSES = new Set([429, 503, 504]) -const API_VERSION = '11.13.18.05' -const API_ROOTS = { - hcm: `/hcmRestApi/resources/${API_VERSION}`, - fscm: `/fscmRestApi/resources/${API_VERSION}`, -} as const -const UNSAFE_PATH_ENCODING = /%(?:2e|2f|5c|3f|23)/i -const ABSOLUTE_PATH = /^[a-z][a-z0-9+.-]*:/i const CANONICAL_BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/ -const JSON_NUMBER_TOKEN = /^-?(0|[1-9]\d*)(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/ interface JsonParseContext { source?: string @@ -34,35 +31,16 @@ type JsonParseWithSource = ( const jsonParseWithSource = JSON.parse as JsonParseWithSource -export type OracleFusionApiFamily = 'hcm' | 'fscm' - export interface OracleFusionResolvedCredential { instanceUrl: string accessToken: string } export interface OracleFusionRequest { - family: OracleFusionApiFamily - path: string + address: OracleFusionResourceAddress query?: Record } -function compareDecimalMagnitudeToInteger(magnitude: string, value: number): number { - const normalizedMagnitude = magnitude.replace(/^0+/, '') || '0' - const integer = String(value) - if (normalizedMagnitude.length !== integer.length) { - return normalizedMagnitude.length < integer.length ? -1 : 1 - } - if (normalizedMagnitude === integer) return 0 - return normalizedMagnitude < integer ? -1 : 1 -} - -function trailingZeroCount(value: string): number { - let count = 0 - for (let index = value.length - 1; index >= 0 && value[index] === '0'; index--) count++ - return count -} - function validateBasicCredential(accessToken: string): void { if ( !accessToken || @@ -74,32 +52,9 @@ function validateBasicCredential(accessToken: string): void { } } -function validateRelativePath(path: string): void { - if ( - !path || - path !== path.trim() || - path.startsWith('/') || - path.startsWith('//') || - ABSOLUTE_PATH.test(path) || - path.includes('\\') || - path.includes('?') || - path.includes('#') || - UNSAFE_PATH_ENCODING.test(path) - ) { - throw new Error('Oracle Fusion resource path must be a safe relative path') - } - for (const segment of path.split('/')) { - if (segment === '.' || segment === '..') { - throw new Error('Oracle Fusion resource path must not contain traversal') - } - } -} - function buildRequestUrl(origin: string, request: OracleFusionRequest): string { - validateRelativePath(request.path) - const root = API_ROOTS[request.family] - if (!root) throw new Error('Oracle Fusion API family is unsupported') - const url = new URL(`${origin}${root}/${request.path}`) + const resourcePath = buildOracleFusionResourcePath(request.address) + const url = new URL(`${origin}${resourcePath}`) for (const [key, value] of Object.entries(request.query ?? {})) { if (value === undefined) continue if (typeof value === 'number' && !Number.isFinite(value)) { @@ -107,38 +62,17 @@ function buildRequestUrl(origin: string, request: OracleFusionRequest): string { } url.searchParams.set(key, String(value)) } - if (url.origin !== origin || !url.pathname.startsWith(`${root}/`)) { + if (url.origin !== origin || url.pathname !== resourcePath) { throw new Error('Oracle Fusion request must remain on the credential-bound API root') } return url.toString() } -function isIntegralJsonNumberToken(source: string): boolean { - const match = JSON_NUMBER_TOKEN.exec(source) - if (!match) return false - const coefficient = `${match[1]}${match[2] ?? ''}` - if (/^0+$/.test(coefficient)) return true - - const fractionDigits = match[2]?.length ?? 0 - const exponentSource = match[3] ?? '0' - const exponentMagnitude = exponentSource.replace(/^[+-]/, '').replace(/^0+/, '') || '0' - const availableTrailingZeros = trailingZeroCount(coefficient) - - if (exponentSource.startsWith('-')) { - if (compareDecimalMagnitudeToInteger(exponentMagnitude, availableTrailingZeros) > 0) { - return false - } - return fractionDigits + Number(exponentMagnitude) <= availableTrailingZeros - } - if (compareDecimalMagnitudeToInteger(exponentMagnitude, fractionDigits) >= 0) return true - return fractionDigits - Number(exponentMagnitude) <= availableTrailingZeros -} - function parseOracleFusionJson(body: string): unknown { return jsonParseWithSource(body, (_key, value, context) => { if (typeof value !== 'number' || Number.isSafeInteger(value)) return value const source = context?.source - return source && isIntegralJsonNumberToken(source) ? source : value + return source && isOracleFusionIntegralJsonNumberToken(source) ? source : value }) } diff --git a/apps/sim/lib/internal/oracle-fusion/identifiers.test.ts b/apps/sim/lib/internal/oracle-fusion/identifiers.test.ts new file mode 100644 index 00000000000..a3a0d587abb --- /dev/null +++ b/apps/sim/lib/internal/oracle-fusion/identifiers.test.ts @@ -0,0 +1,73 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + isOracleFusionIntegralJsonNumberToken, + normalizeOracleFusionDecimalIdentifier, +} from '@/lib/internal/oracle-fusion/identifiers' + +const OPTIONS = { maxDigits: 128 } + +describe('isOracleFusionIntegralJsonNumberToken', () => { + it.each([ + '9007199254740993', + '-9007199254740993', + '9007199254740993.0', + '9.007199254740993e15', + '1e999', + `9007199254740993${'0'.repeat(100)}e-100`, + ])('recognizes the exact integral token %j', (source) => { + expect(isOracleFusionIntegralJsonNumberToken(source)).toBe(true) + }) + + it.each(['1.25', '1e-1', '1.23e1', 'not-a-number'])('rejects %j as non-integral', (source) => { + expect(isOracleFusionIntegralJsonNumberToken(source)).toBe(false) + }) +}) + +describe('normalizeOracleFusionDecimalIdentifier', () => { + it.each([ + [0, '0'], + [42, '42'], + ['9223372036854775807', '9223372036854775807'], + ['9.223372036854775807e18', '9223372036854775807'], + ['123.000', '123'], + ['1.23e2', '123'], + ['1000e-3', '1'], + ['0.001e3', '1'], + ['0e999999999999999999999999', '0'], + ])('canonicalizes %j to %s', (value, expected) => { + expect(normalizeOracleFusionDecimalIdentifier(value, OPTIONS)).toBe(expected) + }) + + it.each([ + -1, + 1.25, + Number.MAX_SAFE_INTEGER + 1, + Number.POSITIVE_INFINITY, + '-1', + '-0', + '+1', + '01', + '1.25', + '1e-1', + '1e129', + `1${'0'.repeat(128)}`, + '1'.repeat(129), + ])('rejects the non-canonical or out-of-range identifier %j', (value) => { + expect(normalizeOracleFusionDecimalIdentifier(value, OPTIONS)).toBeUndefined() + }) + + it('checks configured limits before expanding exponent notation', () => { + expect( + normalizeOracleFusionDecimalIdentifier('1e63', { maxDigits: 64, maxSourceLength: 64 }) + ).toBe(`1${'0'.repeat(63)}`) + expect( + normalizeOracleFusionDecimalIdentifier('1e64', { maxDigits: 64, maxSourceLength: 64 }) + ).toBeUndefined() + expect(() => + normalizeOracleFusionDecimalIdentifier('1', { maxDigits: 129, maxSourceLength: 128 }) + ).toThrow('limits are invalid') + }) +}) diff --git a/apps/sim/lib/internal/oracle-fusion/identifiers.ts b/apps/sim/lib/internal/oracle-fusion/identifiers.ts new file mode 100644 index 00000000000..737958f03d3 --- /dev/null +++ b/apps/sim/lib/internal/oracle-fusion/identifiers.ts @@ -0,0 +1,107 @@ +const INTEGRAL_JSON_NUMBER_TOKEN = /^-?(0|[1-9]\d*)(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/ +const NON_NEGATIVE_INTEGRAL_TOKEN = /^(0|[1-9]\d*)(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/ +const DEFAULT_MAX_SOURCE_LENGTH = 128 + +function compareDecimalMagnitudeToInteger(magnitude: string, value: number): number { + const normalizedMagnitude = magnitude.replace(/^0+/, '') || '0' + const integer = String(value) + if (normalizedMagnitude.length !== integer.length) { + return normalizedMagnitude.length < integer.length ? -1 : 1 + } + if (normalizedMagnitude === integer) return 0 + return normalizedMagnitude < integer ? -1 : 1 +} + +function trailingZeroCount(value: string): number { + let count = 0 + for (let index = value.length - 1; index >= 0 && value[index] === '0'; index--) count++ + return count +} + +/** Whether one JSON number token denotes an exact integer without expanding its exponent. */ +export function isOracleFusionIntegralJsonNumberToken(source: string): boolean { + const match = INTEGRAL_JSON_NUMBER_TOKEN.exec(source) + if (!match) return false + const coefficient = `${match[1]}${match[2] ?? ''}` + if (/^0+$/.test(coefficient)) return true + + const fractionDigits = match[2]?.length ?? 0 + const exponentSource = match[3] ?? '0' + const exponentMagnitude = exponentSource.replace(/^[+-]/, '').replace(/^0+/, '') || '0' + const availableTrailingZeros = trailingZeroCount(coefficient) + + if (exponentSource.startsWith('-')) { + if (compareDecimalMagnitudeToInteger(exponentMagnitude, availableTrailingZeros) > 0) { + return false + } + return fractionDigits + Number(exponentMagnitude) <= availableTrailingZeros + } + if (compareDecimalMagnitudeToInteger(exponentMagnitude, fractionDigits) >= 0) return true + return fractionDigits - Number(exponentMagnitude) <= availableTrailingZeros +} + +function parseBoundedExponent(exponentText: string, maximumMagnitude: number): number | undefined { + const negative = exponentText.startsWith('-') + const unsigned = exponentText.replace(/^[+-]/, '').replace(/^0+(?=\d)/, '') + const maximum = String(maximumMagnitude) + if ( + unsigned.length > maximum.length || + (unsigned.length === maximum.length && unsigned > maximum) + ) { + return undefined + } + const magnitude = Number(unsigned) + return negative ? -magnitude : magnitude +} + +export interface OracleFusionDecimalIdentifierOptions { + maxDigits: number + maxSourceLength?: number +} + +/** Canonicalizes one exact non-negative integral identifier without JS-number precision loss. */ +export function normalizeOracleFusionDecimalIdentifier( + value: unknown, + options: OracleFusionDecimalIdentifierOptions +): string | undefined { + const maxSourceLength = options.maxSourceLength ?? DEFAULT_MAX_SOURCE_LENGTH + if ( + !Number.isSafeInteger(options.maxDigits) || + options.maxDigits < 1 || + !Number.isSafeInteger(maxSourceLength) || + maxSourceLength < 1 || + options.maxDigits > maxSourceLength + ) { + throw new Error('Oracle Fusion decimal identifier limits are invalid') + } + if (typeof value === 'number') { + return Number.isSafeInteger(value) && value >= 0 && String(value).length <= options.maxDigits + ? String(value) + : undefined + } + if (typeof value !== 'string' || value.length > maxSourceLength) return undefined + const match = NON_NEGATIVE_INTEGRAL_TOKEN.exec(value) + if (!match) return undefined + + const integer = match[1] + const fraction = match[2] ?? '' + const exponentText = match[3] ?? '0' + const coefficient = `${integer}${fraction}` + if (/^0+$/.test(coefficient)) return '0' + const significantCoefficient = coefficient.replace(/^0+/, '') + const maximumRelevantExponent = Math.max(integer.length, fraction.length + options.maxDigits) + const exponent = parseBoundedExponent(exponentText, maximumRelevantExponent) + if (exponent === undefined) return undefined + + const scale = exponent - fraction.length + if (scale >= 0) { + if (significantCoefficient.length + scale > options.maxDigits) return undefined + return `${significantCoefficient}${'0'.repeat(scale)}` + } + + const fractionalDigits = -scale + if (fractionalDigits > significantCoefficient.length) return undefined + const suffix = significantCoefficient.slice(significantCoefficient.length - fractionalDigits) + if (!/^0*$/.test(suffix)) return undefined + return significantCoefficient.slice(0, significantCoefficient.length - fractionalDigits) || '0' +} diff --git a/apps/sim/lib/internal/oracle-fusion/paths.test.ts b/apps/sim/lib/internal/oracle-fusion/paths.test.ts new file mode 100644 index 00000000000..13ae1a89971 --- /dev/null +++ b/apps/sim/lib/internal/oracle-fusion/paths.test.ts @@ -0,0 +1,50 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { buildOracleFusionResourcePath } from '@/lib/internal/oracle-fusion/paths' + +describe('buildOracleFusionResourcePath', () => { + it.each([ + ['hcm', 'workers', '/hcmRestApi/resources/11.13.18.05/workers'], + ['fscm', 'invoices/123', '/fscmRestApi/resources/11.13.18.05/invoices/123'], + ['crm', 'opportunities', '/crmRestApi/resources/11.13.18.05/opportunities'], + ] as const)('builds the fixed %s resource root', (family, relativePath, expected) => { + expect(buildOracleFusionResourcePath({ family, relativePath })).toBe(expected) + }) + + it('preserves safe URL encoding in an opaque path segment', () => { + expect( + buildOracleFusionResourcePath({ family: 'hcm', relativePath: 'workers/key%252Fpart' }) + ).toBe('/hcmRestApi/resources/11.13.18.05/workers/key%252Fpart') + }) + + it.each([ + '', + ' workers', + 'workers ', + 'workers/bad key', + '/workers', + '//evil.example/workers', + 'https://evil.example/workers', + 'workers//assignments', + 'workers/', + 'workers/../users', + 'workers/./users', + 'workers\\users', + 'workers?limit=1', + 'workers#fragment', + 'workers/%2e%2e/users', + 'workers/%2Fusers', + 'workers/%5cusers', + 'workers/%3Fquery', + 'workers/%23fragment', + 'workers/%00control', + 'workers/%E0%A4%A', + 'workers/\ud800', + ])('rejects the unsafe relative path %j', (relativePath) => { + expect(() => buildOracleFusionResourcePath({ family: 'hcm', relativePath })).toThrow( + /resource path/ + ) + }) +}) diff --git a/apps/sim/lib/internal/oracle-fusion/paths.ts b/apps/sim/lib/internal/oracle-fusion/paths.ts new file mode 100644 index 00000000000..6c791bfefb4 --- /dev/null +++ b/apps/sim/lib/internal/oracle-fusion/paths.ts @@ -0,0 +1,66 @@ +const API_VERSION = '11.13.18.05' +const API_ROOTS = { + hcm: `/hcmRestApi/resources/${API_VERSION}`, + fscm: `/fscmRestApi/resources/${API_VERSION}`, + crm: `/crmRestApi/resources/${API_VERSION}`, +} as const +const ABSOLUTE_PATH = /^[a-z][a-z0-9+.-]*:/i +const UNSAFE_PATH_ENCODING = /%(?:2e|2f|5c|3f|23)/i +const PATH_CONTROL = /[\u0000-\u001f\u007f]/ +const PATH_WHITESPACE = /\s/ + +export type OracleFusionApiFamily = keyof typeof API_ROOTS + +export interface OracleFusionResourceAddress { + family: OracleFusionApiFamily + relativePath: string +} + +function validateRelativePath(relativePath: string): void { + if ( + !relativePath || + relativePath !== relativePath.trim() || + relativePath.startsWith('/') || + ABSOLUTE_PATH.test(relativePath) || + relativePath.includes('\\') || + relativePath.includes('?') || + relativePath.includes('#') || + PATH_CONTROL.test(relativePath) || + PATH_WHITESPACE.test(relativePath) || + UNSAFE_PATH_ENCODING.test(relativePath) + ) { + throw new Error('Oracle Fusion resource path must be a safe relative path') + } + + for (const segment of relativePath.split('/')) { + if (!segment || segment === '.' || segment === '..') { + throw new Error('Oracle Fusion resource path must not contain empty or traversal segments') + } + let decoded: string + try { + decoded = decodeURIComponent(segment) + void encodeURIComponent(decoded) + } catch { + throw new Error('Oracle Fusion resource path contains invalid URL encoding') + } + if ( + decoded === '.' || + decoded === '..' || + decoded.includes('/') || + decoded.includes('\\') || + decoded.includes('?') || + decoded.includes('#') || + PATH_CONTROL.test(decoded) + ) { + throw new Error('Oracle Fusion resource path must be a safe relative path') + } + } +} + +/** Builds one canonical path beneath a fixed Oracle Fusion product API root. */ +export function buildOracleFusionResourcePath(address: OracleFusionResourceAddress): string { + validateRelativePath(address.relativePath) + const root = API_ROOTS[address.family] + if (!root) throw new Error('Oracle Fusion API family is unsupported') + return `${root}/${address.relativePath}` +} diff --git a/apps/sim/lib/internal/oracle-fusion/protocol.test.ts b/apps/sim/lib/internal/oracle-fusion/protocol.test.ts index 8804098a3df..fd7783e745b 100644 --- a/apps/sim/lib/internal/oracle-fusion/protocol.test.ts +++ b/apps/sim/lib/internal/oracle-fusion/protocol.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment node */ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { encodeOracleFusionPathSegment, extractOracleFusionOpaqueKey, @@ -11,6 +11,7 @@ import { const ORIGIN = 'https://vision.fa.us2.oraclecloud.com' const COLLECTION = '/hcmRestApi/resources/11.13.18.05/workers' +const COLLECTION_ADDRESS = { family: 'hcm', relativePath: 'workers' } as const function resource(href: unknown, links: unknown[] = []): Record { return { links: [{ rel: 'self', href }, ...links] } @@ -44,18 +45,59 @@ describe('parseOracleFusionCollection', () => { }) }) - it('accepts an empty terminal page without inventing nextOffset', () => { + it('accepts an empty terminal page and returns its current nextOffset', () => { expect( parseOracleFusionCollection( { items: [], count: 0, hasMore: false, limit: 25, offset: 0 }, (item) => item ) - ).toEqual({ items: [], count: 0, hasMore: false, limit: 25, offset: 0 }) + ).toEqual({ items: [], count: 0, hasMore: false, limit: 25, offset: 0, nextOffset: 0 }) + }) + + it('accepts omitted items only for an unambiguous empty terminal page', () => { + expect( + parseOracleFusionCollection( + { count: 0, hasMore: false, limit: 25, offset: 10 }, + (item) => item, + { expectedOffset: 10, maxItems: 25 } + ) + ).toEqual({ + items: [], + count: 0, + hasMore: false, + limit: 25, + offset: 10, + nextOffset: 10, + }) + }) + + it('validates expected offset and item limits before projection', () => { + const parseItem = vi.fn((item) => item) + const page = { items: [{ id: 1 }], count: 1, hasMore: false, limit: 5, offset: 4 } + expect(() => + parseOracleFusionCollection(page, parseItem, { expectedOffset: 3, maxItems: 5 }) + ).toThrow('requested offset') + expect(() => + parseOracleFusionCollection(page, parseItem, { expectedOffset: 4, maxItems: 0 }) + ).toThrow('item limit') + expect(parseItem).not.toHaveBeenCalled() + }) + + it('does not require the returned limit to equal the caller item cap', () => { + expect( + parseOracleFusionCollection( + { items: [{ id: 1 }], count: 1, hasMore: false, limit: 73, offset: 0 }, + (item) => item, + { expectedOffset: 0, maxItems: 20 } + ) + ).toMatchObject({ limit: 73, count: 1, nextOffset: 1 }) }) it.each([ [null, 'must be an object'], - [{}, 'items must be an array'], + [{}, 'count'], + [{ count: 1, hasMore: false, limit: 25, offset: 0 }, 'items must be an array'], + [{ count: 0, hasMore: true, limit: 25, offset: 0 }, 'items must be an array'], [{ items: [], count: -1, hasMore: false, limit: 25, offset: 0 }, 'count'], [{ items: [], count: 0, hasMore: 'no', limit: 25, offset: 0 }, 'hasMore'], [{ items: [{}], count: 0, hasMore: false, limit: 25, offset: 0 }, 'match'], @@ -74,11 +116,10 @@ describe('parseOracleFusionCollection', () => { describe('Oracle self links', () => { it('accepts exactly one same-origin self link for the expected path', () => { expect(() => - validateOracleFusionSelfLink( - resource(`${ORIGIN}${COLLECTION}/abc`), - ORIGIN, - `${COLLECTION}/abc` - ) + validateOracleFusionSelfLink(resource(`${ORIGIN}${COLLECTION}/abc`), ORIGIN, { + family: 'hcm', + relativePath: 'workers/abc', + }) ).not.toThrow() }) @@ -95,9 +136,12 @@ describe('Oracle self links', () => { [resource(`${ORIGIN}${COLLECTION}/abc?secret=value`), 'credential-bound origin'], [resource(`${ORIGIN}${COLLECTION}/other`), 'requested resource path'], ])('rejects missing, duplicate, malformed, or unbound self links %#', (value, message) => { - expect(() => validateOracleFusionSelfLink(value, ORIGIN, `${COLLECTION}/abc`)).toThrow( - message as string - ) + expect(() => + validateOracleFusionSelfLink(value, ORIGIN, { + family: 'hcm', + relativePath: 'workers/abc', + }) + ).toThrow(message as string) }) it('extracts and URL-encodes an opaque key without changing its value', () => { @@ -108,18 +152,24 @@ describe('Oracle self links', () => { extractOracleFusionOpaqueKey( resource(`${ORIGIN}${COLLECTION}/${encoded}`), ORIGIN, - COLLECTION + COLLECTION_ADDRESS ) ).toBe(key) }) - it.each(['', '.', '..', 'a/b', 'a\\b', 'a?b', 'a#b', 'a\nb', 'x'.repeat(2049)])( + it.each(['', ' ', '.', '..', 'a/b', 'a\\b', 'a?b', 'a#b', 'a\nb', 'x'.repeat(2049)])( 'rejects the unsafe opaque key %j', (key) => { expect(() => encodeOracleFusionPathSegment(key)).toThrow('safe opaque path segment') } ) + it('rejects malformed Unicode without leaking a URI error', () => { + expect(() => encodeOracleFusionPathSegment('\ud800')).toThrow( + 'Oracle resource key contains malformed Unicode' + ) + }) + it.each([ [`${ORIGIN}/other/abc`, 'collection path'], [`${ORIGIN}${COLLECTION}/a/b`, 'one opaque key'], @@ -127,6 +177,8 @@ describe('Oracle self links', () => { [`${ORIGIN}${COLLECTION}/a%5Cb`, 'one opaque key'], [`${ORIGIN}${COLLECTION}/%E0%A4%A`, 'invalid URL encoding'], ])('rejects an unsafe opaque-key self link %j', (href, message) => { - expect(() => extractOracleFusionOpaqueKey(resource(href), ORIGIN, COLLECTION)).toThrow(message) + expect(() => extractOracleFusionOpaqueKey(resource(href), ORIGIN, COLLECTION_ADDRESS)).toThrow( + message + ) }) }) diff --git a/apps/sim/lib/internal/oracle-fusion/protocol.ts b/apps/sim/lib/internal/oracle-fusion/protocol.ts index a6e1824a01e..0776d9704a5 100644 --- a/apps/sim/lib/internal/oracle-fusion/protocol.ts +++ b/apps/sim/lib/internal/oracle-fusion/protocol.ts @@ -1,4 +1,8 @@ import { normalizeOracleFusionApplicationOrigin } from '@/lib/credentials/client-credential-accounts/descriptors' +import { + buildOracleFusionResourcePath, + type OracleFusionResourceAddress, +} from '@/lib/internal/oracle-fusion/paths' const OPAQUE_KEY_MAX_LENGTH = 2048 const UNSAFE_OPAQUE_KEY = /[\\/?#\u0000-\u001f\u007f]/ @@ -10,7 +14,12 @@ export interface OracleFusionCollection { limit: number offset: number totalResults?: number - nextOffset?: number + nextOffset: number +} + +export interface OracleFusionCollectionOptions { + expectedOffset?: number + maxItems?: number } function asObject(value: unknown, label: string): Record { @@ -30,10 +39,10 @@ function nonNegativeInteger(value: unknown, label: string): number { /** Validates and projects an Oracle collection envelope with pagination invariants. */ export function parseOracleFusionCollection( value: unknown, - parseItem: (item: unknown, index: number) => T + parseItem: (item: unknown, index: number) => T, + options: OracleFusionCollectionOptions = {} ): OracleFusionCollection { const envelope = asObject(value, 'Oracle collection') - if (!Array.isArray(envelope.items)) throw new Error('Oracle collection items must be an array') const count = nonNegativeInteger(envelope.count, 'Oracle collection count') const limit = nonNegativeInteger(envelope.limit, 'Oracle collection limit') const offset = nonNegativeInteger(envelope.offset, 'Oracle collection offset') @@ -41,7 +50,10 @@ export function parseOracleFusionCollection( if (typeof envelope.hasMore !== 'boolean') { throw new Error('Oracle collection hasMore must be a boolean') } - if (count !== envelope.items.length) { + const items = + envelope.items === undefined && count === 0 && !envelope.hasMore ? [] : envelope.items + if (!Array.isArray(items)) throw new Error('Oracle collection items must be an array') + if (count !== items.length) { throw new Error('Oracle collection count must match the item count') } if (envelope.hasMore && count === 0) { @@ -59,15 +71,30 @@ export function parseOracleFusionCollection( if (totalResults !== undefined && totalResults < pageEnd) { throw new Error('Oracle collection totalResults is smaller than the returned page') } + if (options.expectedOffset !== undefined) { + const expectedOffset = nonNegativeInteger( + options.expectedOffset, + 'Oracle collection expected offset' + ) + if (offset !== expectedOffset) { + throw new Error('Oracle collection offset does not match the requested offset') + } + } + if (options.maxItems !== undefined) { + const maxItems = nonNegativeInteger(options.maxItems, 'Oracle collection item limit') + if (items.length > maxItems) { + throw new Error('Oracle collection exceeds the requested item limit') + } + } return { - items: envelope.items.map(parseItem), + items: items.map(parseItem), count, hasMore: envelope.hasMore, limit, offset, ...(totalResults !== undefined ? { totalResults } : {}), - ...(envelope.hasMore ? { nextOffset: pageEnd } : {}), + nextOffset: pageEnd, } } @@ -110,11 +137,11 @@ function validateSelfLinkBase(link: URL, instanceUrl: string): void { export function validateOracleFusionSelfLink( value: unknown, instanceUrl: string, - expectedPath: string + address: OracleFusionResourceAddress ): void { const link = getOnlySelfLink(value) validateSelfLinkBase(link, instanceUrl) - if (!expectedPath.startsWith('/') || link.pathname !== expectedPath) { + if (link.pathname !== buildOracleFusionResourcePath(address)) { throw new Error('Oracle response self link does not match the requested resource path') } } @@ -122,6 +149,7 @@ export function validateOracleFusionSelfLink( function validateOpaqueKey(key: string): string { if ( !key || + !key.trim() || key.length > OPAQUE_KEY_MAX_LENGTH || key === '.' || key === '..' || @@ -129,6 +157,18 @@ function validateOpaqueKey(key: string): string { ) { throw new Error('Oracle resource key is not a safe opaque path segment') } + for (let index = 0; index < key.length; index++) { + const codeUnit = key.charCodeAt(index) + if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) { + const next = key.charCodeAt(index + 1) + if (!(next >= 0xdc00 && next <= 0xdfff)) { + throw new Error('Oracle resource key contains malformed Unicode') + } + index++ + } else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) { + throw new Error('Oracle resource key contains malformed Unicode') + } + } return key } @@ -136,13 +176,11 @@ function validateOpaqueKey(key: string): string { export function extractOracleFusionOpaqueKey( value: unknown, instanceUrl: string, - collectionPath: string + collectionAddress: OracleFusionResourceAddress ): string { const link = getOnlySelfLink(value) validateSelfLinkBase(link, instanceUrl) - if (!collectionPath.startsWith('/')) { - throw new Error('Oracle collection path must be absolute') - } + const collectionPath = buildOracleFusionResourcePath(collectionAddress) const prefix = `${collectionPath}/` if (!link.pathname.startsWith(prefix)) { throw new Error('Oracle self link does not match the requested collection path') @@ -161,5 +199,12 @@ export function extractOracleFusionOpaqueKey( /** Encodes a validated opaque Oracle resource key for one URL path segment. */ export function encodeOracleFusionPathSegment(key: string): string { - return encodeURIComponent(validateOpaqueKey(key)) + try { + return encodeURIComponent(validateOpaqueKey(key)) + } catch (error) { + if (error instanceof URIError) { + throw new Error('Oracle resource key contains malformed Unicode') + } + throw error + } } From c8c83c88879be3b3e84f13b6e04c1563f87c00df Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Thu, 3 Sep 2026 18:05:28 -0700 Subject: [PATCH 07/15] feat(oracle-fusion): add bounded JSON mutations --- .../lib/internal/oracle-fusion/client.test.ts | 334 ++++++++++++---- apps/sim/lib/internal/oracle-fusion/client.ts | 357 ++++++++++++++---- .../oracle-fusion/request-body.test.ts | 86 +++++ .../internal/oracle-fusion/request-body.ts | 206 ++++++++++ 4 files changed, 830 insertions(+), 153 deletions(-) create mode 100644 apps/sim/lib/internal/oracle-fusion/request-body.test.ts create mode 100644 apps/sim/lib/internal/oracle-fusion/request-body.ts diff --git a/apps/sim/lib/internal/oracle-fusion/client.test.ts b/apps/sim/lib/internal/oracle-fusion/client.test.ts index f51e3150bb7..400033a80a4 100644 --- a/apps/sim/lib/internal/oracle-fusion/client.test.ts +++ b/apps/sim/lib/internal/oracle-fusion/client.test.ts @@ -15,9 +15,12 @@ vi.mock('@/lib/core/security/input-validation.server', () => ({ })) vi.mock('@sim/utils/helpers', () => ({ interruptibleSleep: mockSleep })) +import { createTimeoutAbortController } from '@/lib/core/execution-limits' import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { + type OracleFusionRequest, type OracleFusionResolvedCredential, + requestOracleFusionEmpty, requestOracleFusionJson, } from '@/lib/internal/oracle-fusion/client' import { OracleFusionProviderError } from '@/lib/internal/oracle-fusion/errors' @@ -29,20 +32,29 @@ const CREDENTIAL: OracleFusionResolvedCredential = { accessToken: BASIC, } -function response(status: number, body: string, headers: Record = {}) { +function response( + status: number, + body: string, + headers: Record = {}, + stream: ReadableStream | null = null +) { return { ok: status >= 200 && status < 300, status, statusText: '', headers: { get: (name: string) => headers[name.toLowerCase()] ?? null }, - body: null, + body: stream, text: vi.fn(async () => body), json: vi.fn(async () => JSON.parse(body)), arrayBuffer: vi.fn(async () => new TextEncoder().encode(body).buffer), } } -describe('requestOracleFusionJson', () => { +function getRequest(): OracleFusionRequest { + return { address: { family: 'hcm', relativePath: 'workers' } } +} + +describe('Oracle Fusion client', () => { beforeEach(() => { vi.clearAllMocks() mockValidateUrl.mockResolvedValue({ @@ -54,7 +66,10 @@ describe('requestOracleFusionJson', () => { mockSecureFetch.mockResolvedValue(response(200, '{"items":[]}')) }) - afterEach(() => vi.restoreAllMocks()) + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + }) it.each([ ['hcm', '/hcmRestApi/resources/11.13.18.05/workers'], @@ -66,7 +81,12 @@ describe('requestOracleFusionJson', () => { await expect( requestOracleFusionJson(CREDENTIAL, { address: { family, relativePath: path.split('/').at(-1)! }, - query: { q: 'Name="A B"', limit: 25, expand: undefined, onlyData: true }, + query: { + q: 'Name="A B"', + limit: 25, + expand: undefined, + onlyData: true, + }, }) ).resolves.toEqual({ items: [] }) @@ -97,9 +117,74 @@ describe('requestOracleFusionJson', () => { 'REST-Framework-Version': '9', }, }) + expect(init.signal).toBeInstanceOf(AbortSignal) + } + ) + + it.each([ + ['POST', 'application/json'], + ['PATCH', 'application/vnd.oracle.adf.resourceitem+json'], + ['PUT', 'application/vnd.oracle.adf.action+json'], + ] as const)( + 'sends a bounded %s JSON request with a closed media type', + async (method, mediaType) => { + await expect( + requestOracleFusionJson(CREDENTIAL, { + address: { family: 'fscm', relativePath: 'invoices/42' }, + method, + mediaType, + body: { amount: 12, approved: true }, + operationHeaders: { + effectiveOf: 'RangeMode=UPDATE', + ifMatch: 'etag-value', + upsertMode: false, + }, + }) + ).resolves.toEqual({ items: [] }) + + const init = mockSecureFetch.mock.calls[0][2] + expect(init).toMatchObject({ + method, + body: '{"amount":12,"approved":true}', + headers: { + Accept: 'application/json', + Authorization: `Basic ${BASIC}`, + 'Content-Type': mediaType, + 'Effective-Of': 'RangeMode=UPDATE', + 'If-Match': 'etag-value', + 'REST-Framework-Version': '9', + 'Upsert-Mode': 'false', + }, + }) } ) + it('sends DELETE without a body and consumes an empty-mode success without JSON parsing', async () => { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('ignored')) + controller.close() + }, + }) + const success = response(204, 'not-json', {}, stream) + mockSecureFetch.mockResolvedValueOnce(success) + + await expect( + requestOracleFusionEmpty(CREDENTIAL, { + address: { family: 'crm', relativePath: 'opportunities/42' }, + method: 'DELETE', + operationHeaders: { ifMatch: '*' }, + }) + ).resolves.toBeUndefined() + + expect(mockSecureFetch.mock.calls[0][2]).toMatchObject({ + method: 'DELETE', + headers: { 'If-Match': '*' }, + }) + expect(mockSecureFetch.mock.calls[0][2]).not.toHaveProperty('body') + expect(success.text).not.toHaveBeenCalled() + }) + it.each([ '', '/workers', @@ -132,39 +217,156 @@ describe('requestOracleFusionJson', () => { expect(new URL(mockSecureFetch.mock.calls[0][0]).pathname).toMatch(/\/workers\/key%252Fpart$/) }) + it('rejects unsupported methods, body modes, media types, and operation headers locally', async () => { + const invalidRequests: OracleFusionRequest[] = [ + { + ...getRequest(), + method: 'OPTIONS', + } as unknown as OracleFusionRequest, + { + ...getRequest(), + method: 'GET', + body: {}, + } as unknown as OracleFusionRequest, + { + ...getRequest(), + method: 'POST', + mediaType: 'application/json', + } as unknown as OracleFusionRequest, + { + ...getRequest(), + method: 'POST', + mediaType: 'text/plain', + body: {}, + } as unknown as OracleFusionRequest, + { + ...getRequest(), + operationHeaders: { authorization: 'secret' }, + } as unknown as OracleFusionRequest, + ] + + for (const request of invalidRequests) { + await expect(requestOracleFusionJson(CREDENTIAL, request)).rejects.toThrow(/Oracle Fusion/) + } + expect(mockValidateUrl).not.toHaveBeenCalled() + expect(mockSecureFetch).not.toHaveBeenCalled() + }) + + it.each(['', ' ', ' untrimmed', 'line\nbreak', 'x'.repeat(2_049)])( + 'rejects the unsafe operation header value %j locally', + async (value) => { + await expect( + requestOracleFusionJson(CREDENTIAL, { + ...getRequest(), + operationHeaders: { ifMatch: value }, + }) + ).rejects.toThrow('header value is invalid') + expect(mockSecureFetch).not.toHaveBeenCalled() + } + ) + it('rejects a non-public DNS result before fetching', async () => { - mockValidateUrl.mockResolvedValueOnce({ isValid: false, error: 'private address' }) - await expect( - requestOracleFusionJson(CREDENTIAL, { - address: { family: 'hcm', relativePath: 'workers' }, - }) - ).rejects.toThrow('not a public endpoint') + mockValidateUrl.mockResolvedValueOnce({ + isValid: false, + error: 'private address', + }) + await expect(requestOracleFusionJson(CREDENTIAL, getRequest())).rejects.toThrow( + 'not a public endpoint' + ) expect(mockSecureFetch).not.toHaveBeenCalled() }) - it('retries 429, 503, and 504 at most twice and honors bounded Retry-After', async () => { + it.each([429, 503, 504])( + 'retries GET once for HTTP %s and caps Retry-After at five seconds', + async (status) => { + mockSecureFetch + .mockResolvedValueOnce(response(status, 'secret provider body', { 'retry-after': '90' })) + .mockResolvedValueOnce(response(200, '{"ok":true}')) + + await expect(requestOracleFusionJson(CREDENTIAL, getRequest())).resolves.toEqual({ ok: true }) + expect(mockSecureFetch).toHaveBeenCalledTimes(2) + expect(mockSleep).toHaveBeenCalledTimes(1) + expect(mockSleep).toHaveBeenCalledWith(5_000, undefined) + } + ) + + it('stops after one GET retry', async () => { mockSecureFetch - .mockResolvedValueOnce(response(429, 'secret provider body', { 'retry-after': '90' })) - .mockResolvedValueOnce(response(503, 'secret provider body', { 'retry-after': '1' })) - .mockResolvedValueOnce(response(504, 'secret provider body')) + .mockResolvedValueOnce(response(429, 'secret provider body')) + .mockResolvedValueOnce(response(503, 'secret provider body')) + await expect(requestOracleFusionJson(CREDENTIAL, getRequest())).rejects.toMatchObject({ + status: 503, + }) + expect(mockSecureFetch).toHaveBeenCalledTimes(2) + expect(mockSleep).toHaveBeenCalledTimes(1) + }) + + it('does not retry when the caller deadline cannot fit the delay, attempt, and reserve', async () => { + const execution = createTimeoutAbortController(39_000) + mockSecureFetch.mockResolvedValueOnce( + response(429, 'secret provider body', { 'retry-after': '5' }) + ) + try { + await expect( + requestOracleFusionJson(CREDENTIAL, getRequest(), execution.signal) + ).rejects.toMatchObject({ status: 429 }) + } finally { + execution.cleanup() + } + expect(mockSecureFetch).toHaveBeenCalledTimes(1) + expect(mockSleep).not.toHaveBeenCalled() + }) + + it.each(['POST', 'PATCH', 'PUT'] as const)('never retries %s mutations', async (method) => { + mockSecureFetch.mockResolvedValueOnce(response(503, 'secret provider body')) await expect( requestOracleFusionJson(CREDENTIAL, { - address: { family: 'fscm', relativePath: 'invoices' }, + ...getRequest(), + method, + mediaType: 'application/json', + body: {}, + }) + ).rejects.toMatchObject({ status: 503 }) + expect(mockSecureFetch).toHaveBeenCalledTimes(1) + expect(mockSleep).not.toHaveBeenCalled() + }) + + it('never retries DELETE', async () => { + mockSecureFetch.mockResolvedValueOnce(response(504, 'secret provider body')) + await expect( + requestOracleFusionEmpty(CREDENTIAL, { + ...getRequest(), + method: 'DELETE', }) ).rejects.toMatchObject({ status: 504 }) - expect(mockSecureFetch).toHaveBeenCalledTimes(3) - expect(mockSleep).toHaveBeenNthCalledWith(1, 30_000, undefined) - expect(mockSleep).toHaveBeenNthCalledWith(2, 1_000, undefined) + expect(mockSecureFetch).toHaveBeenCalledTimes(1) + expect(mockSleep).not.toHaveBeenCalled() + }) + + it('enforces the attempt deadline through response body consumption', async () => { + vi.useFakeTimers() + const success = response(200, '') + success.text.mockImplementationOnce(() => new Promise(() => {})) + mockSecureFetch.mockResolvedValueOnce(success) + + const pending = expect(requestOracleFusionJson(CREDENTIAL, getRequest())).rejects.toMatchObject( + { + message: 'Oracle Fusion request timed out', + status: 504, + } + ) + await vi.advanceTimersByTimeAsync(30_000) + await pending }) it('rejects redirects without exposing their location or body', async () => { mockSecureFetch.mockResolvedValueOnce( response(302, `redirect ${BASIC}`, { location: 'https://evil.example' }) ) - const error = await requestOracleFusionJson(CREDENTIAL, { - address: { family: 'hcm', relativePath: 'workers' }, - }).catch((caught: unknown) => caught) + const error = await requestOracleFusionJson(CREDENTIAL, getRequest()).catch( + (caught: unknown) => caught + ) expect(error).toMatchObject({ status: 302 }) expect(String(error)).not.toContain('evil.example') expect(String(error)).not.toContain(BASIC) @@ -172,10 +374,13 @@ describe('requestOracleFusionJson', () => { it('classifies redirects rejected by the pinned transport without exposing details', async () => { mockSecureFetch.mockRejectedValueOnce(new Error('Too many redirects (max: 0)')) - const error = await requestOracleFusionJson(CREDENTIAL, { - address: { family: 'hcm', relativePath: 'workers' }, - }).catch((caught: unknown) => caught) - expect(error).toMatchObject({ message: 'Oracle Fusion returned a redirect', status: 502 }) + const error = await requestOracleFusionJson(CREDENTIAL, getRequest()).catch( + (caught: unknown) => caught + ) + expect(error).toMatchObject({ + message: 'Oracle Fusion returned a redirect', + status: 502, + }) expect(String(error)).not.toContain(ORIGIN) expect(String(error)).not.toContain(BASIC) }) @@ -187,11 +392,7 @@ describe('requestOracleFusionJson', () => { '{"id":9007199254740993,"negative":-9007199254740993,"zeroFraction":9007199254740993.0,"exponent":9.007199254740993e15,"hugeExponent":1e999,"safe":9007199254740991,"decimal":9007199254740993.5}' ) ) - await expect( - requestOracleFusionJson(CREDENTIAL, { - address: { family: 'hcm', relativePath: 'workers' }, - }) - ).resolves.toEqual({ + await expect(requestOracleFusionJson(CREDENTIAL, getRequest())).resolves.toEqual({ id: '9007199254740993', negative: '-9007199254740993', zeroFraction: '9007199254740993.0', @@ -202,79 +403,64 @@ describe('requestOracleFusionJson', () => { }) }) - it('recognizes a large negative exponent absorbed by coefficient trailing zeroes', async () => { - const token = `9007199254740993${'0'.repeat(1_000_000)}e-1000000` - mockSecureFetch.mockResolvedValueOnce(response(200, `{"id":${token}}`)) - await expect( - requestOracleFusionJson(CREDENTIAL, { - address: { family: 'hcm', relativePath: 'workers' }, - }) - ).resolves.toEqual({ id: token }) - }) - it('returns fixed provider errors without credential or body reflection', async () => { const password = 'provider-reflected-password' const accessToken = Buffer.from(`integration-user:${password}`).toString('base64') mockSecureFetch.mockResolvedValueOnce( response(401, `integration-user ${password} ${accessToken}`) ) - const error = await requestOracleFusionJson( - { ...CREDENTIAL, accessToken }, - { address: { family: 'hcm', relativePath: 'workers' } } - ).catch((caught: unknown) => caught) + const error = await requestOracleFusionJson({ ...CREDENTIAL, accessToken }, getRequest()).catch( + (caught: unknown) => caught + ) expect(error).toBeInstanceOf(OracleFusionProviderError) - expect(error).toMatchObject({ message: 'Oracle Fusion authentication failed', status: 401 }) + expect(error).toMatchObject({ + message: 'Oracle Fusion authentication failed', + status: 401, + }) expect(String(error)).not.toContain('integration-user') expect(String(error)).not.toContain(password) expect(String(error)).not.toContain(accessToken) }) - it('classifies timeout, response-limit, and malformed JSON failures', async () => { + it('classifies transport timeout, response-limit, and malformed JSON failures', async () => { mockSecureFetch.mockRejectedValueOnce(new Error('Request timed out after 30000ms')) - await expect( - requestOracleFusionJson(CREDENTIAL, { - address: { family: 'hcm', relativePath: 'workers' }, - }) - ).rejects.toMatchObject({ message: 'Oracle Fusion request timed out', status: 504 }) + await expect(requestOracleFusionJson(CREDENTIAL, getRequest())).rejects.toMatchObject({ + message: 'Oracle Fusion request timed out', + status: 504, + }) mockSecureFetch.mockRejectedValueOnce( - new PayloadSizeLimitError({ label: 'response', maxBytes: 5 * 1024 * 1024 }) - ) - await expect( - requestOracleFusionJson(CREDENTIAL, { - address: { family: 'hcm', relativePath: 'workers' }, + new PayloadSizeLimitError({ + label: 'response', + maxBytes: 5 * 1024 * 1024, }) - ).rejects.toMatchObject({ message: 'Oracle Fusion response exceeded 5 MiB', status: 502 }) + ) + await expect(requestOracleFusionJson(CREDENTIAL, getRequest())).rejects.toMatchObject({ + message: 'Oracle Fusion response exceeded 5 MiB', + status: 502, + }) mockSecureFetch.mockResolvedValueOnce(response(200, 'not-json')) - await expect( - requestOracleFusionJson(CREDENTIAL, { - address: { family: 'hcm', relativePath: 'workers' }, - }) - ).rejects.toMatchObject({ message: 'Oracle Fusion returned malformed JSON', status: 502 }) + await expect(requestOracleFusionJson(CREDENTIAL, getRequest())).rejects.toMatchObject({ + message: 'Oracle Fusion returned malformed JSON', + status: 502, + }) }) it('preserves caller aborts and never starts the request', async () => { const controller = new AbortController() const reason = new DOMException('cancelled', 'AbortError') controller.abort(reason) - await expect( - requestOracleFusionJson( - CREDENTIAL, - { address: { family: 'hcm', relativePath: 'workers' } }, - controller.signal - ) - ).rejects.toBe(reason) + await expect(requestOracleFusionJson(CREDENTIAL, getRequest(), controller.signal)).rejects.toBe( + reason + ) expect(mockValidateUrl).not.toHaveBeenCalled() expect(mockSecureFetch).not.toHaveBeenCalled() }) it('rejects malformed Basic material and non-finite query values locally', async () => { await expect( - requestOracleFusionJson( - { ...CREDENTIAL, accessToken: 'not basic\r\n' }, - { address: { family: 'hcm', relativePath: 'workers' } } - ) + requestOracleFusionJson({ ...CREDENTIAL, accessToken: 'not basic\r\n' }, getRequest()) ).rejects.toThrow('credential is malformed') await expect( requestOracleFusionJson(CREDENTIAL, { diff --git a/apps/sim/lib/internal/oracle-fusion/client.ts b/apps/sim/lib/internal/oracle-fusion/client.ts index 9473d7ccc54..6ea36ec72c3 100644 --- a/apps/sim/lib/internal/oracle-fusion/client.ts +++ b/apps/sim/lib/internal/oracle-fusion/client.ts @@ -1,5 +1,6 @@ import { interruptibleSleep } from '@sim/utils/helpers' import { backoffWithJitter, parseRetryAfter } from '@sim/utils/retry' +import { createTimeoutAbortController, getRemainingExecutionMs } from '@/lib/core/execution-limits' import { type SecureFetchResponse, secureFetchWithPinnedIP, @@ -13,11 +14,23 @@ import { buildOracleFusionResourcePath, type OracleFusionResourceAddress, } from '@/lib/internal/oracle-fusion/paths' +import { serializeOracleFusionJsonBody } from '@/lib/internal/oracle-fusion/request-body' const REQUEST_TIMEOUT_MS = 30_000 +const RETRY_AFTER_MAX_MS = 5_000 +const RETRY_RESERVE_MS = 5_000 const RESPONSE_MAX_BYTES = 5 * 1024 * 1024 -const MAX_RETRIES = 2 +const MAX_GET_RETRIES = 1 const TRANSIENT_STATUSES = new Set([429, 503, 504]) +const METHODS = new Set(['GET', 'POST', 'PATCH', 'PUT', 'DELETE']) +const MEDIA_TYPES = new Set([ + 'application/json', + 'application/vnd.oracle.adf.resourceitem+json', + 'application/vnd.oracle.adf.action+json', +]) +const OPERATION_HEADER_KEYS = new Set(['effectiveOf', 'ifMatch', 'upsertMode']) +const HEADER_VALUE_MAX_LENGTH = 2_048 +const HEADER_CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/ const CANONICAL_BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/ interface JsonParseContext { @@ -36,9 +49,40 @@ export interface OracleFusionResolvedCredential { accessToken: string } -export interface OracleFusionRequest { +export type OracleFusionMethod = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE' + +export type OracleFusionMediaType = + | 'application/json' + | 'application/vnd.oracle.adf.resourceitem+json' + | 'application/vnd.oracle.adf.action+json' + +export interface OracleFusionOperationHeaders { + effectiveOf?: string + ifMatch?: string + upsertMode?: boolean +} + +interface OracleFusionRequestBase { address: OracleFusionResourceAddress query?: Record + operationHeaders?: OracleFusionOperationHeaders +} + +export type OracleFusionRequest = OracleFusionRequestBase & + ( + | { method?: 'GET'; body?: never; mediaType?: never } + | { method: 'DELETE'; body?: never; mediaType?: never } + | { + method: 'POST' | 'PATCH' | 'PUT' + body: unknown + mediaType: OracleFusionMediaType + } + ) + +interface PreparedRequest { + method: OracleFusionMethod + headers: Record + body?: string } function validateBasicCredential(accessToken: string): void { @@ -76,39 +120,145 @@ function parseOracleFusionJson(body: string): unknown { }) } -async function waitForRetry( - attempt: number, - signal?: AbortSignal, - retryAfterMs: number | null = null -): Promise { - const delay = backoffWithJitter(attempt + 1, retryAfterMs, { +function validateHeaderValue(value: unknown): string { + if (typeof value !== 'string') { + throw new Error('Oracle Fusion operation header values must be strings') + } + const normalized = value.trim() + if ( + !normalized || + normalized !== value || + normalized.length > HEADER_VALUE_MAX_LENGTH || + HEADER_CONTROL_CHARACTERS.test(normalized) + ) { + throw new Error('Oracle Fusion operation header value is invalid') + } + return normalized +} + +function appendOperationHeaders( + target: Record, + operationHeaders: OracleFusionOperationHeaders | undefined +): void { + if (operationHeaders === undefined) return + if ( + operationHeaders === null || + typeof operationHeaders !== 'object' || + Array.isArray(operationHeaders) || + (Object.getPrototypeOf(operationHeaders) !== Object.prototype && + Object.getPrototypeOf(operationHeaders) !== null) + ) { + throw new Error('Oracle Fusion operation headers must be a plain object') + } + for (const key of Reflect.ownKeys(operationHeaders)) { + if (typeof key !== 'string' || !OPERATION_HEADER_KEYS.has(key)) { + throw new Error('Oracle Fusion operation header is not supported') + } + const descriptor = Object.getOwnPropertyDescriptor(operationHeaders, key) + if (!descriptor || descriptor.get || descriptor.set) { + throw new Error('Oracle Fusion operation header is not supported') + } + } + if (operationHeaders.effectiveOf !== undefined) { + target['Effective-Of'] = validateHeaderValue(operationHeaders.effectiveOf) + } + if (operationHeaders.ifMatch !== undefined) { + target['If-Match'] = validateHeaderValue(operationHeaders.ifMatch) + } + if (operationHeaders.upsertMode !== undefined) { + if (typeof operationHeaders.upsertMode !== 'boolean') { + throw new Error('Oracle Fusion Upsert-Mode must be boolean') + } + target['Upsert-Mode'] = String(operationHeaders.upsertMode) + } +} + +function prepareRequest(accessToken: string, request: OracleFusionRequest): PreparedRequest { + const method = request.method ?? 'GET' + if (!METHODS.has(method)) throw new Error('Oracle Fusion request method is not supported') + + const headers: Record = { + Accept: 'application/json', + Authorization: `Basic ${accessToken}`, + 'REST-Framework-Version': '9', + } + appendOperationHeaders(headers, request.operationHeaders) + + const hasBody = 'body' in request && request.body !== undefined + if (method === 'GET' || method === 'DELETE') { + if (hasBody || 'mediaType' in request) { + throw new Error(`Oracle Fusion ${method} requests must not include a body`) + } + return { method, headers } + } + + if (!hasBody) throw new Error(`Oracle Fusion ${method} requests require a JSON body`) + if (!MEDIA_TYPES.has(request.mediaType)) { + throw new Error('Oracle Fusion request media type is not supported') + } + headers['Content-Type'] = request.mediaType + return { method, headers, body: serializeOracleFusionJsonBody(request.body) } +} + +function retryDelay(attempt: number, retryAfterMs: number | null): number { + return backoffWithJitter(attempt + 1, retryAfterMs, { baseMs: 250, - maxMs: 30_000, + maxMs: RETRY_AFTER_MAX_MS, }) +} + +async function waitForRetry(delay: number, signal?: AbortSignal): Promise { await interruptibleSleep(delay, signal) signal?.throwIfAborted() } +function hasTimeForRetry(delay: number, signal?: AbortSignal): boolean { + const remaining = getRemainingExecutionMs(signal) + return remaining === undefined || remaining >= delay + REQUEST_TIMEOUT_MS + RETRY_RESERVE_MS +} + +async function waitWithSignal(promise: Promise, signal: AbortSignal): Promise { + signal.throwIfAborted() + return new Promise((resolve, reject) => { + const cleanup = () => signal.removeEventListener('abort', onAbort) + const onAbort = () => { + cleanup() + reject(signal.reason ?? new DOMException('user', 'AbortError')) + } + signal.addEventListener('abort', onAbort, { once: true }) + promise.then( + (value) => { + cleanup() + resolve(value) + }, + (error: unknown) => { + cleanup() + reject(error) + } + ) + }) +} + async function fetchAttempt( url: string, resolvedIP: string, - accessToken: string, - signal?: AbortSignal + prepared: PreparedRequest, + signal: AbortSignal ): Promise { - return secureFetchWithPinnedIP(url, resolvedIP, { - profile: 'configuredEndpoint', - method: 'GET', - headers: { - Accept: 'application/json', - Authorization: `Basic ${accessToken}`, - 'REST-Framework-Version': '9', - }, - timeout: REQUEST_TIMEOUT_MS, - maxRedirects: 0, - maxResponseBytes: RESPONSE_MAX_BYTES, - signal, - logUrlValidationDetails: false, - }) + return waitWithSignal( + secureFetchWithPinnedIP(url, resolvedIP, { + profile: 'configuredEndpoint', + method: prepared.method, + headers: prepared.headers, + ...(prepared.body === undefined ? {} : { body: prepared.body }), + timeout: REQUEST_TIMEOUT_MS, + maxRedirects: 0, + maxResponseBytes: RESPONSE_MAX_BYTES, + signal, + logUrlValidationDetails: false, + }), + signal + ) } function statusMessage(status: number): string { @@ -124,20 +274,49 @@ function isRejectedRedirect(error: unknown): boolean { return error instanceof Error && error.message === 'Too many redirects (max: 0)' } -/** Executes one bounded, DNS-pinned GET against a fixed Oracle product API family. */ -export async function requestOracleFusionJson( - credential: OracleFusionResolvedCredential, - request: OracleFusionRequest, - signal?: AbortSignal +function mapAttemptError(error: unknown, timedOut: boolean, callerSignal?: AbortSignal): never { + callerSignal?.throwIfAborted() + if (error instanceof OracleFusionProviderError) throw error + if (timedOut) { + throw new OracleFusionProviderError('Oracle Fusion request timed out', 504) + } + if (isRejectedRedirect(error)) { + throw new OracleFusionProviderError('Oracle Fusion returned a redirect', 502) + } + if (isPayloadSizeLimitError(error)) { + throw new OracleFusionProviderError('Oracle Fusion response exceeded 5 MiB', 502) + } + if (error instanceof Error && error.message.includes('timed out')) { + throw new OracleFusionProviderError('Oracle Fusion request timed out', 504) + } + throw new OracleFusionProviderError('Could not reach Oracle Fusion', 502) +} + +async function readJsonResponse( + response: SecureFetchResponse, + signal: AbortSignal ): Promise { - signal?.throwIfAborted() - const origin = normalizeOracleFusionApplicationOrigin(credential.instanceUrl) - if (!origin) { - throw new Error('Oracle Fusion credential is not bound to a canonical application URL') + let body: string + try { + body = await waitWithSignal(response.text(), signal) + } catch (error) { + if (isPayloadSizeLimitError(error)) { + throw new OracleFusionProviderError('Oracle Fusion response exceeded 5 MiB', 502) + } + throw error } - validateBasicCredential(credential.accessToken) - const url = buildRequestUrl(origin, request) + try { + return parseOracleFusionJson(body) + } catch { + throw new OracleFusionProviderError('Oracle Fusion returned malformed JSON', 502) + } +} + +async function consumeResponse(response: SecureFetchResponse, signal: AbortSignal): Promise { + await waitWithSignal(consumeOrCancelBody(response), signal) +} +async function validateCredentialOrigin(origin: string, signal?: AbortSignal): Promise { let validation: Awaited> try { validation = await validateUrlWithDNS(origin, 'Fusion Applications URL', 'configuredEndpoint', { @@ -151,56 +330,76 @@ export async function requestOracleFusionJson( if (!validation.isValid) { throw new Error('Oracle Fusion credential application URL is not a public endpoint') } + return validation.resolvedIP +} + +async function requestOracleFusion( + credential: OracleFusionResolvedCredential, + request: OracleFusionRequest, + responseMode: 'json' | 'empty', + signal?: AbortSignal +): Promise { + signal?.throwIfAborted() + const origin = normalizeOracleFusionApplicationOrigin(credential.instanceUrl) + if (!origin) { + throw new Error('Oracle Fusion credential is not bound to a canonical application URL') + } + validateBasicCredential(credential.accessToken) + const prepared = prepareRequest(credential.accessToken, request) + const url = buildRequestUrl(origin, request) + const resolvedIP = await validateCredentialOrigin(origin, signal) + const maxRetries = prepared.method === 'GET' ? MAX_GET_RETRIES : 0 - for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + for (let attempt = 0; attempt <= maxRetries; attempt += 1) { signal?.throwIfAborted() - let response: SecureFetchResponse + const deadline = createTimeoutAbortController(REQUEST_TIMEOUT_MS, signal) + let delay: number | undefined try { - response = await fetchAttempt(url, validation.resolvedIP, credential.accessToken, signal) - } catch (error) { - signal?.throwIfAborted() - if (isRejectedRedirect(error)) { - throw new OracleFusionProviderError('Oracle Fusion returned a redirect', 502) - } - if (isPayloadSizeLimitError(error)) { - throw new OracleFusionProviderError('Oracle Fusion response exceeded 5 MiB', 502) + const response = await fetchAttempt(url, resolvedIP, prepared, deadline.signal) + if (TRANSIENT_STATUSES.has(response.status) && attempt < maxRetries) { + const retryAfterMs = parseRetryAfter( + response.headers.get('retry-after'), + RETRY_AFTER_MAX_MS + ) + await consumeResponse(response, deadline.signal) + const candidateDelay = retryDelay(attempt, retryAfterMs) + if (hasTimeForRetry(candidateDelay, signal)) delay = candidateDelay + else throw new OracleFusionProviderError(statusMessage(response.status), response.status) + } else if (!response.ok) { + await consumeResponse(response, deadline.signal) + throw new OracleFusionProviderError(statusMessage(response.status), response.status) + } else if (responseMode === 'json') { + return await readJsonResponse(response, deadline.signal) + } else { + await consumeResponse(response, deadline.signal) + return } - if (error instanceof Error && error.message.includes('timed out')) { - throw new OracleFusionProviderError('Oracle Fusion request timed out', 504) - } - throw new OracleFusionProviderError('Could not reach Oracle Fusion', 502) - } - - if (TRANSIENT_STATUSES.has(response.status) && attempt < MAX_RETRIES) { - const retryAfterMs = parseRetryAfter(response.headers.get('retry-after'), 30_000) - await consumeOrCancelBody(response) - await waitForRetry(attempt, signal, retryAfterMs) - continue - } - - if (!response.ok) { - await consumeOrCancelBody(response) - signal?.throwIfAborted() - throw new OracleFusionProviderError(statusMessage(response.status), response.status) - } - - let body: string - try { - body = await response.text() } catch (error) { - signal?.throwIfAborted() - if (isPayloadSizeLimitError(error)) { - throw new OracleFusionProviderError('Oracle Fusion response exceeded 5 MiB', 502) - } - throw new OracleFusionProviderError('Oracle Fusion response could not be read', 502) - } - signal?.throwIfAborted() - try { - return parseOracleFusionJson(body) - } catch { - throw new OracleFusionProviderError('Oracle Fusion returned malformed JSON', 502) + mapAttemptError(error, deadline.isTimedOut(), signal) + } finally { + deadline.cleanup() } + + if (delay !== undefined) await waitForRetry(delay, signal) } throw new OracleFusionProviderError('Oracle Fusion retry limit was exhausted', 502) } + +/** Executes a bounded request and parses a required JSON success response losslessly. */ +export async function requestOracleFusionJson( + credential: OracleFusionResolvedCredential, + request: OracleFusionRequest, + signal?: AbortSignal +): Promise { + return requestOracleFusion(credential, request, 'json', signal) +} + +/** Executes a bounded request and consumes or cancels its success response body. */ +export async function requestOracleFusionEmpty( + credential: OracleFusionResolvedCredential, + request: OracleFusionRequest, + signal?: AbortSignal +): Promise { + await requestOracleFusion(credential, request, 'empty', signal) +} diff --git a/apps/sim/lib/internal/oracle-fusion/request-body.test.ts b/apps/sim/lib/internal/oracle-fusion/request-body.test.ts new file mode 100644 index 00000000000..25ff76ddbb6 --- /dev/null +++ b/apps/sim/lib/internal/oracle-fusion/request-body.test.ts @@ -0,0 +1,86 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { MAX_INLINE_MATERIALIZATION_BYTES } from '@/lib/execution/payloads/limits' +import { serializeOracleFusionJsonBody } from '@/lib/internal/oracle-fusion/request-body' + +describe('serializeOracleFusionJsonBody', () => { + it('serializes plain objects, arrays, null-prototype objects, and JSON scalars', () => { + const nullPrototype = Object.create(null) as Record + nullPrototype.value = 'ok' + + expect( + serializeOracleFusionJsonBody({ + string: 'value', + number: 12.5, + boolean: false, + nil: null, + array: [1, 'two'], + nullPrototype, + }) + ).toBe( + '{"string":"value","number":12.5,"boolean":false,"nil":null,"array":[1,"two"],"nullPrototype":{"value":"ok"}}' + ) + }) + + it.each([ + undefined, + () => undefined, + Symbol('value'), + 1n, + Number.NaN, + Number.POSITIVE_INFINITY, + new Date(), + ])('rejects unsupported root values %#', (value) => { + expect(() => serializeOracleFusionJsonBody(value)).toThrow('plain JSON data') + }) + + it('rejects unsupported nested values instead of applying JSON omission rules', () => { + expect(() => serializeOracleFusionJsonBody({ missing: undefined })).toThrow('plain JSON data') + expect(() => serializeOracleFusionJsonBody([undefined])).toThrow('plain JSON data') + expect(() => serializeOracleFusionJsonBody(new Array(1))).toThrow('plain JSON data') + }) + + it('rejects custom serialization, accessors, symbols, and array properties', () => { + expect(() => serializeOracleFusionJsonBody({ toJSON: () => ({}) })).toThrow('plain JSON data') + + const accessor = Object.defineProperty({}, 'value', { + enumerable: true, + get: () => 'secret', + }) + expect(() => serializeOracleFusionJsonBody(accessor)).toThrow('plain JSON data') + + expect(() => serializeOracleFusionJsonBody({ [Symbol('secret')]: 'value' })).toThrow( + 'plain JSON data' + ) + + const array = [1] + Object.defineProperty(array, 'extra', { value: true, enumerable: true }) + expect(() => serializeOracleFusionJsonBody(array)).toThrow('plain JSON data') + + const customArray = [1] + Object.setPrototypeOf(customArray, null) + expect(() => serializeOracleFusionJsonBody(customArray)).toThrow('plain JSON data') + }) + + it('rejects cycles, excessive nesting, and excessive complexity', () => { + const cycle: unknown[] = [] + cycle.push(cycle) + expect(() => serializeOracleFusionJsonBody(cycle)).toThrow('must not be cyclic') + + let nested: unknown = null + for (let index = 0; index < 101; index += 1) nested = [nested] + expect(() => serializeOracleFusionJsonBody(nested)).toThrow('nesting limit') + + expect(() => serializeOracleFusionJsonBody(new Array(100_001).fill(null))).toThrow( + 'complexity limit' + ) + }) + + it('rejects UTF-8 output beyond the inline materialization limit', () => { + expect(() => + serializeOracleFusionJsonBody('x'.repeat(MAX_INLINE_MATERIALIZATION_BYTES)) + ).toThrow('inline payload limit') + }) +}) diff --git a/apps/sim/lib/internal/oracle-fusion/request-body.ts b/apps/sim/lib/internal/oracle-fusion/request-body.ts new file mode 100644 index 00000000000..d8499c9a407 --- /dev/null +++ b/apps/sim/lib/internal/oracle-fusion/request-body.ts @@ -0,0 +1,206 @@ +import { MAX_INLINE_MATERIALIZATION_BYTES } from '@/lib/execution/payloads/limits' + +const MAX_JSON_NESTING_DEPTH = 100 +const MAX_JSON_NODE_COUNT = 100_000 + +interface JsonBudgetState { + bytes: number + nodes: number + ancestors: WeakSet +} + +type JsonBudgetFrame = + | { kind: 'value'; value: unknown; depth: number } + | { kind: 'array'; value: unknown[]; index: number; depth: number } + | { + kind: 'object' + value: Record + keys: string[] + index: number + depth: number + } + +/** Serializes a bounded request body containing only plain JSON data. */ +export function serializeOracleFusionJsonBody(body: unknown): string { + assertJsonBodyWithinLimit(body) + const serialized = JSON.stringify(body) + if (serialized === undefined) throwNonPlainJsonError() + if (Buffer.byteLength(serialized, 'utf8') > MAX_INLINE_MATERIALIZATION_BYTES) { + throwRequestBodyLimitError() + } + return serialized +} + +function assertJsonBodyWithinLimit(body: unknown): void { + const state: JsonBudgetState = { + bytes: 0, + nodes: 0, + ancestors: new WeakSet(), + } + const frames: JsonBudgetFrame[] = [{ kind: 'value', value: body, depth: 0 }] + + while (frames.length > 0) { + const frame = frames.pop() + if (!frame) break + + if (frame.kind === 'array') { + if (frame.index >= frame.value.length) { + addJsonBytes(state, 1) + state.ancestors.delete(frame.value) + continue + } + if (frame.index > 0) addJsonBytes(state, 1) + const descriptor = Object.getOwnPropertyDescriptor(frame.value, String(frame.index)) + if (!descriptor || descriptor.get || descriptor.set) throwNonPlainJsonError() + frames.push({ ...frame, index: frame.index + 1 }) + frames.push({ + kind: 'value', + value: descriptor.value, + depth: frame.depth + 1, + }) + continue + } + + if (frame.kind === 'object') { + if (frame.index >= frame.keys.length) { + addJsonBytes(state, 1) + state.ancestors.delete(frame.value) + continue + } + const key = frame.keys[frame.index] + const descriptor = Object.getOwnPropertyDescriptor(frame.value, key) + if (!descriptor || descriptor.get || descriptor.set) throwNonPlainJsonError() + if (frame.index > 0) addJsonBytes(state, 1) + addJsonBytes(state, jsonStringByteLength(key) + 1) + frames.push({ ...frame, index: frame.index + 1 }) + frames.push({ + kind: 'value', + value: descriptor.value, + depth: frame.depth + 1, + }) + continue + } + + admitJsonNode(state) + const { value, depth } = frame + if (depth > MAX_JSON_NESTING_DEPTH) { + throw new Error('Oracle Fusion request body exceeds the JSON nesting limit') + } + if (value === null) { + addJsonBytes(state, 4) + } else if (typeof value === 'string') { + addJsonBytes(state, jsonStringByteLength(value)) + } else if (typeof value === 'boolean') { + addJsonBytes(state, value ? 4 : 5) + } else if (typeof value === 'number') { + if (!Number.isFinite(value)) throwNonPlainJsonError() + addJsonBytes(state, JSON.stringify(value).length) + } else if (Array.isArray(value)) { + if (Object.getPrototypeOf(value) !== Array.prototype) throwNonPlainJsonError() + assertContainerIsPlain(value) + if (state.ancestors.has(value)) { + throw new Error('Oracle Fusion request body must not be cyclic') + } + if (value.length * 2 + 1 > MAX_INLINE_MATERIALIZATION_BYTES - state.bytes) { + throwRequestBodyLimitError() + } + state.ancestors.add(value) + addJsonBytes(state, 1) + frames.push({ kind: 'array', value, index: 0, depth }) + } else if (isRecordLike(value)) { + const prototype = Object.getPrototypeOf(value) + if (prototype !== Object.prototype && prototype !== null) throwNonPlainJsonError() + assertContainerIsPlain(value) + if (state.ancestors.has(value)) { + throw new Error('Oracle Fusion request body must not be cyclic') + } + state.ancestors.add(value) + addJsonBytes(state, 1) + frames.push({ + kind: 'object', + value, + keys: Object.keys(value), + index: 0, + depth, + }) + } else { + throwNonPlainJsonError() + } + } +} + +function isRecordLike(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function assertContainerIsPlain(value: object): void { + for (const key of Reflect.ownKeys(value)) { + if (typeof key === 'symbol') throwNonPlainJsonError() + if (key === 'length' && Array.isArray(value)) continue + const descriptor = Object.getOwnPropertyDescriptor(value, key) + if (descriptor?.get || descriptor?.set || key === 'toJSON') throwNonPlainJsonError() + if (Array.isArray(value)) { + const index = Number(key) + if ( + !Number.isSafeInteger(index) || + index < 0 || + String(index) !== key || + index >= value.length + ) { + throwNonPlainJsonError() + } + } + } +} + +function admitJsonNode(state: JsonBudgetState): void { + state.nodes += 1 + if (state.nodes > MAX_JSON_NODE_COUNT) { + throw new Error('Oracle Fusion request body exceeds the JSON complexity limit') + } +} + +function addJsonBytes(state: JsonBudgetState, bytes: number): void { + state.bytes += bytes + if (state.bytes > MAX_INLINE_MATERIALIZATION_BYTES) throwRequestBodyLimitError() +} + +function jsonStringByteLength(value: string): number { + let bytes = 2 + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index) + if (code === 0x22 || code === 0x5c) { + bytes += 2 + } else if (code < 0x20) { + bytes += + code === 0x08 || code === 0x09 || code === 0x0a || code === 0x0c || code === 0x0d ? 2 : 6 + } else if (code < 0x80) { + bytes += 1 + } else if (code < 0x800) { + bytes += 2 + } else if (code >= 0xd800 && code <= 0xdbff) { + const next = value.charCodeAt(index + 1) + if (next >= 0xdc00 && next <= 0xdfff) { + bytes += 4 + index += 1 + } else { + bytes += 6 + } + } else if (code >= 0xdc00 && code <= 0xdfff) { + bytes += 6 + } else { + bytes += 3 + } + } + return bytes +} + +function throwRequestBodyLimitError(): never { + throw new Error('Oracle Fusion request body exceeds the inline payload limit') +} + +function throwNonPlainJsonError(): never { + throw new Error( + 'Oracle Fusion request body must contain plain JSON data without accessors or custom serialization' + ) +} From 584f770984b6e77dcbdb10e605089707f22a8494 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Thu, 3 Sep 2026 18:11:34 -0700 Subject: [PATCH 08/15] fix(credentials): reuse trusted tool policy during resolution --- apps/sim/lib/oauth/token-resolution.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/sim/lib/oauth/token-resolution.ts b/apps/sim/lib/oauth/token-resolution.ts index f16b7a38548..64bea2c24f0 100644 --- a/apps/sim/lib/oauth/token-resolution.ts +++ b/apps/sim/lib/oauth/token-resolution.ts @@ -348,11 +348,12 @@ function credentialProviderMismatch(): ResolveCredentialTokenResult { function validateToolCredentialBinding( resolved: ResolvedCredential | null, - toolId?: string + toolId: string | undefined, + toolMetadata: ReturnType ): ResolveCredentialTokenResult | null { if (!resolved || !toolId) return null - const oauth = getToolMetadata(toolId)?.oauth + const oauth = toolMetadata?.oauth const isServiceAccount = resolved.credentialType === 'service_account' if ( oauth?.credentialKind === 'service-account' @@ -387,8 +388,9 @@ export async function resolveCredentialAccessToken( ): Promise { const { requestId, credentialId, toolId, auditRequest } = input + const toolMetadata = toolId ? getToolMetadata(toolId) : undefined const resolved = credentialId ? await resolveOAuthAccountId(credentialId) : null - const bindingError = validateToolCredentialBinding(resolved, toolId) + const bindingError = validateToolCredentialBinding(resolved, toolId, toolMetadata) if (bindingError) return bindingError if (resolved?.credentialType !== 'managed_oauth' || !resolved.credentialId) { @@ -441,7 +443,6 @@ export async function resolveCredentialAccessToken( } } - const toolMetadata = getToolMetadata(toolId) if (!toolMetadata?.oauth?.required) { logger.error(`[${requestId}] Tool is not configured for managed OAuth`, { toolId }) return { From 4600846ffde4084c97b5c078cde3046174597eb3 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Thu, 3 Sep 2026 18:21:51 -0700 Subject: [PATCH 09/15] fix(oracle-fusion): close validation edge cases --- .../oracle-fusion/identifiers.test.ts | 8 +++++ .../lib/internal/oracle-fusion/identifiers.ts | 4 ++- .../internal/oracle-fusion/protocol.test.ts | 10 ++++++ .../lib/internal/oracle-fusion/protocol.ts | 31 ++++++++++++------- .../oracle-fusion/request-body.test.ts | 14 +++++++++ .../internal/oracle-fusion/request-body.ts | 9 +++++- 6 files changed, 62 insertions(+), 14 deletions(-) diff --git a/apps/sim/lib/internal/oracle-fusion/identifiers.test.ts b/apps/sim/lib/internal/oracle-fusion/identifiers.test.ts index a3a0d587abb..e366f92ff5d 100644 --- a/apps/sim/lib/internal/oracle-fusion/identifiers.test.ts +++ b/apps/sim/lib/internal/oracle-fusion/identifiers.test.ts @@ -70,4 +70,12 @@ describe('normalizeOracleFusionDecimalIdentifier', () => { normalizeOracleFusionDecimalIdentifier('1', { maxDigits: 129, maxSourceLength: 128 }) ).toThrow('limits are invalid') }) + + it('checks the digit limit after removing an exact fractional suffix', () => { + expect( + normalizeOracleFusionDecimalIdentifier('123456000.000', { + maxDigits: 8, + }) + ).toBeUndefined() + }) }) diff --git a/apps/sim/lib/internal/oracle-fusion/identifiers.ts b/apps/sim/lib/internal/oracle-fusion/identifiers.ts index 737958f03d3..26be97e8def 100644 --- a/apps/sim/lib/internal/oracle-fusion/identifiers.ts +++ b/apps/sim/lib/internal/oracle-fusion/identifiers.ts @@ -103,5 +103,7 @@ export function normalizeOracleFusionDecimalIdentifier( if (fractionalDigits > significantCoefficient.length) return undefined const suffix = significantCoefficient.slice(significantCoefficient.length - fractionalDigits) if (!/^0*$/.test(suffix)) return undefined - return significantCoefficient.slice(0, significantCoefficient.length - fractionalDigits) || '0' + const normalized = + significantCoefficient.slice(0, significantCoefficient.length - fractionalDigits) || '0' + return normalized.length <= options.maxDigits ? normalized : undefined } diff --git a/apps/sim/lib/internal/oracle-fusion/protocol.test.ts b/apps/sim/lib/internal/oracle-fusion/protocol.test.ts index fd7783e745b..ec8e42fd706 100644 --- a/apps/sim/lib/internal/oracle-fusion/protocol.test.ts +++ b/apps/sim/lib/internal/oracle-fusion/protocol.test.ts @@ -170,6 +170,16 @@ describe('Oracle self links', () => { ) }) + it('rejects a self-link href containing malformed Unicode before URL parsing', () => { + expect(() => + extractOracleFusionOpaqueKey( + resource(`${ORIGIN}${COLLECTION}/bad\ud800key`), + ORIGIN, + COLLECTION_ADDRESS + ) + ).toThrow('Oracle self link is malformed') + }) + it.each([ [`${ORIGIN}/other/abc`, 'collection path'], [`${ORIGIN}${COLLECTION}/a/b`, 'one opaque key'], diff --git a/apps/sim/lib/internal/oracle-fusion/protocol.ts b/apps/sim/lib/internal/oracle-fusion/protocol.ts index 0776d9704a5..5f48a932694 100644 --- a/apps/sim/lib/internal/oracle-fusion/protocol.ts +++ b/apps/sim/lib/internal/oracle-fusion/protocol.ts @@ -7,6 +7,20 @@ import { const OPAQUE_KEY_MAX_LENGTH = 2048 const UNSAFE_OPAQUE_KEY = /[\\/?#\u0000-\u001f\u007f]/ +function hasWellFormedUtf16(value: string): boolean { + for (let index = 0; index < value.length; index++) { + const codeUnit = value.charCodeAt(index) + if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) { + const next = value.charCodeAt(index + 1) + if (!(next >= 0xdc00 && next <= 0xdfff)) return false + index++ + } else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) { + return false + } + } + return true +} + export interface OracleFusionCollection { items: T[] count: number @@ -111,7 +125,9 @@ function getOnlySelfLink(value: unknown): URL { throw new Error('Oracle response must include exactly one self link') } const href = (selfLinks[0] as Record).href - if (typeof href !== 'string') throw new Error('Oracle self link is malformed') + if (typeof href !== 'string' || !hasWellFormedUtf16(href)) { + throw new Error('Oracle self link is malformed') + } try { return new URL(href) } catch { @@ -157,17 +173,8 @@ function validateOpaqueKey(key: string): string { ) { throw new Error('Oracle resource key is not a safe opaque path segment') } - for (let index = 0; index < key.length; index++) { - const codeUnit = key.charCodeAt(index) - if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) { - const next = key.charCodeAt(index + 1) - if (!(next >= 0xdc00 && next <= 0xdfff)) { - throw new Error('Oracle resource key contains malformed Unicode') - } - index++ - } else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) { - throw new Error('Oracle resource key contains malformed Unicode') - } + if (!hasWellFormedUtf16(key)) { + throw new Error('Oracle resource key contains malformed Unicode') } return key } diff --git a/apps/sim/lib/internal/oracle-fusion/request-body.test.ts b/apps/sim/lib/internal/oracle-fusion/request-body.test.ts index 25ff76ddbb6..20ec8ca04c3 100644 --- a/apps/sim/lib/internal/oracle-fusion/request-body.test.ts +++ b/apps/sim/lib/internal/oracle-fusion/request-body.test.ts @@ -64,6 +64,20 @@ describe('serializeOracleFusionJsonBody', () => { expect(() => serializeOracleFusionJsonBody(customArray)).toThrow('plain JSON data') }) + it('rejects inherited custom serialization before JSON.stringify can invoke it', () => { + const previous = Object.getOwnPropertyDescriptor(Array.prototype, 'toJSON') + Object.defineProperty(Array.prototype, 'toJSON', { + configurable: true, + value: () => ({ replaced: true }), + }) + try { + expect(() => serializeOracleFusionJsonBody([1])).toThrow('plain JSON data') + } finally { + if (previous) Object.defineProperty(Array.prototype, 'toJSON', previous) + else Reflect.deleteProperty(Array.prototype, 'toJSON') + } + }) + it('rejects cycles, excessive nesting, and excessive complexity', () => { const cycle: unknown[] = [] cycle.push(cycle) diff --git a/apps/sim/lib/internal/oracle-fusion/request-body.ts b/apps/sim/lib/internal/oracle-fusion/request-body.ts index d8499c9a407..3e58b77b3d6 100644 --- a/apps/sim/lib/internal/oracle-fusion/request-body.ts +++ b/apps/sim/lib/internal/oracle-fusion/request-body.ts @@ -134,11 +134,18 @@ function isRecordLike(value: unknown): value is Record { } function assertContainerIsPlain(value: object): void { + for ( + let candidate: object | null = value; + candidate; + candidate = Object.getPrototypeOf(candidate) + ) { + if (Object.hasOwn(candidate, 'toJSON')) throwNonPlainJsonError() + } for (const key of Reflect.ownKeys(value)) { if (typeof key === 'symbol') throwNonPlainJsonError() if (key === 'length' && Array.isArray(value)) continue const descriptor = Object.getOwnPropertyDescriptor(value, key) - if (descriptor?.get || descriptor?.set || key === 'toJSON') throwNonPlainJsonError() + if (descriptor?.get || descriptor?.set) throwNonPlainJsonError() if (Array.isArray(value)) { const index = Number(key) if ( From 0431a39bac72198c2a58d24a3559cd73ba4137c3 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Thu, 3 Sep 2026 18:36:10 -0700 Subject: [PATCH 10/15] fix(oracle-fusion): preserve validated protocol values --- .../internal/oracle-fusion/protocol.test.ts | 28 ++- .../lib/internal/oracle-fusion/protocol.ts | 5 +- .../oracle-fusion/request-body.test.ts | 23 ++ .../internal/oracle-fusion/request-body.ts | 208 +++++++++++------- 4 files changed, 182 insertions(+), 82 deletions(-) diff --git a/apps/sim/lib/internal/oracle-fusion/protocol.test.ts b/apps/sim/lib/internal/oracle-fusion/protocol.test.ts index ec8e42fd706..db26be9eab5 100644 --- a/apps/sim/lib/internal/oracle-fusion/protocol.test.ts +++ b/apps/sim/lib/internal/oracle-fusion/protocol.test.ts @@ -48,10 +48,19 @@ describe('parseOracleFusionCollection', () => { it('accepts an empty terminal page and returns its current nextOffset', () => { expect( parseOracleFusionCollection( - { items: [], count: 0, hasMore: false, limit: 25, offset: 0 }, - (item) => item + { items: [], count: 0, hasMore: false, limit: 25, offset: 10, totalResults: 5 }, + (item) => item, + { expectedOffset: 10 } ) - ).toEqual({ items: [], count: 0, hasMore: false, limit: 25, offset: 0, nextOffset: 0 }) + ).toEqual({ + items: [], + count: 0, + hasMore: false, + limit: 25, + offset: 10, + totalResults: 5, + nextOffset: 10, + }) }) it('accepts omitted items only for an unambiguous empty terminal page', () => { @@ -180,6 +189,19 @@ describe('Oracle self links', () => { ).toThrow('Oracle self link is malformed') }) + it.each(['\t', '\n', '\r'])( + 'rejects a self-link key containing the raw control character %j before URL parsing', + (control) => { + expect(() => + extractOracleFusionOpaqueKey( + resource(`${ORIGIN}${COLLECTION}/bad${control}key`), + ORIGIN, + COLLECTION_ADDRESS + ) + ).toThrow('Oracle self link is malformed') + } + ) + it.each([ [`${ORIGIN}/other/abc`, 'collection path'], [`${ORIGIN}${COLLECTION}/a/b`, 'one opaque key'], diff --git a/apps/sim/lib/internal/oracle-fusion/protocol.ts b/apps/sim/lib/internal/oracle-fusion/protocol.ts index 5f48a932694..3310fbe45fa 100644 --- a/apps/sim/lib/internal/oracle-fusion/protocol.ts +++ b/apps/sim/lib/internal/oracle-fusion/protocol.ts @@ -6,6 +6,7 @@ import { const OPAQUE_KEY_MAX_LENGTH = 2048 const UNSAFE_OPAQUE_KEY = /[\\/?#\u0000-\u001f\u007f]/ +const UNSAFE_SELF_LINK_TEXT = /[\u0000-\u001f\u007f]/ function hasWellFormedUtf16(value: string): boolean { for (let index = 0; index < value.length; index++) { @@ -82,7 +83,7 @@ export function parseOracleFusionCollection( if (!Number.isSafeInteger(pageEnd)) { throw new Error('Oracle collection next offset exceeds the safe integer range') } - if (totalResults !== undefined && totalResults < pageEnd) { + if (totalResults !== undefined && count > 0 && totalResults < pageEnd) { throw new Error('Oracle collection totalResults is smaller than the returned page') } if (options.expectedOffset !== undefined) { @@ -125,7 +126,7 @@ function getOnlySelfLink(value: unknown): URL { throw new Error('Oracle response must include exactly one self link') } const href = (selfLinks[0] as Record).href - if (typeof href !== 'string' || !hasWellFormedUtf16(href)) { + if (typeof href !== 'string' || UNSAFE_SELF_LINK_TEXT.test(href) || !hasWellFormedUtf16(href)) { throw new Error('Oracle self link is malformed') } try { diff --git a/apps/sim/lib/internal/oracle-fusion/request-body.test.ts b/apps/sim/lib/internal/oracle-fusion/request-body.test.ts index 20ec8ca04c3..fa9d48f15fe 100644 --- a/apps/sim/lib/internal/oracle-fusion/request-body.test.ts +++ b/apps/sim/lib/internal/oracle-fusion/request-body.test.ts @@ -78,6 +78,29 @@ describe('serializeOracleFusionJsonBody', () => { } }) + it('serializes the descriptor values captured from a proxy exactly once', () => { + const target = { value: 'first' } + let descriptorReads = 0 + const proxy = new Proxy(target, { + getOwnPropertyDescriptor(current, key) { + descriptorReads += 1 + const descriptor = Reflect.getOwnPropertyDescriptor(current, key) + return descriptor ? { ...descriptor, value: `read-${descriptorReads}` } : undefined + }, + }) + + expect(serializeOracleFusionJsonBody(proxy)).toBe('{"value":"read-1"}') + expect(descriptorReads).toBe(1) + + const arrayProxy = new Proxy([1], { + get(_current, key) { + if (key === 'length') throw new Error('array length getter must not run') + return undefined + }, + }) + expect(serializeOracleFusionJsonBody(arrayProxy)).toBe('[1]') + }) + it('rejects cycles, excessive nesting, and excessive complexity', () => { const cycle: unknown[] = [] cycle.push(cycle) diff --git a/apps/sim/lib/internal/oracle-fusion/request-body.ts b/apps/sim/lib/internal/oracle-fusion/request-body.ts index 3e58b77b3d6..0d3a4f2aef9 100644 --- a/apps/sim/lib/internal/oracle-fusion/request-body.ts +++ b/apps/sim/lib/internal/oracle-fusion/request-body.ts @@ -3,39 +3,53 @@ import { MAX_INLINE_MATERIALIZATION_BYTES } from '@/lib/execution/payloads/limit const MAX_JSON_NESTING_DEPTH = 100 const MAX_JSON_NODE_COUNT = 100_000 +class OracleFusionRequestBodyError extends Error {} + interface JsonBudgetState { bytes: number nodes: number ancestors: WeakSet + fragments: string[] } type JsonBudgetFrame = | { kind: 'value'; value: unknown; depth: number } - | { kind: 'array'; value: unknown[]; index: number; depth: number } + | { + kind: 'array' + owner: unknown[] + values: unknown[] + index: number + depth: number + } | { kind: 'object' - value: Record - keys: string[] + owner: Record + entries: [string, unknown][] index: number depth: number } /** Serializes a bounded request body containing only plain JSON data. */ export function serializeOracleFusionJsonBody(body: unknown): string { - assertJsonBodyWithinLimit(body) - const serialized = JSON.stringify(body) - if (serialized === undefined) throwNonPlainJsonError() - if (Buffer.byteLength(serialized, 'utf8') > MAX_INLINE_MATERIALIZATION_BYTES) { - throwRequestBodyLimitError() + try { + const state = serializeJsonBodyWithinLimit(body) + const serialized = state.fragments.join('') + if (Buffer.byteLength(serialized, 'utf8') > MAX_INLINE_MATERIALIZATION_BYTES) { + throwRequestBodyLimitError() + } + return serialized + } catch (error) { + if (error instanceof OracleFusionRequestBodyError) throw error + throwNonPlainJsonError() } - return serialized } -function assertJsonBodyWithinLimit(body: unknown): void { +function serializeJsonBodyWithinLimit(body: unknown): JsonBudgetState { const state: JsonBudgetState = { bytes: 0, nodes: 0, ancestors: new WeakSet(), + fragments: [], } const frames: JsonBudgetFrame[] = [{ kind: 'value', value: body, depth: 0 }] @@ -44,130 +58,162 @@ function assertJsonBodyWithinLimit(body: unknown): void { if (!frame) break if (frame.kind === 'array') { - if (frame.index >= frame.value.length) { - addJsonBytes(state, 1) - state.ancestors.delete(frame.value) + if (frame.index >= frame.values.length) { + appendJsonFragment(state, ']') + state.ancestors.delete(frame.owner) continue } - if (frame.index > 0) addJsonBytes(state, 1) - const descriptor = Object.getOwnPropertyDescriptor(frame.value, String(frame.index)) - if (!descriptor || descriptor.get || descriptor.set) throwNonPlainJsonError() + if (frame.index > 0) appendJsonFragment(state, ',') frames.push({ ...frame, index: frame.index + 1 }) frames.push({ kind: 'value', - value: descriptor.value, + value: frame.values[frame.index], depth: frame.depth + 1, }) continue } if (frame.kind === 'object') { - if (frame.index >= frame.keys.length) { - addJsonBytes(state, 1) - state.ancestors.delete(frame.value) + if (frame.index >= frame.entries.length) { + appendJsonFragment(state, '}') + state.ancestors.delete(frame.owner) continue } - const key = frame.keys[frame.index] - const descriptor = Object.getOwnPropertyDescriptor(frame.value, key) - if (!descriptor || descriptor.get || descriptor.set) throwNonPlainJsonError() - if (frame.index > 0) addJsonBytes(state, 1) - addJsonBytes(state, jsonStringByteLength(key) + 1) + const [key, value] = frame.entries[frame.index] + if (frame.index > 0) appendJsonFragment(state, ',') + appendJsonString(state, key) + appendJsonFragment(state, ':') frames.push({ ...frame, index: frame.index + 1 }) - frames.push({ - kind: 'value', - value: descriptor.value, - depth: frame.depth + 1, - }) + frames.push({ kind: 'value', value, depth: frame.depth + 1 }) continue } admitJsonNode(state) const { value, depth } = frame if (depth > MAX_JSON_NESTING_DEPTH) { - throw new Error('Oracle Fusion request body exceeds the JSON nesting limit') + throw new OracleFusionRequestBodyError( + 'Oracle Fusion request body exceeds the JSON nesting limit' + ) } if (value === null) { - addJsonBytes(state, 4) + appendJsonFragment(state, 'null') } else if (typeof value === 'string') { - addJsonBytes(state, jsonStringByteLength(value)) + appendJsonString(state, value) } else if (typeof value === 'boolean') { - addJsonBytes(state, value ? 4 : 5) + appendJsonFragment(state, value ? 'true' : 'false') } else if (typeof value === 'number') { if (!Number.isFinite(value)) throwNonPlainJsonError() - addJsonBytes(state, JSON.stringify(value).length) + appendJsonFragment(state, JSON.stringify(value)) } else if (Array.isArray(value)) { - if (Object.getPrototypeOf(value) !== Array.prototype) throwNonPlainJsonError() - assertContainerIsPlain(value) + const prototype = Object.getPrototypeOf(value) + if (prototype !== Array.prototype) throwNonPlainJsonError() if (state.ancestors.has(value)) { - throw new Error('Oracle Fusion request body must not be cyclic') + throw new OracleFusionRequestBodyError('Oracle Fusion request body must not be cyclic') } - if (value.length * 2 + 1 > MAX_INLINE_MATERIALIZATION_BYTES - state.bytes) { + const values = captureArrayValues(value, prototype) + if (values.length * 2 + 1 > MAX_INLINE_MATERIALIZATION_BYTES - state.bytes) { throwRequestBodyLimitError() } state.ancestors.add(value) - addJsonBytes(state, 1) - frames.push({ kind: 'array', value, index: 0, depth }) + appendJsonFragment(state, '[') + frames.push({ kind: 'array', owner: value, values, index: 0, depth }) } else if (isRecordLike(value)) { const prototype = Object.getPrototypeOf(value) if (prototype !== Object.prototype && prototype !== null) throwNonPlainJsonError() - assertContainerIsPlain(value) if (state.ancestors.has(value)) { - throw new Error('Oracle Fusion request body must not be cyclic') + throw new OracleFusionRequestBodyError('Oracle Fusion request body must not be cyclic') } + const entries = captureObjectEntries(value, prototype) state.ancestors.add(value) - addJsonBytes(state, 1) - frames.push({ - kind: 'object', - value, - keys: Object.keys(value), - index: 0, - depth, - }) + appendJsonFragment(state, '{') + frames.push({ kind: 'object', owner: value, entries, index: 0, depth }) } else { throwNonPlainJsonError() } } + + return state } function isRecordLike(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } -function assertContainerIsPlain(value: object): void { - for ( - let candidate: object | null = value; - candidate; - candidate = Object.getPrototypeOf(candidate) - ) { +function rejectInheritedJsonSerialization(prototype: object | null): void { + for (let candidate = prototype; candidate; candidate = Object.getPrototypeOf(candidate)) { if (Object.hasOwn(candidate, 'toJSON')) throwNonPlainJsonError() } - for (const key of Reflect.ownKeys(value)) { +} + +function captureArrayValues(value: unknown[], prototype: object): unknown[] { + rejectInheritedJsonSerialization(prototype) + const ownKeys = Reflect.ownKeys(value) + if (ownKeys.length > MAX_JSON_NODE_COUNT + 1) throwComplexityLimitError() + const lengthDescriptor = Object.getOwnPropertyDescriptor(value, 'length') + const length = lengthDescriptor?.value + if ( + typeof length !== 'number' || + !Number.isSafeInteger(length) || + length < 0 || + length >= MAX_JSON_NODE_COUNT + ) { + throwComplexityLimitError() + } + const values = new Array(length) + let captured = 0 + + for (const key of ownKeys) { if (typeof key === 'symbol') throwNonPlainJsonError() - if (key === 'length' && Array.isArray(value)) continue - const descriptor = Object.getOwnPropertyDescriptor(value, key) - if (descriptor?.get || descriptor?.set) throwNonPlainJsonError() - if (Array.isArray(value)) { - const index = Number(key) - if ( - !Number.isSafeInteger(index) || - index < 0 || - String(index) !== key || - index >= value.length - ) { - throwNonPlainJsonError() - } + if (key === 'length') continue + const index = Number(key) + if (!Number.isSafeInteger(index) || index < 0 || String(index) !== key || index >= length) { + throwNonPlainJsonError() } + const descriptor = Object.getOwnPropertyDescriptor(value, key) + if (!descriptor || descriptor.get || descriptor.set) throwNonPlainJsonError() + values[index] = descriptor.value + captured += 1 + } + + if (captured !== length) throwNonPlainJsonError() + return values +} + +function captureObjectEntries( + value: Record, + prototype: object | null +): [string, unknown][] { + rejectInheritedJsonSerialization(prototype) + const ownKeys = Reflect.ownKeys(value) + if (ownKeys.length > MAX_JSON_NODE_COUNT) throwComplexityLimitError() + const entries: [string, unknown][] = [] + + for (const key of ownKeys) { + if (typeof key === 'symbol' || key === 'toJSON') throwNonPlainJsonError() + const descriptor = Object.getOwnPropertyDescriptor(value, key) + if (!descriptor || descriptor.get || descriptor.set) throwNonPlainJsonError() + if (descriptor.enumerable) entries.push([key, descriptor.value]) } + + return entries } function admitJsonNode(state: JsonBudgetState): void { state.nodes += 1 - if (state.nodes > MAX_JSON_NODE_COUNT) { - throw new Error('Oracle Fusion request body exceeds the JSON complexity limit') - } + if (state.nodes > MAX_JSON_NODE_COUNT) throwComplexityLimitError() +} + +function appendJsonFragment(state: JsonBudgetState, fragment: string): void { + reserveJsonBytes(state, Buffer.byteLength(fragment, 'utf8')) + state.fragments.push(fragment) +} + +function appendJsonString(state: JsonBudgetState, value: string): void { + reserveJsonBytes(state, jsonStringByteLength(value)) + state.fragments.push(JSON.stringify(value)) } -function addJsonBytes(state: JsonBudgetState, bytes: number): void { +function reserveJsonBytes(state: JsonBudgetState, bytes: number): void { state.bytes += bytes if (state.bytes > MAX_INLINE_MATERIALIZATION_BYTES) throwRequestBodyLimitError() } @@ -203,11 +249,19 @@ function jsonStringByteLength(value: string): number { } function throwRequestBodyLimitError(): never { - throw new Error('Oracle Fusion request body exceeds the inline payload limit') + throw new OracleFusionRequestBodyError( + 'Oracle Fusion request body exceeds the inline payload limit' + ) +} + +function throwComplexityLimitError(): never { + throw new OracleFusionRequestBodyError( + 'Oracle Fusion request body exceeds the JSON complexity limit' + ) } function throwNonPlainJsonError(): never { - throw new Error( + throw new OracleFusionRequestBodyError( 'Oracle Fusion request body must contain plain JSON data without accessors or custom serialization' ) } From fc2d2eaa62bc7e746141830b4812d800812fe156 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Thu, 3 Sep 2026 18:45:22 -0700 Subject: [PATCH 11/15] fix(oracle-fusion): harden response invariants --- .../internal/oracle-fusion/protocol.test.ts | 23 +++++++++++++++++++ .../lib/internal/oracle-fusion/protocol.ts | 12 +++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/apps/sim/lib/internal/oracle-fusion/protocol.test.ts b/apps/sim/lib/internal/oracle-fusion/protocol.test.ts index db26be9eab5..8d9661c1682 100644 --- a/apps/sim/lib/internal/oracle-fusion/protocol.test.ts +++ b/apps/sim/lib/internal/oracle-fusion/protocol.test.ts @@ -63,6 +63,15 @@ describe('parseOracleFusionCollection', () => { }) }) + it.each([ + { items: [], count: 0, hasMore: false, limit: 25, offset: 5, totalResults: 6 }, + { items: [{}], count: 1, hasMore: false, limit: 25, offset: 5, totalResults: 7 }, + ])('rejects terminal pagination metadata while results remain %#', (value) => { + expect(() => parseOracleFusionCollection(value, (item) => item)).toThrow( + 'hasMore contradicts totalResults' + ) + }) + it('accepts omitted items only for an unambiguous empty terminal page', () => { expect( parseOracleFusionCollection( @@ -202,6 +211,20 @@ describe('Oracle self links', () => { } ) + it.each([ + `${ORIGIN}${COLLECTION}/parent/../abc`, + `${ORIGIN}${COLLECTION}/parent/%2e%2e/abc`, + `${ORIGIN}${COLLECTION}/parent/.%2E/abc`, + `${ORIGIN}${COLLECTION}/parent\\..\\abc`, + ])('rejects a self-link path that URL parsing would normalize %j', (href) => { + expect(() => + validateOracleFusionSelfLink(resource(href), ORIGIN, { + family: 'hcm', + relativePath: 'workers/abc', + }) + ).toThrow('Oracle self link is malformed') + }) + it.each([ [`${ORIGIN}/other/abc`, 'collection path'], [`${ORIGIN}${COLLECTION}/a/b`, 'one opaque key'], diff --git a/apps/sim/lib/internal/oracle-fusion/protocol.ts b/apps/sim/lib/internal/oracle-fusion/protocol.ts index 3310fbe45fa..e890e8983bf 100644 --- a/apps/sim/lib/internal/oracle-fusion/protocol.ts +++ b/apps/sim/lib/internal/oracle-fusion/protocol.ts @@ -7,6 +7,7 @@ import { const OPAQUE_KEY_MAX_LENGTH = 2048 const UNSAFE_OPAQUE_KEY = /[\\/?#\u0000-\u001f\u007f]/ const UNSAFE_SELF_LINK_TEXT = /[\u0000-\u001f\u007f]/ +const RAW_SELF_LINK_DOT_SEGMENT = /(?:^|[\\/])(?:\.|%2e){1,2}(?=[\\/?#]|$)/i function hasWellFormedUtf16(value: string): boolean { for (let index = 0; index < value.length; index++) { @@ -86,6 +87,9 @@ export function parseOracleFusionCollection( if (totalResults !== undefined && count > 0 && totalResults < pageEnd) { throw new Error('Oracle collection totalResults is smaller than the returned page') } + if (totalResults !== undefined && !envelope.hasMore && totalResults > pageEnd) { + throw new Error('Oracle collection hasMore contradicts totalResults') + } if (options.expectedOffset !== undefined) { const expectedOffset = nonNegativeInteger( options.expectedOffset, @@ -126,7 +130,13 @@ function getOnlySelfLink(value: unknown): URL { throw new Error('Oracle response must include exactly one self link') } const href = (selfLinks[0] as Record).href - if (typeof href !== 'string' || UNSAFE_SELF_LINK_TEXT.test(href) || !hasWellFormedUtf16(href)) { + if ( + typeof href !== 'string' || + UNSAFE_SELF_LINK_TEXT.test(href) || + href.includes('\\') || + RAW_SELF_LINK_DOT_SEGMENT.test(href) || + !hasWellFormedUtf16(href) + ) { throw new Error('Oracle self link is malformed') } try { From 4d644af1c25dad843c9012e29f5d38c220f90a76 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Thu, 3 Sep 2026 18:53:22 -0700 Subject: [PATCH 12/15] fix(oracle-fusion): validate continuing page totals --- apps/sim/lib/internal/oracle-fusion/protocol.test.ts | 3 ++- apps/sim/lib/internal/oracle-fusion/protocol.ts | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/sim/lib/internal/oracle-fusion/protocol.test.ts b/apps/sim/lib/internal/oracle-fusion/protocol.test.ts index 8d9661c1682..a5496a4378c 100644 --- a/apps/sim/lib/internal/oracle-fusion/protocol.test.ts +++ b/apps/sim/lib/internal/oracle-fusion/protocol.test.ts @@ -66,7 +66,8 @@ describe('parseOracleFusionCollection', () => { it.each([ { items: [], count: 0, hasMore: false, limit: 25, offset: 5, totalResults: 6 }, { items: [{}], count: 1, hasMore: false, limit: 25, offset: 5, totalResults: 7 }, - ])('rejects terminal pagination metadata while results remain %#', (value) => { + { items: [{}], count: 1, hasMore: true, limit: 25, offset: 5, totalResults: 6 }, + ])('rejects pagination metadata that contradicts total results %#', (value) => { expect(() => parseOracleFusionCollection(value, (item) => item)).toThrow( 'hasMore contradicts totalResults' ) diff --git a/apps/sim/lib/internal/oracle-fusion/protocol.ts b/apps/sim/lib/internal/oracle-fusion/protocol.ts index e890e8983bf..cc90ff18d60 100644 --- a/apps/sim/lib/internal/oracle-fusion/protocol.ts +++ b/apps/sim/lib/internal/oracle-fusion/protocol.ts @@ -90,6 +90,9 @@ export function parseOracleFusionCollection( if (totalResults !== undefined && !envelope.hasMore && totalResults > pageEnd) { throw new Error('Oracle collection hasMore contradicts totalResults') } + if (totalResults !== undefined && envelope.hasMore && totalResults <= pageEnd) { + throw new Error('Oracle collection hasMore contradicts totalResults') + } if (options.expectedOffset !== undefined) { const expectedOffset = nonNegativeInteger( options.expectedOffset, From a9659a7022d7c6eb6e7fbe7def4fb6611c63ab12 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Thu, 3 Sep 2026 19:02:24 -0700 Subject: [PATCH 13/15] fix(oracle-fusion): preserve self-link key whitespace --- .../internal/oracle-fusion/protocol.test.ts | 20 +++++++++++++++++++ .../lib/internal/oracle-fusion/protocol.ts | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/apps/sim/lib/internal/oracle-fusion/protocol.test.ts b/apps/sim/lib/internal/oracle-fusion/protocol.test.ts index a5496a4378c..10b38619789 100644 --- a/apps/sim/lib/internal/oracle-fusion/protocol.test.ts +++ b/apps/sim/lib/internal/oracle-fusion/protocol.test.ts @@ -176,6 +176,26 @@ describe('Oracle self links', () => { ).toBe(key) }) + it('requires spaces in self-link keys to be encoded and preserves their value', () => { + const key = 'person name ' + const encoded = encodeOracleFusionPathSegment(key) + expect(encoded).toBe('person%20name%20') + expect( + extractOracleFusionOpaqueKey( + resource(`${ORIGIN}${COLLECTION}/${encoded}`), + ORIGIN, + COLLECTION_ADDRESS + ) + ).toBe(key) + expect(() => + extractOracleFusionOpaqueKey( + resource(`${ORIGIN}${COLLECTION}/${key}`), + ORIGIN, + COLLECTION_ADDRESS + ) + ).toThrow('Oracle self link is malformed') + }) + it.each(['', ' ', '.', '..', 'a/b', 'a\\b', 'a?b', 'a#b', 'a\nb', 'x'.repeat(2049)])( 'rejects the unsafe opaque key %j', (key) => { diff --git a/apps/sim/lib/internal/oracle-fusion/protocol.ts b/apps/sim/lib/internal/oracle-fusion/protocol.ts index cc90ff18d60..6a410cc722c 100644 --- a/apps/sim/lib/internal/oracle-fusion/protocol.ts +++ b/apps/sim/lib/internal/oracle-fusion/protocol.ts @@ -6,7 +6,7 @@ import { const OPAQUE_KEY_MAX_LENGTH = 2048 const UNSAFE_OPAQUE_KEY = /[\\/?#\u0000-\u001f\u007f]/ -const UNSAFE_SELF_LINK_TEXT = /[\u0000-\u001f\u007f]/ +const UNSAFE_SELF_LINK_TEXT = /[\s\u0000-\u001f\u007f]/ const RAW_SELF_LINK_DOT_SEGMENT = /(?:^|[\\/])(?:\.|%2e){1,2}(?=[\\/?#]|$)/i function hasWellFormedUtf16(value: string): boolean { From 50276856579af771760dd6ae0c8e8cc56bfb3f8c Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Fri, 4 Sep 2026 09:34:55 -0700 Subject: [PATCH 14/15] revert(credentials): defer platform-wide provider enforcement --- apps/sim/lib/oauth/token-resolution.test.ts | 169 ------------------ apps/sim/lib/oauth/token-resolution.ts | 51 +----- .../lib/selectors/server/credentials.test.ts | 77 -------- apps/sim/lib/selectors/server/credentials.ts | 10 -- apps/sim/lib/selectors/server/types.ts | 2 - 5 files changed, 2 insertions(+), 307 deletions(-) diff --git a/apps/sim/lib/oauth/token-resolution.test.ts b/apps/sim/lib/oauth/token-resolution.test.ts index 103dd066301..e06ee3c9c8d 100644 --- a/apps/sim/lib/oauth/token-resolution.test.ts +++ b/apps/sim/lib/oauth/token-resolution.test.ts @@ -6,11 +6,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockAuthorizeCredentialUseForAuth, mockCaptureServerEvent, - mockCredentialProviderMatchesService, mockExecuteManagedToken, mockGetCredential, - mockGetServiceConfigByProviderId, - mockGetServiceConfigByServiceId, mockGetToolMetadata, mockRecordAudit, mockRefreshTokenIfNeeded, @@ -19,11 +16,8 @@ const { } = vi.hoisted(() => ({ mockAuthorizeCredentialUseForAuth: vi.fn(), mockCaptureServerEvent: vi.fn(), - mockCredentialProviderMatchesService: vi.fn(), mockExecuteManagedToken: vi.fn(), mockGetCredential: vi.fn(), - mockGetServiceConfigByProviderId: vi.fn(), - mockGetServiceConfigByServiceId: vi.fn(), mockGetToolMetadata: vi.fn(), mockRecordAudit: vi.fn(), mockRefreshTokenIfNeeded: vi.fn(), @@ -84,10 +78,7 @@ vi.mock('@/tools/metadata', () => ({ })) vi.mock('@/lib/oauth/utils', () => ({ - credentialProviderMatchesService: mockCredentialProviderMatchesService, getCanonicalScopesForProvider: vi.fn().mockReturnValue([]), - getServiceConfigByProviderId: mockGetServiceConfigByProviderId, - getServiceConfigByServiceId: mockGetServiceConfigByServiceId, })) import { OrchestrationError } from '@/lib/core/orchestration/types' @@ -355,12 +346,6 @@ describe('resolveCredentialAccessToken', () => { beforeEach(() => { vi.clearAllMocks() mockResolveOAuthAccountId.mockResolvedValue(null) - mockCredentialProviderMatchesService.mockReturnValue(true) - mockGetServiceConfigByServiceId.mockReturnValue({ - providerId: 'google', - serviceAccountProviderId: 'google-service-account', - }) - mockGetServiceConfigByProviderId.mockReturnValue(null) authenticate.mockResolvedValue(INTERNAL_AUTH) resolveManagedPrincipal.mockResolvedValue(EXECUTOR_PRINCIPAL) mockGetToolMetadata.mockReturnValue({ @@ -426,160 +411,6 @@ describe('resolveCredentialAccessToken', () => { }) }) - it('rejects a service-account credential with no provider before authentication', async () => { - mockResolveOAuthAccountId.mockResolvedValue({ - credentialType: 'service_account', - credentialId: 'service-account-1', - workspaceId: 'ws-1', - accountId: '', - usedCredentialTable: true, - }) - mockGetToolMetadata.mockReturnValue({ - oauth: { - required: true, - provider: 'google', - credentialKind: 'service-account', - }, - }) - - await expect( - resolveCredentialAccessToken({ - requestId: 'req-1', - credentialId: 'service-account-1', - toolId: 'google_service_account_tool', - authenticate, - }) - ).resolves.toEqual({ - ok: false, - status: 403, - code: 'CREDENTIAL_PROVIDER_MISMATCH', - error: 'Credential belongs to another service', - }) - expect(authenticate).not.toHaveBeenCalled() - expect(mockResolveServiceAccountToken).not.toHaveBeenCalled() - }) - - it('rejects a service-account credential from another provider before authentication', async () => { - mockResolveOAuthAccountId.mockResolvedValue({ - credentialType: 'service_account', - credentialId: 'service-account-1', - providerId: 'atlassian-service-account', - workspaceId: 'ws-1', - accountId: '', - usedCredentialTable: true, - }) - mockGetToolMetadata.mockReturnValue({ - oauth: { - required: true, - provider: 'google', - credentialKind: 'service-account', - }, - }) - mockCredentialProviderMatchesService.mockReturnValue(false) - - await expect( - resolveCredentialAccessToken({ - requestId: 'req-1', - credentialId: 'service-account-1', - toolId: 'google_service_account_tool', - authenticate, - }) - ).resolves.toEqual({ - ok: false, - status: 403, - code: 'CREDENTIAL_PROVIDER_MISMATCH', - error: 'Credential belongs to another service', - }) - expect(authenticate).not.toHaveBeenCalled() - expect(mockResolveServiceAccountToken).not.toHaveBeenCalled() - }) - - it('accepts a non-Oracle service account whose provider matches the tool service', async () => { - mockResolveOAuthAccountId.mockResolvedValue({ - credentialType: 'service_account', - credentialId: 'service-account-1', - providerId: 'google-service-account', - workspaceId: 'ws-1', - accountId: '', - usedCredentialTable: true, - }) - mockGetToolMetadata.mockReturnValue({ - oauth: { - required: true, - provider: 'google-email', - requiredScopes: ['scope-a'], - credentialKind: 'service-account', - }, - }) - mockGetServiceConfigByServiceId.mockReturnValue(null) - mockGetServiceConfigByProviderId.mockReturnValue({ - providerId: 'google-email', - serviceAccountProviderId: 'google-service-account', - }) - mockAuthorizeCredentialUseForAuth.mockResolvedValue({ - ok: true, - requesterUserId: 'user-1', - workspaceId: 'ws-1', - }) - mockResolveServiceAccountToken.mockResolvedValue({ accessToken: 'service-account-token' }) - - await expect( - resolveCredentialAccessToken({ - requestId: 'req-1', - credentialId: 'service-account-1', - toolId: 'gmail_read', - scopes: ['scope-a'], - authenticate, - }) - ).resolves.toMatchObject({ - ok: true, - token: { accessToken: 'service-account-token', credentialType: 'service_account' }, - }) - expect(mockGetServiceConfigByProviderId).toHaveBeenCalledWith('google-email') - expect(mockResolveServiceAccountToken).toHaveBeenCalledWith( - 'service-account-1', - 'google-service-account', - ['scope-a'], - undefined - ) - }) - - it.each([ - ['an OAuth credential for a service-account-only tool', undefined, 'service-account'], - ['a service account for an OAuth-only tool', 'service_account', 'oauth'], - ])('rejects %s before authentication', async (_label, credentialType, requiredKind) => { - mockResolveOAuthAccountId.mockResolvedValue({ - ...(credentialType ? { credentialType } : {}), - credentialId: 'credential-1', - providerId: credentialType ? 'google-service-account' : undefined, - workspaceId: 'ws-1', - accountId: credentialType ? '' : 'account-1', - usedCredentialTable: true, - }) - mockGetToolMetadata.mockReturnValue({ - oauth: { - required: true, - provider: 'google', - credentialKind: requiredKind, - }, - }) - - const result = await resolveCredentialAccessToken({ - requestId: 'req-1', - credentialId: 'credential-1', - toolId: 'kind_restricted_tool', - authenticate, - }) - - expect(result).toEqual({ - ok: false, - status: 403, - code: 'CREDENTIAL_PROVIDER_MISMATCH', - error: 'Credential belongs to another service', - }) - expect(authenticate).not.toHaveBeenCalled() - }) - it('rejects a managed credential when no delegation resolver is wired', async () => { mockResolveOAuthAccountId.mockResolvedValue(MANAGED_RESOLVED) diff --git a/apps/sim/lib/oauth/token-resolution.ts b/apps/sim/lib/oauth/token-resolution.ts index 64bea2c24f0..dfd1f682b5b 100644 --- a/apps/sim/lib/oauth/token-resolution.ts +++ b/apps/sim/lib/oauth/token-resolution.ts @@ -24,12 +24,7 @@ import { MICROSOFT_DATAVERSE_PROVIDER_ID, } from '@/lib/oauth/microsoft-dataverse' import { extractSalesforceInstanceUrl, isSalesforceOAuthProviderId } from '@/lib/oauth/salesforce' -import { - credentialProviderMatchesService, - getCanonicalScopesForProvider, - getServiceConfigByProviderId, - getServiceConfigByServiceId, -} from '@/lib/oauth/utils' +import { getCanonicalScopesForProvider } from '@/lib/oauth/utils' import { captureServerEvent } from '@/lib/posthog/server' import { getToolMetadata } from '@/tools/metadata' import { extractZohoDeskBaseFromScope } from '@/tools/zoho_desk/host-allowlist' @@ -337,46 +332,6 @@ export interface ResolveCredentialAccessTokenInput resolveManagedPrincipal?: (credentialId: string) => Promise } -function credentialProviderMismatch(): ResolveCredentialTokenResult { - return { - ok: false, - status: 403, - code: 'CREDENTIAL_PROVIDER_MISMATCH', - error: 'Credential belongs to another service', - } -} - -function validateToolCredentialBinding( - resolved: ResolvedCredential | null, - toolId: string | undefined, - toolMetadata: ReturnType -): ResolveCredentialTokenResult | null { - if (!resolved || !toolId) return null - - const oauth = toolMetadata?.oauth - const isServiceAccount = resolved.credentialType === 'service_account' - if ( - oauth?.credentialKind === 'service-account' - ? !isServiceAccount - : oauth?.credentialKind === 'oauth' && isServiceAccount - ) { - return credentialProviderMismatch() - } - if (!isServiceAccount) return null - - const service = oauth?.required - ? (getServiceConfigByServiceId(oauth.provider) ?? getServiceConfigByProviderId(oauth.provider)) - : null - if ( - !resolved.providerId || - !service || - !credentialProviderMatchesService(resolved.providerId, service) - ) { - return credentialProviderMismatch() - } - return null -} - /** * Authorized application dispatch behind `POST /api/auth/oauth/token`. Every server * surface that needs a credential token — the route and the in-process tool @@ -388,10 +343,7 @@ export async function resolveCredentialAccessToken( ): Promise { const { requestId, credentialId, toolId, auditRequest } = input - const toolMetadata = toolId ? getToolMetadata(toolId) : undefined const resolved = credentialId ? await resolveOAuthAccountId(credentialId) : null - const bindingError = validateToolCredentialBinding(resolved, toolId, toolMetadata) - if (bindingError) return bindingError if (resolved?.credentialType !== 'managed_oauth' || !resolved.credentialId) { const auth = await input.authenticate() @@ -443,6 +395,7 @@ export async function resolveCredentialAccessToken( } } + const toolMetadata = getToolMetadata(toolId) if (!toolMetadata?.oauth?.required) { logger.error(`[${requestId}] Tool is not configured for managed OAuth`, { toolId }) return { diff --git a/apps/sim/lib/selectors/server/credentials.test.ts b/apps/sim/lib/selectors/server/credentials.test.ts index 518d53f3b41..17138d2079a 100644 --- a/apps/sim/lib/selectors/server/credentials.test.ts +++ b/apps/sim/lib/selectors/server/credentials.test.ts @@ -125,83 +125,6 @@ describe('authorizeSelectorCredential', () => { expect(mocks.authorizeCredentialUse).not.toHaveBeenCalled() }) - it('rejects a fixed token for a service-account-only selector', async () => { - await expect( - authorizeSelectorCredential({ - principal, - context: { oauthCredential: 'xoxb-a' }, - scope: { kind: 'workspace', workspaceId: 'workspace-1' }, - workspaceId: 'workspace-1', - policy: { - kind: 'stored-or-fixed-token', - field: 'oauthCredential', - serviceIds: ['slack'], - tokenPrefixes: ['xoxb-'], - credentialKind: 'service-account', - }, - protectedValues: createSelectorProtectedValues(), - references: new Map(), - }) - ).rejects.toEqual(new SelectorConnectionUnavailableError()) - expect(mocks.authorizeCredentialUse).not.toHaveBeenCalled() - }) - - it.each([ - ['oauth', 'service-account'], - ['service_account', 'oauth'], - ] as const)( - 'rejects a %s credential for a %s-only selector before provider resolution', - async (credentialType, credentialKind) => { - mocks.authorizeCredentialUse.mockResolvedValue({ - ok: true, - workspaceId: 'workspace-1', - credentialOwnerUserId: 'owner-1', - resolvedCredentialId: 'credential-1', - credentialType, - }) - - await expect( - authorizeSelectorCredential({ - principal, - context: { oauthCredential: 'credential-1' }, - scope: { kind: 'workspace', workspaceId: 'workspace-1' }, - workspaceId: 'workspace-1', - policy: { ...policy, credentialKind }, - protectedValues: createSelectorProtectedValues(), - references: new Map(), - }) - ).rejects.toEqual(new SelectorConnectionUnavailableError()) - expect(mocks.credentialProviderMatchesService).not.toHaveBeenCalled() - } - ) - - it.each([ - ['oauth', 'oauth'], - ['service_account', 'service-account'], - ] as const)('accepts a matching %s credential kind', async (credentialType, credentialKind) => { - mocks.authorizeCredentialUse.mockResolvedValue({ - ok: true, - workspaceId: 'workspace-1', - credentialOwnerUserId: 'owner-1', - resolvedCredentialId: 'credential-1', - credentialType, - }) - queueTableRows(credential, [{ accountId: 'account-1', providerId: 'google' }]) - mocks.credentialProviderMatchesService.mockReturnValue(true) - - await expect( - authorizeSelectorCredential({ - principal, - context: { oauthCredential: 'credential-1' }, - scope: { kind: 'workspace', workspaceId: 'workspace-1' }, - workspaceId: 'workspace-1', - policy: { ...policy, credentialKind }, - protectedValues: createSelectorProtectedValues(), - references: new Map(), - }) - ).resolves.toMatchObject({ access: { credentialType } }) - }) - it('conceals a stored credential whose trusted provider does not match the selector service', async () => { mocks.authorizeCredentialUse.mockResolvedValue({ ok: true, diff --git a/apps/sim/lib/selectors/server/credentials.ts b/apps/sim/lib/selectors/server/credentials.ts index 5352aa21126..2bc68c62275 100644 --- a/apps/sim/lib/selectors/server/credentials.ts +++ b/apps/sim/lib/selectors/server/credentials.ts @@ -111,9 +111,6 @@ export async function authorizeSelectorCredential(input: { input.policy.kind === 'stored-or-fixed-token' && input.policy.tokenPrefixes.some((prefix) => suppliedId.startsWith(prefix)) ) { - if (input.policy.credentialKind === 'service-account') { - throw new SelectorConnectionUnavailableError() - } const reference = input.references.get(input.policy.field) if (reference && !reference.visible) { input.protectedValues.add(suppliedId, 'secret') @@ -136,13 +133,6 @@ export async function authorizeSelectorCredential(input: { if (!access.ok || access.workspaceId !== input.workspaceId) { throw new SelectorConnectionUnavailableError() } - if ( - input.policy.credentialKind === 'service-account' - ? access.credentialType !== 'service_account' - : input.policy.credentialKind === 'oauth' && access.credentialType === 'service_account' - ) { - throw new SelectorConnectionUnavailableError() - } input.protectedValues.add(access.resolvedCredentialId, 'reference') const providerId = await requireCredentialProviderBinding( diff --git a/apps/sim/lib/selectors/server/types.ts b/apps/sim/lib/selectors/server/types.ts index 5dc6283b4d3..4249782a7e9 100644 --- a/apps/sim/lib/selectors/server/types.ts +++ b/apps/sim/lib/selectors/server/types.ts @@ -34,7 +34,6 @@ export type SelectorCredentialPolicy = field: 'oauthCredential' serviceIds: readonly string[] resourceServiceId?: string - credentialKind?: 'oauth' | 'service-account' } | { kind: 'stored-or-fixed-token' @@ -42,7 +41,6 @@ export type SelectorCredentialPolicy = serviceIds: readonly string[] tokenPrefixes: readonly string[] resourceServiceId?: string - credentialKind?: 'oauth' | 'service-account' } export interface AuthorizedSelectorCredential { From 23032c26b3f42a5508f1e8dad3bc2f190dbd7a93 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Fri, 4 Sep 2026 09:36:08 -0700 Subject: [PATCH 15/15] refactor(oracle-fusion): narrow shared infrastructure footprint --- apps/docs/components/icons.tsx | 3 - apps/sim/components/icons.tsx | 3 - .../service-account-secret.test.ts | 1 - .../lib/credentials/service-account-secret.ts | 2 +- apps/sim/lib/oauth/credential-service.test.ts | 64 +------------------ apps/sim/lib/oauth/credential-service.ts | 16 ++--- 6 files changed, 6 insertions(+), 83 deletions(-) diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index 5366c3b7681..0c0c8783e1b 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -9287,9 +9287,6 @@ export function NetSuiteIcon(props: SVGProps) { ) } -/** Oracle's red oval, shared by Oracle product integrations. */ -export const OracleIcon = NetSuiteIcon - export function WizaIcon(props: SVGProps) { return ( diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index 5366c3b7681..0c0c8783e1b 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -9287,9 +9287,6 @@ export function NetSuiteIcon(props: SVGProps) { ) } -/** Oracle's red oval, shared by Oracle product integrations. */ -export const OracleIcon = NetSuiteIcon - export function WizaIcon(props: SVGProps) { return ( diff --git a/apps/sim/lib/credentials/service-account-secret.test.ts b/apps/sim/lib/credentials/service-account-secret.test.ts index af88b5c2b89..5dd00952192 100644 --- a/apps/sim/lib/credentials/service-account-secret.test.ts +++ b/apps/sim/lib/credentials/service-account-secret.test.ts @@ -282,7 +282,6 @@ describe('verifyAndBuildServiceAccountSecret', () => { clientId: ' integration-user ', clientSecret: ' password ', certificateId: 'discard-me', - dataCenter: 'discard-me', authMethod: 'discard-me', privateKey: 'discard-me', username: 'discard-me', diff --git a/apps/sim/lib/credentials/service-account-secret.ts b/apps/sim/lib/credentials/service-account-secret.ts index 9bf3cd273c6..5c35210cfe3 100644 --- a/apps/sim/lib/credentials/service-account-secret.ts +++ b/apps/sim/lib/credentials/service-account-secret.ts @@ -299,7 +299,7 @@ async function buildClientCredentialAccountSecret( ? fields.certificateId?.trim() || undefined : undefined, orgId: fields.orgId?.trim() ?? '', - dataCenter: usesField('dataCenter') ? fields.dataCenter?.trim() || undefined : undefined, + dataCenter: fields.dataCenter?.trim() || undefined, authMethod: resolvedAuthMethod, clientSecret: usesField('clientSecret') ? fields.clientSecret?.trim() || undefined : undefined, privateKey: usesField('privateKey') ? fields.privateKey?.trim() || undefined : undefined, diff --git a/apps/sim/lib/oauth/credential-service.test.ts b/apps/sim/lib/oauth/credential-service.test.ts index 9c0b553d33a..337b56aa435 100644 --- a/apps/sim/lib/oauth/credential-service.test.ts +++ b/apps/sim/lib/oauth/credential-service.test.ts @@ -7,8 +7,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ coalesceLocally: vi.fn(), - clientCredentialMinter: vi.fn(), - decryptSecret: vi.fn(), getFreshestSlackChain: vi.fn(), getRecentTerminalError: vi.fn(), logger: { @@ -35,15 +33,6 @@ vi.mock('@/lib/concurrency/leader-lock', () => ({ withLeaderLock: mocks.withLeaderLock, })) -vi.mock('@/lib/core/security/encryption', () => ({ - decryptSecret: mocks.decryptSecret, -})) - -vi.mock('@/lib/credentials/client-credential-accounts/server', () => ({ - getClientCredentialAccountMinter: () => mocks.clientCredentialMinter, - parseClientCredentialAccountSecretBlob: (decrypted: string) => JSON.parse(decrypted), -})) - vi.mock('@/lib/oauth/instagram', () => ({ isInstagramProvider: vi.fn(() => false), shouldProactivelyRefreshInstagramToken: vi.fn(() => false), @@ -75,10 +64,7 @@ vi.mock('@/lib/oauth/terminal-errors', () => ({ markCredentialDead: vi.fn(), })) -import { - resolveCredentialTokenBundle, - resolveServiceAccountToken, -} from '@/lib/oauth/credential-service' +import { resolveCredentialTokenBundle } from '@/lib/oauth/credential-service' const RAW_CREDENTIAL_ID = 'credential-raw-secret-id' const RAW_ACCOUNT_ID = 'account-raw-secret-id' @@ -214,51 +200,3 @@ describe('resolveCredentialTokenBundle selector privacy', () => { expect(slack.logs).toContain(RAW_PROVIDER_ERROR) }) }) - -describe('resolveServiceAccountToken Oracle Fusion cache', () => { - beforeEach(() => { - vi.clearAllMocks() - resetDbChainMock() - mocks.coalesceLocally.mockImplementation( - async (_key: string, producer: () => Promise) => producer() - ) - mocks.decryptSecret.mockImplementation(async (encrypted: string) => ({ - decrypted: JSON.stringify({ - type: 'client_credential_account', - providerId: 'oracle-fusion-service-account', - clientId: 'integration-user', - clientSecret: encrypted, - orgId: 'https://vision.fa.us2.oraclecloud.com', - }), - })) - mocks.clientCredentialMinter.mockImplementation(async (fields: { clientSecret: string }) => ({ - accessToken: `basic-${fields.clientSecret}`, - expiresInSeconds: 300, - instanceUrl: 'https://vision.fa.us2.oraclecloud.com', - })) - }) - - it('reuses Basic material for five minutes and invalidates it on encrypted-secret rotation', async () => { - const credentialId = 'oracle-fusion-cache-test' - const providerId = 'oracle-fusion-service-account' - const encryptedV1 = 'encrypted-v1-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' - const encryptedV2 = 'encrypted-v2-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' - - queueTableRows(credential, [{ encryptedServiceAccountKey: encryptedV1 }]) - await expect(resolveServiceAccountToken(credentialId, providerId)).resolves.toMatchObject({ - accessToken: `basic-${encryptedV1}`, - }) - - queueTableRows(credential, [{ encryptedServiceAccountKey: encryptedV1 }]) - await expect(resolveServiceAccountToken(credentialId, providerId)).resolves.toMatchObject({ - accessToken: `basic-${encryptedV1}`, - }) - expect(mocks.clientCredentialMinter).toHaveBeenCalledTimes(1) - - queueTableRows(credential, [{ encryptedServiceAccountKey: encryptedV2 }]) - await expect(resolveServiceAccountToken(credentialId, providerId)).resolves.toMatchObject({ - accessToken: `basic-${encryptedV2}`, - }) - expect(mocks.clientCredentialMinter).toHaveBeenCalledTimes(2) - }) -}) diff --git a/apps/sim/lib/oauth/credential-service.ts b/apps/sim/lib/oauth/credential-service.ts index 5fb7b08744f..0962cf8cb56 100644 --- a/apps/sim/lib/oauth/credential-service.ts +++ b/apps/sim/lib/oauth/credential-service.ts @@ -8,10 +8,7 @@ import { withLeaderLock } from '@/lib/concurrency/leader-lock' import { coalesceLocally } from '@/lib/concurrency/singleflight' import { env } from '@/lib/core/config/env' import { decryptSecret } from '@/lib/core/security/encryption' -import { - isClientCredentialAccountProviderId, - ORACLE_FUSION_SERVICE_ACCOUNT_PROVIDER_ID, -} from '@/lib/credentials/client-credential-accounts/descriptors' +import { isClientCredentialAccountProviderId } from '@/lib/credentials/client-credential-accounts/descriptors' import { getClientCredentialAccountMinter, parseClientCredentialAccountSecretBlob, @@ -469,13 +466,11 @@ interface FailedClientCredentialMint { /** * Per-instance cache of minted client-credential access tokens (Zoom S2S, - * Box CCG, Salesforce, NetSuite, Oracle Fusion), keyed by credential id. Entries are + * Box CCG, Salesforce, NetSuite), keyed by credential id. Entries are * served while more than {@link CLIENT_CREDENTIAL_TOKEN_MIN_TTL_MS} of * validity remains, so a hot credential mints roughly once per token TTL * (~1h for Zoom/Box/NetSuite; Salesforce reports a conservative 10-minute TTL - * because its responses never carry an expiry) per instance. Oracle Fusion's - * locally derived, non-expiring Basic value instead uses its complete - * five-minute synthetic lifetime. + * because its responses never carry an expiry) per instance. * * Every resolution re-reads the credential row (a cheap indexed PK select — * the mint is the expensive part) and validates the cached entry's secret @@ -555,10 +550,7 @@ async function resolveClientCredentialAccountToken( if ( cached && cached.secretFingerprint === secretFingerprint && - cached.expiresAtMs - Date.now() > - (providerId === ORACLE_FUSION_SERVICE_ACCOUNT_PROVIDER_ID - ? 0 - : CLIENT_CREDENTIAL_TOKEN_MIN_TTL_MS) + cached.expiresAtMs - Date.now() > CLIENT_CREDENTIAL_TOKEN_MIN_TTL_MS ) { return { accessToken: cached.accessToken,