Skip to content

Commit 5e32a64

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
fix(oracle-epm): validate raw returned-link paths
1 parent c000b7b commit 5e32a64

2 files changed

Lines changed: 57 additions & 16 deletions

File tree

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,8 +355,18 @@ describe('Oracle EPM guarded client', () => {
355355
'https://epm.example.com/gateway/SyntheticAlpha/rest/v3/files/abc?token=x&token=y',
356356
'https://epm.example.com/gateway/SyntheticAlpha/rest/v3/files/abc?unknown=x',
357357
'https://epm.example.com/gateway/SyntheticAlpha/rest/v3/files/abc?token=x#fragment',
358+
'https://epm.example.com/gateway/SyntheticAlpha/rest/v3/files/abc?token=x#',
358359
'https://epm.example.com/gateway/SyntheticAlpha/rest/v3/files/ab\nc?token=x',
359360
'https://epm.example.com/gateway/SyntheticAlpha/rest/v3/files/\uD800?token=x',
361+
'https://epm.example.com/gateway/SyntheticAlpha/rest//v3/files/abc?token=x',
362+
'https://epm.example.com/gateway/SyntheticAlpha/rest/v3/files/abc/?token=x',
363+
'https://epm.example.com/gateway/SyntheticAlpha/rest/v3/files/./abc?token=x',
364+
'https://epm.example.com/gateway/SyntheticAlpha/rest/v3/files/%2e%2e?token=x',
365+
'https://epm.example.com/gateway/SyntheticAlpha/rest/v3/files/%2e.?token=x',
366+
'https://epm.example.com/gateway/SyntheticAlpha/rest/v3/files/%252e%252e?token=x',
367+
'https://epm.example.com/gateway/SyntheticAlpha/rest/v3/files%2Fabc?token=x',
368+
'https://epm.example.com/gateway/SyntheticAlpha/rest/v3/files%5Cabc?token=x',
369+
'https://epm.example.com/gateway/SyntheticAlpha/rest/v3/files\\abc?token=x',
360370
])('rejects unsafe returned link %j', (href) => {
361371
const policy = routes.defineReturnedLinkPolicy({
362372
relation: 'download',

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

Lines changed: 47 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -312,15 +312,52 @@ async function projectResponse(
312312
}
313313
}
314314

315-
function matchReturnedPath(candidate: string[], expected: readonly OracleEpmPathPart[]): void {
316-
if (candidate.length !== expected.length) throw oracleEpmLocalError('invalid_input')
317-
for (let index = 0; index < expected.length; index += 1) {
318-
let decoded: string
315+
function decodeReturnedPathSegment(encoded: string): string {
316+
let decoded: string
317+
try {
318+
decoded = decodeURIComponent(encoded)
319+
} catch {
320+
throw oracleEpmLocalError('invalid_input')
321+
}
322+
let safetyValue = decoded
323+
for (let depth = 0; depth < 4 && /%[0-9A-Fa-f]{2}/.test(safetyValue); depth += 1) {
319324
try {
320-
decoded = decodeURIComponent(candidate[index])
325+
safetyValue = decodeURIComponent(safetyValue)
321326
} catch {
322327
throw oracleEpmLocalError('invalid_input')
323328
}
329+
}
330+
if (
331+
!decoded ||
332+
/%[0-9A-Fa-f]{2}/.test(safetyValue) ||
333+
safetyValue === '.' ||
334+
safetyValue === '..' ||
335+
/[/\\\u0000-\u001f\u007f]/.test(safetyValue) ||
336+
MALFORMED_UTF16.test(decoded)
337+
) {
338+
throw oracleEpmLocalError('invalid_input')
339+
}
340+
return decoded
341+
}
342+
343+
function rawReturnedPathSegments(href: string): string[] {
344+
const match = /^https:\/\/[^/?#]*(\/[^?#]*)?(?:\?[^#]*)?(?:#.*)?$/i.exec(href)
345+
if (!match) throw oracleEpmLocalError('invalid_input')
346+
const rawPath = match[1] ?? ''
347+
if (rawPath.includes('\\')) throw oracleEpmLocalError('invalid_input')
348+
if (!rawPath) return []
349+
const segments = rawPath.slice(1).split('/')
350+
if (segments.some((segment) => !segment)) throw oracleEpmLocalError('invalid_input')
351+
return segments.map(decodeReturnedPathSegment)
352+
}
353+
354+
function matchReturnedPath(
355+
candidate: readonly string[],
356+
expected: readonly OracleEpmPathPart[]
357+
): void {
358+
if (candidate.length !== expected.length) throw oracleEpmLocalError('invalid_input')
359+
for (let index = 0; index < expected.length; index += 1) {
360+
const decoded = candidate[index]
324361
const part = expected[index]
325362
if (part.kind === 'literal') {
326363
if (decoded !== part.value) throw oracleEpmLocalError('invalid_input')
@@ -468,9 +505,11 @@ export function createOracleEpmClient(input: {
468505
typeof link.href !== 'string' ||
469506
link.href.length > 8_192 ||
470507
FORBIDDEN_LINK_TEXT.test(link.href) ||
471-
MALFORMED_UTF16.test(link.href)
508+
MALFORMED_UTF16.test(link.href) ||
509+
link.href.includes('#')
472510
)
473511
throw oracleEpmLocalError('invalid_input')
512+
const candidate = rawReturnedPathSegments(link.href)
474513
let url: URL
475514
try {
476515
url = new URL(link.href)
@@ -480,17 +519,9 @@ export function createOracleEpmClient(input: {
480519
if (url.origin !== destinationData.origin || url.username || url.password || url.hash)
481520
throw oracleEpmLocalError('invalid_input')
482521
const route = getOracleEpmRouteSpace(policy.routeSpace)
483-
const candidate = url.pathname.split('/').filter(Boolean)
484522
const prefix = [...destinationData.baseSegments, ...route.context, policy.version]
485-
const prefixMatches = (expected: readonly string[]): boolean => {
486-
try {
487-
return expected.every(
488-
(part, index) => decodeURIComponent(candidate[index] ?? '') === part
489-
)
490-
} catch {
491-
return false
492-
}
493-
}
523+
const prefixMatches = (expected: readonly string[]): boolean =>
524+
expected.every((part, index) => candidate[index] === part)
494525
if (policy.preserveGatewayBasePath && !prefixMatches(prefix))
495526
throw oracleEpmLocalError('invalid_input')
496527
const pathStart = policy.preserveGatewayBasePath ? prefix.length : route.context.length + 1

0 commit comments

Comments
 (0)