Skip to content

Commit 7840639

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
fix(oracle-epm): bound DNS waits and validate header values
1 parent 8c5f26c commit 7840639

2 files changed

Lines changed: 166 additions & 7 deletions

File tree

‎apps/sim/lib/internal/oracle-epm/client.server.test.ts‎

Lines changed: 128 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/** @vitest-environment node */
2-
import { beforeEach, describe, expect, it, vi } from 'vitest'
2+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
3+
import type { AsyncValidationResult } from '@/lib/core/security/input-validation.server'
34
import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
45

56
const { mockSecureFetch, mockValidateUrl } = vi.hoisted(() => ({
@@ -139,6 +140,41 @@ describe('Oracle EPM guarded client', () => {
139140
expect(mockSecureFetch.mock.calls[0][0]).toContain('%252e%252e%252fadmin')
140141
})
141142

143+
it.each([null, 123, true, {}, ['etag'], new Uint8Array([65])])(
144+
'rejects non-string header %j before DNS or fetch',
145+
async (etag) => {
146+
const client = createOracleEpmClient({
147+
instanceUrl: 'https://epm.example.com',
148+
accessToken: Buffer.from('u:p').toString('base64'),
149+
})
150+
await expect(
151+
client.request(getJob, {
152+
pathParams: { jobId: '42' },
153+
headers: { etag: etag as unknown as string },
154+
})
155+
).rejects.toMatchObject({ name: 'OracleEpmError', category: 'invalid_input' })
156+
expect(mockValidateUrl).not.toHaveBeenCalled()
157+
expect(mockSecureFetch).not.toHaveBeenCalled()
158+
}
159+
)
160+
161+
it('rejects header objects without invoking their string coercion', async () => {
162+
const stringifyHeader = vi.fn(() => 'coerced-header')
163+
const client = createOracleEpmClient({
164+
instanceUrl: 'https://epm.example.com',
165+
accessToken: Buffer.from('u:p').toString('base64'),
166+
})
167+
await expect(
168+
client.request(getJob, {
169+
pathParams: { jobId: '42' },
170+
headers: { etag: { toString: stringifyHeader } as unknown as string },
171+
})
172+
).rejects.toMatchObject({ category: 'invalid_input' })
173+
expect(stringifyHeader).not.toHaveBeenCalled()
174+
expect(mockValidateUrl).not.toHaveBeenCalled()
175+
expect(mockSecureFetch).not.toHaveBeenCalled()
176+
})
177+
142178
it('rejects malformed UTF-16 path input before URL encoding', async () => {
143179
const client = createOracleEpmClient({
144180
instanceUrl: 'https://epm.example.com',
@@ -305,9 +341,100 @@ describe('Oracle EPM guarded client', () => {
305341
signal: controller.signal,
306342
})
307343
).rejects.toMatchObject({ name: 'AbortError' })
344+
expect(mockValidateUrl).not.toHaveBeenCalled()
308345
expect(mockSecureFetch).not.toHaveBeenCalled()
309346
})
310347

348+
describe('DNS cancellation', () => {
349+
afterEach(() => vi.restoreAllMocks())
350+
351+
it.each(['deadline', 'caller'] as const)(
352+
'ends on %s cancellation even when DNS never settles',
353+
async (source) => {
354+
const deadline = new AbortController()
355+
const caller = new AbortController()
356+
const timeout = vi.spyOn(AbortSignal, 'timeout').mockReturnValue(deadline.signal)
357+
mockValidateUrl.mockReturnValueOnce(new Promise<AsyncValidationResult>(() => {}))
358+
const client = createOracleEpmClient({
359+
instanceUrl: 'https://epm.example.com',
360+
accessToken: Buffer.from('u:p').toString('base64'),
361+
})
362+
const rejected = vi.fn()
363+
const fulfilled = vi.fn()
364+
const request = client
365+
.request(getJob, { pathParams: { jobId: '42' }, signal: caller.signal })
366+
.then(fulfilled, rejected)
367+
const callerReason = new DOMException('caller cancelled', 'AbortError')
368+
if (source === 'deadline') deadline.abort(new DOMException('deadline', 'TimeoutError'))
369+
else caller.abort(callerReason)
370+
371+
await vi.waitFor(() => expect(rejected).toHaveBeenCalledTimes(1), {
372+
interval: 1,
373+
timeout: 100,
374+
})
375+
await request
376+
expect(timeout).toHaveBeenCalledWith(5_000)
377+
expect(rejected).toHaveBeenCalledWith(
378+
source === 'deadline'
379+
? expect.objectContaining({ category: 'timeout', retryable: true })
380+
: callerReason
381+
)
382+
expect(fulfilled).not.toHaveBeenCalled()
383+
expect(mockSecureFetch).not.toHaveBeenCalled()
384+
}
385+
)
386+
387+
it.each(['resolve', 'reject'] as const)(
388+
'does not revive a cancelled request when DNS later %ss',
389+
async (settlement) => {
390+
const dns = Promise.withResolvers<AsyncValidationResult>()
391+
mockValidateUrl.mockReturnValueOnce(dns.promise)
392+
const controller = new AbortController()
393+
const client = createOracleEpmClient({
394+
instanceUrl: 'https://epm.example.com',
395+
accessToken: Buffer.from('u:p').toString('base64'),
396+
})
397+
const rejected = vi.fn()
398+
const request = client
399+
.request(getJob, { pathParams: { jobId: '42' }, signal: controller.signal })
400+
.catch(rejected)
401+
controller.abort(new DOMException('caller cancelled', 'AbortError'))
402+
await vi.waitFor(() => expect(rejected).toHaveBeenCalledTimes(1), {
403+
interval: 1,
404+
timeout: 100,
405+
})
406+
await request
407+
408+
if (settlement === 'resolve') {
409+
dns.resolve({
410+
isValid: true,
411+
resolvedIP: '203.0.113.10',
412+
originalHostname: 'epm.example.com',
413+
})
414+
} else {
415+
dns.reject(new Error('late resolver failure'))
416+
}
417+
await Promise.resolve()
418+
expect(rejected).toHaveBeenCalledTimes(1)
419+
expect(mockSecureFetch).not.toHaveBeenCalled()
420+
}
421+
)
422+
423+
it('suppresses unexpected DNS rejection details', async () => {
424+
mockValidateUrl.mockRejectedValueOnce(new Error('private resolver failure'))
425+
const client = createOracleEpmClient({
426+
instanceUrl: 'https://epm.example.com',
427+
accessToken: Buffer.from('u:p').toString('base64'),
428+
})
429+
const error = await client
430+
.request(getJob, { pathParams: { jobId: '42' } })
431+
.catch((value: unknown) => value)
432+
expect(error).toMatchObject({ category: 'service_unavailable', retryable: true })
433+
expect(String(error)).not.toContain('private resolver failure')
434+
expect(mockSecureFetch).not.toHaveBeenCalled()
435+
})
436+
})
437+
311438
it.each(['download', 'Job Status'])(
312439
'keeps %s links opaque and client-owned',
313440
async (relation) => {

‎apps/sim/lib/internal/oracle-epm/client.server.ts‎

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { interruptibleSleep } from '@sim/utils/helpers'
22
import { backoffWithJitter } from '@sim/utils/retry'
33
import {
4+
type AsyncValidationResult,
45
type SecureFetchResponse,
56
secureFetchWithPinnedIP,
67
validateUrlWithDNS,
@@ -168,6 +169,7 @@ function buildHeaders(
168169
continue
169170
}
170171
if (
172+
typeof value !== 'string' ||
171173
/\r|\n|\u0000/.test(value) ||
172174
MALFORMED_UTF16.test(value) ||
173175
Buffer.byteLength(value, 'utf8') > declaration.maxBytes ||
@@ -402,6 +404,34 @@ export interface OracleEpmClient {
402404
): Promise<OracleEpmClientResponse>
403405
}
404406

407+
/** Bounds the DNS wait without changing the platform resolver's own lifetime. */
408+
function validateDestinationWithSignal(
409+
origin: string,
410+
signal: AbortSignal
411+
): Promise<AsyncValidationResult> {
412+
signal.throwIfAborted()
413+
return new Promise((resolve, reject) => {
414+
const cleanup = () => signal.removeEventListener('abort', onAbort)
415+
const onAbort = () => {
416+
cleanup()
417+
reject(signal.reason)
418+
}
419+
signal.addEventListener('abort', onAbort, { once: true })
420+
validateUrlWithDNS(origin, 'Oracle EPM destination', 'configuredEndpoint', {
421+
logDetails: false,
422+
}).then(
423+
(validation) => {
424+
cleanup()
425+
resolve(validation)
426+
},
427+
(error: unknown) => {
428+
cleanup()
429+
reject(error)
430+
}
431+
)
432+
})
433+
}
434+
405435
/** Creates a fixed-destination Oracle EPM client from resolved credential material. */
406436
export function createOracleEpmClient(input: {
407437
instanceUrl: string
@@ -430,12 +460,14 @@ export function createOracleEpmClient(input: {
430460
const signal = request.signal
431461
? AbortSignal.any([request.signal, deadlineSignal])
432462
: deadlineSignal
433-
const validation = await validateUrlWithDNS(
434-
destinationData.origin,
435-
'Oracle EPM destination',
436-
'configuredEndpoint',
437-
{ logDetails: false }
438-
)
463+
let validation: AsyncValidationResult
464+
try {
465+
validation = await validateDestinationWithSignal(destinationData.origin, signal)
466+
} catch (error) {
467+
if (request.signal?.aborted) throw request.signal.reason ?? error
468+
if (deadlineSignal.aborted) throw oracleEpmLocalError('timeout', true)
469+
throw oracleEpmLocalError('service_unavailable', true)
470+
}
439471
if (request.signal?.aborted) {
440472
throw request.signal.reason ?? new DOMException('Aborted', 'AbortError')
441473
}

0 commit comments

Comments
 (0)