Skip to content

Commit a818f7a

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
fix(oracle-epm): support multiword returned-link relations
1 parent b81803b commit a818f7a

3 files changed

Lines changed: 199 additions & 77 deletions

File tree

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

Lines changed: 110 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -308,75 +308,106 @@ describe('Oracle EPM guarded client', () => {
308308
expect(mockSecureFetch).not.toHaveBeenCalled()
309309
})
310310

311-
it('returns an opaque same-client link capability and keeps query secrets out of serialization', async () => {
312-
const download = routes.defineEndpoint({
311+
it.each(['download', 'Job Status'])(
312+
'keeps %s links opaque and client-owned',
313+
async (relation) => {
314+
const download = routes.defineEndpoint({
315+
method: 'GET',
316+
version: 'v3',
317+
path: [oracleEpmLiteral('files'), oracleEpmPathParameter('fileId', { maxBytes: 32 })],
318+
query: { token: oracleEpmQuery.string({ required: true, maxBytes: 128 }) },
319+
body: 'none',
320+
response: 'stream',
321+
timeoutMs: 5_000,
322+
maxResponseBytes: 4_096,
323+
})
324+
const policy = routes.defineReturnedLinkPolicy({
325+
relation,
326+
method: 'GET',
327+
endpoint: download,
328+
preserveGatewayBasePath: true,
329+
})
330+
const client = createOracleEpmClient({
331+
instanceUrl: 'https://epm.example.com/gateway',
332+
accessToken: Buffer.from('user:password').toString('base64'),
333+
})
334+
const secret = 'signed-query-secret'
335+
const link = client.validateReturnedLink(policy, {
336+
rel: relation,
337+
href: `https://epm.example.com/gateway/SyntheticAlpha/rest/v3/files/abc?token=${secret}`,
338+
})
339+
340+
expect(Object.isFrozen(link)).toBe(true)
341+
expect(Object.keys(link)).toEqual([])
342+
expect(JSON.stringify(link)).toBe('{}')
343+
expect(String(link)).not.toContain(secret)
344+
345+
mockSecureFetch.mockResolvedValue(secureResponse({ body: new ReadableStream() }))
346+
await client.requestValidatedLink(link)
347+
expect(mockSecureFetch).toHaveBeenCalledWith(
348+
`https://epm.example.com/gateway/SyntheticAlpha/rest/v3/files/abc?token=${secret}`,
349+
'203.0.113.10',
350+
expect.objectContaining({ method: 'GET' })
351+
)
352+
353+
const otherClient = createOracleEpmClient({
354+
instanceUrl: 'https://epm.example.com/gateway',
355+
accessToken: Buffer.from('other:password').toString('base64'),
356+
})
357+
await expect(otherClient.requestValidatedLink(link)).rejects.toBeInstanceOf(OracleEpmError)
358+
expect(mockValidateUrl).toHaveBeenCalledTimes(1)
359+
expect(mockSecureFetch).toHaveBeenCalledTimes(1)
360+
}
361+
)
362+
363+
it.each(
364+
[
365+
'https://evil.example.com/gateway/SyntheticAlpha/rest/v3/files/abc?token=x',
366+
'https://user@epm.example.com/gateway/SyntheticAlpha/rest/v3/files/abc?token=x',
367+
'https://epm.example.com/SyntheticAlpha/rest/v3/files/abc?token=x',
368+
'https://epm.example.com/gateway/SyntheticAlpha/rest/v3/files/abc?token=x&token=y',
369+
'https://epm.example.com/gateway/SyntheticAlpha/rest/v3/files/abc?unknown=x',
370+
'https://epm.example.com/gateway/SyntheticAlpha/rest/v3/files/abc?token=x#fragment',
371+
'https://epm.example.com/gateway/SyntheticAlpha/rest/v3/files/abc?token=x#',
372+
'https://epm.example.com/gateway/SyntheticAlpha/rest/v3/files/ab\nc?token=x',
373+
'https://epm.example.com/gateway/SyntheticAlpha/rest/v3/files/\uD800?token=x',
374+
'https://epm.example.com/gateway/SyntheticAlpha/rest//v3/files/abc?token=x',
375+
'https://epm.example.com/gateway/SyntheticAlpha/rest/v3/files/abc/?token=x',
376+
'https://epm.example.com/gateway/SyntheticAlpha/rest/v3/files/./abc?token=x',
377+
'https://epm.example.com/gateway/SyntheticAlpha/rest/v3/files/%2e%2e?token=x',
378+
'https://epm.example.com/gateway/SyntheticAlpha/rest/v3/files/%2e.?token=x',
379+
'https://epm.example.com/gateway/SyntheticAlpha/rest/v3/files/%252e%252e?token=x',
380+
'https://epm.example.com/gateway/SyntheticAlpha/rest/v3/files%2Fabc?token=x',
381+
'https://epm.example.com/gateway/SyntheticAlpha/rest/v3/files%5Cabc?token=x',
382+
'https://epm.example.com/gateway/SyntheticAlpha/rest/v3/files\\abc?token=x',
383+
].flatMap((href) => ['download', 'Job Status'].map((relation) => ({ relation, href })))
384+
)('rejects unsafe $relation link $href', ({ relation, href }) => {
385+
const policy = routes.defineReturnedLinkPolicy({
386+
relation,
313387
method: 'GET',
314388
version: 'v3',
315389
path: [oracleEpmLiteral('files'), oracleEpmPathParameter('fileId', { maxBytes: 32 })],
316390
query: { token: oracleEpmQuery.string({ required: true, maxBytes: 128 }) },
317-
body: 'none',
318391
response: 'stream',
319392
timeoutMs: 5_000,
320393
maxResponseBytes: 4_096,
321-
})
322-
const policy = routes.defineReturnedLinkPolicy({
323-
relation: 'download',
324-
method: 'GET',
325-
endpoint: download,
326394
preserveGatewayBasePath: true,
327395
})
328396
const client = createOracleEpmClient({
329397
instanceUrl: 'https://epm.example.com/gateway',
330-
accessToken: Buffer.from('user:password').toString('base64'),
331-
})
332-
const secret = 'signed-query-secret'
333-
const link = client.validateReturnedLink(policy, {
334-
rel: 'download',
335-
href: `https://epm.example.com/gateway/SyntheticAlpha/rest/v3/files/abc?token=${secret}`,
336-
})
337-
338-
expect(Object.isFrozen(link)).toBe(true)
339-
expect(Object.keys(link)).toEqual([])
340-
expect(JSON.stringify(link)).toBe('{}')
341-
expect(String(link)).not.toContain(secret)
342-
343-
mockSecureFetch.mockResolvedValue(secureResponse({ body: new ReadableStream() }))
344-
await client.requestValidatedLink(link)
345-
expect(mockSecureFetch).toHaveBeenCalledTimes(1)
346-
347-
const otherClient = createOracleEpmClient({
348-
instanceUrl: 'https://epm.example.com/gateway',
349-
accessToken: Buffer.from('other:password').toString('base64'),
398+
accessToken: Buffer.from('u:p').toString('base64'),
350399
})
351-
await expect(otherClient.requestValidatedLink(link)).rejects.toBeInstanceOf(OracleEpmError)
400+
expect(() => client.validateReturnedLink(policy, { rel: relation, href })).toThrow()
401+
expect(mockValidateUrl).not.toHaveBeenCalled()
402+
expect(mockSecureFetch).not.toHaveBeenCalled()
352403
})
353404

354-
it.each([
355-
'https://evil.example.com/gateway/SyntheticAlpha/rest/v3/files/abc?token=x',
356-
'https://user@epm.example.com/gateway/SyntheticAlpha/rest/v3/files/abc?token=x',
357-
'https://epm.example.com/SyntheticAlpha/rest/v3/files/abc?token=x',
358-
'https://epm.example.com/gateway/SyntheticAlpha/rest/v3/files/abc?token=x&token=y',
359-
'https://epm.example.com/gateway/SyntheticAlpha/rest/v3/files/abc?unknown=x',
360-
'https://epm.example.com/gateway/SyntheticAlpha/rest/v3/files/abc?token=x#fragment',
361-
'https://epm.example.com/gateway/SyntheticAlpha/rest/v3/files/abc?token=x#',
362-
'https://epm.example.com/gateway/SyntheticAlpha/rest/v3/files/ab\nc?token=x',
363-
'https://epm.example.com/gateway/SyntheticAlpha/rest/v3/files/\uD800?token=x',
364-
'https://epm.example.com/gateway/SyntheticAlpha/rest//v3/files/abc?token=x',
365-
'https://epm.example.com/gateway/SyntheticAlpha/rest/v3/files/abc/?token=x',
366-
'https://epm.example.com/gateway/SyntheticAlpha/rest/v3/files/./abc?token=x',
367-
'https://epm.example.com/gateway/SyntheticAlpha/rest/v3/files/%2e%2e?token=x',
368-
'https://epm.example.com/gateway/SyntheticAlpha/rest/v3/files/%2e.?token=x',
369-
'https://epm.example.com/gateway/SyntheticAlpha/rest/v3/files/%252e%252e?token=x',
370-
'https://epm.example.com/gateway/SyntheticAlpha/rest/v3/files%2Fabc?token=x',
371-
'https://epm.example.com/gateway/SyntheticAlpha/rest/v3/files%5Cabc?token=x',
372-
'https://epm.example.com/gateway/SyntheticAlpha/rest/v3/files\\abc?token=x',
373-
])('rejects unsafe returned link %j', (href) => {
405+
it.each(['download', 'Job Status'])('rejects an incorrect %s link method', (relation) => {
374406
const policy = routes.defineReturnedLinkPolicy({
375-
relation: 'download',
407+
relation,
376408
method: 'GET',
377409
version: 'v3',
378410
path: [oracleEpmLiteral('files'), oracleEpmPathParameter('fileId', { maxBytes: 32 })],
379-
query: { token: oracleEpmQuery.string({ required: true, maxBytes: 128 }) },
380411
response: 'stream',
381412
timeoutMs: 5_000,
382413
maxResponseBytes: 4_096,
@@ -386,31 +417,47 @@ describe('Oracle EPM guarded client', () => {
386417
instanceUrl: 'https://epm.example.com/gateway',
387418
accessToken: Buffer.from('u:p').toString('base64'),
388419
})
389-
expect(() => client.validateReturnedLink(policy, { rel: 'download', href })).toThrow()
420+
expect(() =>
421+
client.validateReturnedLink(policy, {
422+
rel: relation,
423+
method: 'POST',
424+
href: 'https://epm.example.com/gateway/SyntheticAlpha/rest/v3/files/abc',
425+
})
426+
).toThrow()
427+
expect(mockValidateUrl).not.toHaveBeenCalled()
428+
expect(mockSecureFetch).not.toHaveBeenCalled()
390429
})
391430

392-
it('rejects an incorrect returned-link method', () => {
431+
it.each([
432+
'job status',
433+
'Job status',
434+
' Job Status',
435+
'Job Status ',
436+
'Job Status',
437+
'Job\tStatus',
438+
'Job Status\n',
439+
'download',
440+
])('rejects nonmatching relation %j before DNS or network access', (rel) => {
393441
const policy = routes.defineReturnedLinkPolicy({
394-
relation: 'download',
442+
relation: 'Job Status',
395443
method: 'GET',
396-
version: 'v3',
397-
path: [oracleEpmLiteral('files'), oracleEpmPathParameter('fileId', { maxBytes: 32 })],
398-
response: 'stream',
399-
timeoutMs: 5_000,
400-
maxResponseBytes: 4_096,
444+
endpoint: getJob,
401445
preserveGatewayBasePath: true,
402446
})
403447
const client = createOracleEpmClient({
404448
instanceUrl: 'https://epm.example.com/gateway',
405449
accessToken: Buffer.from('u:p').toString('base64'),
406450
})
451+
407452
expect(() =>
408453
client.validateReturnedLink(policy, {
409-
rel: 'download',
410-
method: 'POST',
411-
href: 'https://epm.example.com/gateway/SyntheticAlpha/rest/v3/files/abc',
454+
rel,
455+
method: 'GET',
456+
href: 'https://epm.example.com/gateway/SyntheticAlpha/rest/v3/jobs/42',
412457
})
413-
).toThrow()
458+
).toThrow(OracleEpmError)
459+
expect(mockValidateUrl).not.toHaveBeenCalled()
460+
expect(mockSecureFetch).not.toHaveBeenCalled()
414461
})
415462

416463
it('rejects forged validated-link handles', async () => {

apps/sim/lib/internal/oracle-epm/links.test.ts

Lines changed: 82 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,27 +11,97 @@ const routes = defineOracleEpmRouteSpace({
1111
})
1212

1313
describe('Oracle EPM returned-link declarations', () => {
14-
it('binds a frozen policy to an endpoint', () => {
15-
const endpoint = routes.defineEndpoint({
14+
describe.each(['endpoint', 'route'] as const)('%s-bound policies', (binding) => {
15+
const declaration = {
1616
method: 'GET',
1717
version: 'v3',
1818
path: [oracleEpmLiteral('download')],
1919
body: 'none',
2020
response: 'stream',
2121
timeoutMs: 2_000,
2222
maxResponseBytes: 1_024,
23+
} as const
24+
const endpoint = routes.defineEndpoint(declaration)
25+
26+
function definePolicy(relation: string) {
27+
return routes.defineReturnedLinkPolicy({
28+
relation,
29+
method: 'GET',
30+
...(binding === 'endpoint'
31+
? { endpoint }
32+
: {
33+
version: declaration.version,
34+
path: declaration.path,
35+
response: declaration.response,
36+
timeoutMs: declaration.timeoutMs,
37+
maxResponseBytes: declaration.maxResponseBytes,
38+
}),
39+
preserveGatewayBasePath: true,
40+
})
41+
}
42+
43+
it.each([
44+
'a',
45+
'download',
46+
'report-content.v1_2',
47+
'Job Status',
48+
'Download link',
49+
'Report Job Status',
50+
'Job 1.v2_3-4',
51+
'a'.repeat(64),
52+
`Job ${'a'.repeat(60)}`,
53+
])('preserves relation %j in a frozen policy', (relation) => {
54+
const policy = definePolicy(relation)
55+
const definition = getOracleEpmReturnedLinkPolicy(policy)
56+
expect(Object.isFrozen(policy)).toBe(true)
57+
expect(Object.isFrozen(definition)).toBe(true)
58+
expect(definition).toMatchObject({ relation, method: 'GET', version: 'v3' })
2359
})
24-
const policy = routes.defineReturnedLinkPolicy({
25-
relation: 'download',
26-
method: 'GET',
27-
endpoint,
28-
preserveGatewayBasePath: true,
60+
61+
it.each([
62+
'',
63+
'a'.repeat(65),
64+
`Job ${'a'.repeat(61)}`,
65+
'1Job',
66+
'.Job',
67+
'_Job',
68+
'-Job',
69+
' Job Status',
70+
'Job Status ',
71+
'Job Status',
72+
'Job\tStatus',
73+
'Job\nStatus',
74+
'Job\rStatus',
75+
'download\n',
76+
'download\r',
77+
'Job Status\n',
78+
'Job Status\r\n',
79+
'Job\u0000Status',
80+
'Job\u001fStatus',
81+
'Job\u007fStatus',
82+
'Job\u00a0Status',
83+
'Job\u200bStatus',
84+
'Job Status\u2028',
85+
'Job Status\u2029',
86+
'Job/Status',
87+
'Job\\Status',
88+
'Job:Status',
89+
'Job%20Status',
90+
'Jób Status',
91+
'Job 😀',
92+
'Job\uD800',
93+
])('rejects invalid relation %j', (relation) => {
94+
expect(() => definePolicy(relation)).toThrow('Oracle EPM returned-link relation is invalid')
2995
})
30-
expect(Object.isFrozen(policy)).toBe(true)
31-
expect(getOracleEpmReturnedLinkPolicy(policy)).toMatchObject({
32-
relation: 'download',
33-
method: 'GET',
34-
version: 'v3',
96+
97+
it.each(
98+
[undefined, null, 1, true, {}, ['Job Status'], { toString: () => 'Job Status' }].map(
99+
(relation) => ({ relation })
100+
)
101+
)('rejects non-string relation $relation without coercion', ({ relation }) => {
102+
expect(() => definePolicy(relation as unknown as string)).toThrow(
103+
'Oracle EPM returned-link relation is invalid'
104+
)
35105
})
36106
})
37107

apps/sim/lib/internal/oracle-epm/links.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ import type {
1212
} from '@/lib/internal/oracle-epm/types'
1313

1414
const policies = new WeakMap<object, OracleEpmReturnedLinkPolicyDefinition>()
15-
const RELATION = /^[A-Za-z][A-Za-z0-9._-]{0,63}$/
15+
/** Single ASCII spaces separate words; the final assertion rejects trailing line breaks too. */
16+
const RELATION = /^[A-Za-z][A-Za-z0-9._-]*(?: [A-Za-z0-9._-]+)*(?![\s\S])/
1617

1718
/** Internal frozen link policy available only after runtime-brand validation. */
1819
export interface OracleEpmReturnedLinkPolicyDefinition {
@@ -32,7 +33,11 @@ export function defineOracleEpmReturnedLinkPolicy(
3233
declaration: OracleEpmReturnedLinkPolicyDeclaration
3334
): OracleEpmReturnedLinkPolicy {
3435
const route = getOracleEpmRouteSpace(routeSpace)
35-
if (!RELATION.test(declaration.relation))
36+
if (
37+
typeof declaration.relation !== 'string' ||
38+
declaration.relation.length > 64 ||
39+
!RELATION.test(declaration.relation)
40+
)
3641
throw new Error('Oracle EPM returned-link relation is invalid')
3742
if (!['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'].includes(declaration.method))
3843
throw new Error('Oracle EPM returned-link method is invalid')

0 commit comments

Comments
 (0)