Skip to content

Commit 68df997

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
fix(oracle-epm): validate typed returned-link queries
1 parent fbb5a4e commit 68df997

2 files changed

Lines changed: 101 additions & 1 deletion

File tree

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

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,96 @@ describe('Oracle EPM guarded client', () => {
460460
}
461461
)
462462

463+
describe.each(['endpoint', 'route'] as const)(
464+
'%s-bound typed returned-link queries',
465+
(binding) => {
466+
const declaration = {
467+
method: 'GET',
468+
version: 'v3',
469+
path: [oracleEpmLiteral('jobs')],
470+
query: {
471+
offset: oracleEpmQuery.integer({ required: true, minimum: -10, maximum: 10 }),
472+
include: oracleEpmQuery.boolean({ required: true }),
473+
token: oracleEpmQuery.string({ required: true, maxBytes: 32 }),
474+
},
475+
body: 'none',
476+
response: 'json',
477+
timeoutMs: 5_000,
478+
maxResponseBytes: 4_096,
479+
} as const
480+
const endpoint = routes.defineEndpoint(declaration)
481+
const policy = routes.defineReturnedLinkPolicy({
482+
...(binding === 'endpoint' ? { endpoint, method: 'GET' as const } : declaration),
483+
relation: 'next',
484+
preserveGatewayBasePath: true,
485+
})
486+
const client = createOracleEpmClient({
487+
instanceUrl: 'https://epm.example.com/gateway',
488+
accessToken: Buffer.from('u:p').toString('base64'),
489+
})
490+
const prefix = 'https://epm.example.com/gateway/SyntheticAlpha/rest/v3/jobs'
491+
492+
it.each(['offset=-10&include=false', 'offset=0&include=false', 'offset=10&include=true'])(
493+
'accepts canonical typed query %s without rewriting the returned URL',
494+
async (query) => {
495+
const href = `${prefix}?${query}&token=signed%2Bquery%2Fsecret`
496+
const handle = client.validateReturnedLink(policy, { rel: 'next', href })
497+
expect(JSON.stringify(handle)).toBe('{}')
498+
await client.requestValidatedLink(handle)
499+
expect(mockSecureFetch.mock.calls[0][0]).toBe(href)
500+
}
501+
)
502+
503+
it.each([
504+
'offset=-11&include=true',
505+
'offset=11&include=true',
506+
'offset=9007199254740992&include=true',
507+
'offset=1.5&include=true',
508+
'offset=1e0&include=true',
509+
'offset=0x1&include=true',
510+
'offset=01&include=true',
511+
'offset=-0&include=true',
512+
'offset=%201&include=true',
513+
'offset=%2B1&include=true',
514+
'offset=&include=true',
515+
'offset=NaN&include=true',
516+
'offset=Infinity&include=true',
517+
'offset=1&include=True',
518+
'offset=1&include=FALSE',
519+
'offset=1&include=1',
520+
'offset=1&include=0',
521+
'offset=1&include=',
522+
'offset=1',
523+
'include=true',
524+
'offset=1&offset=2&include=true',
525+
'offset=1&include=true&include=false',
526+
'offset=1&include=true&unknown=value',
527+
])('rejects invalid typed query %s before DNS or fetch', (query) => {
528+
expect(() =>
529+
client.validateReturnedLink(policy, {
530+
rel: 'next',
531+
href: `${prefix}?${query}&token=signed-query-secret`,
532+
})
533+
).toThrowError(
534+
expect.objectContaining({ name: 'OracleEpmError', category: 'invalid_input' })
535+
)
536+
expect(mockValidateUrl).not.toHaveBeenCalled()
537+
expect(mockSecureFetch).not.toHaveBeenCalled()
538+
})
539+
540+
it.each([
541+
{ offset: '1', include: true },
542+
{ offset: 1, include: 'true' },
543+
])('keeps direct request query types strict for %j', async (query) => {
544+
await expect(
545+
client.request(endpoint, { query: { ...query, token: 'signed-query-secret' } })
546+
).rejects.toMatchObject({ category: 'invalid_input' })
547+
expect(mockValidateUrl).not.toHaveBeenCalled()
548+
expect(mockSecureFetch).not.toHaveBeenCalled()
549+
})
550+
}
551+
)
552+
463553
it.each(['download', 'Job Status'])(
464554
'keeps %s links opaque and client-owned',
465555
async (relation) => {

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,16 @@ function serializeQueryValue(value: unknown, declaration: OracleEpmQueryParamete
132132
return String(value)
133133
}
134134

135+
/** Accepts only the canonical wire form of each declared type without rewriting the URL. */
136+
function validateReturnedQueryValue(value: string, declaration: OracleEpmQueryParameter): void {
137+
let parsed: string | number | boolean = value
138+
if (declaration.kind === 'integer') parsed = Number(value)
139+
else if (declaration.kind === 'boolean') parsed = value === 'true'
140+
if (serializeQueryValue(parsed, declaration) !== value) {
141+
throw oracleEpmLocalError('invalid_input')
142+
}
143+
}
144+
135145
function buildQuery(
136146
declarations: Readonly<Record<string, OracleEpmQueryParameter>>,
137147
values: Readonly<Record<string, string | number | boolean | undefined>> | undefined
@@ -589,7 +599,7 @@ export function createOracleEpmClient(input: {
589599
if (seen.has(name) || !Object.hasOwn(policy.query, name))
590600
throw oracleEpmLocalError('invalid_input')
591601
seen.add(name)
592-
serializeQueryValue(value, policy.query[name])
602+
validateReturnedQueryValue(value, policy.query[name])
593603
}
594604
for (const [name, declaration] of Object.entries(policy.query)) {
595605
if (declaration.required && !seen.has(name)) throw oracleEpmLocalError('invalid_input')

0 commit comments

Comments
 (0)