Skip to content

Commit 77e26a0

Browse files
fix(oci): fail closed on encoded diagnostics
1 parent 735f734 commit 77e26a0

3 files changed

Lines changed: 242 additions & 46 deletions

File tree

apps/sim/lib/internal/oci/client.server.test.ts

Lines changed: 139 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ describe('OCI request client', () => {
181181
code: 'NotAuthenticated',
182182
opcRequestId: 'request-401',
183183
})
184-
expect((failure as Error).message).toContain('[redacted]')
184+
expect((failure as Error).message).toContain('[REDACTED]')
185185
expect((failure as Error).message).not.toContain('client-secret-passphrase')
186186
expect((failure as Error).message).not.toContain('BEGIN PRIVATE KEY')
187187
expect((failure as Error).message).not.toContain('objectstorage.us-ashburn-1')
@@ -232,12 +232,149 @@ describe('OCI request client', () => {
232232
maxResponseBytes: 65_536,
233233
}).catch((error: unknown) => error)
234234
expect(failure).toBeInstanceOf(OciRequestError)
235-
expect((failure as Error).message).toContain('[redacted]')
235+
expect((failure as Error).message).toContain('[REDACTED]')
236236
expect((failure as Error).message).not.toContain('provider-echo')
237237
expect((failure as Error).message).not.toContain('(request-target)')
238238
expect((failure as Error).message).not.toContain('tenant/user/fingerprint')
239239
})
240240

241+
it('redacts encoded credentials and request URLs echoed by the provider', async () => {
242+
const encodedFingerprint = encodeURIComponent(credentials.fingerprint)
243+
const requestUrl = `${destination.origin}/n/`
244+
const encodedRequestUrl = encodeURIComponent(requestUrl)
245+
const escapedPassphrase = 'secret "pass"'
246+
const escapedPassphraseEcho = JSON.stringify(escapedPassphrase).slice(1, -1)
247+
secureFetchMock.mockResolvedValueOnce(
248+
secureResponse({
249+
ok: false,
250+
status: 401,
251+
body: JSON.stringify({
252+
code: 'NotAuthenticated',
253+
message: `provider echoed ${encodedFingerprint} ${encodedRequestUrl} ${escapedPassphraseEcho}`,
254+
}),
255+
})
256+
)
257+
const failure = await sendOciRequest({
258+
destination,
259+
credentials: { ...credentials, passphrase: escapedPassphrase },
260+
method: 'GET',
261+
encodedPath: '/n/',
262+
timeout: 10_000,
263+
maxResponseBytes: 65_536,
264+
}).catch((error: unknown) => error)
265+
expect((failure as Error).message).not.toContain(encodedFingerprint)
266+
expect((failure as Error).message).not.toContain(encodedRequestUrl)
267+
expect((failure as Error).message).not.toContain(escapedPassphraseEcho)
268+
expect((failure as Error).message).not.toContain(escapedPassphrase)
269+
})
270+
271+
it('redacts a maximum-size passphrase before bounding an encoded diagnostic', async () => {
272+
const longPassphrase = ' '.repeat(4096)
273+
const encodedPassphrase = new URLSearchParams({ value: longPassphrase })
274+
.toString()
275+
.slice('value='.length)
276+
secureFetchMock.mockResolvedValueOnce(
277+
secureResponse({
278+
ok: false,
279+
status: 401,
280+
body: JSON.stringify({
281+
code: 'NotAuthenticated',
282+
message: `provider echoed ${encodedPassphrase}`,
283+
}),
284+
})
285+
)
286+
const failure = await sendOciRequest({
287+
destination,
288+
credentials: { ...credentials, passphrase: longPassphrase },
289+
method: 'GET',
290+
encodedPath: '/n/',
291+
timeout: 10_000,
292+
maxResponseBytes: 65_536,
293+
}).catch((error: unknown) => error)
294+
expect((failure as Error).message).toContain('[REDACTED]')
295+
expect((failure as Error).message).not.toContain('+'.repeat(1024))
296+
})
297+
298+
it.each([
299+
encodeURIComponent('-----BEGIN PRIVATE KEY-----\ntruncated'),
300+
encodeURIComponent(`${destination.origin}/n/truncated`),
301+
])('fails closed for encoded key or URL prefixes', async (message) => {
302+
secureFetchMock.mockResolvedValueOnce(
303+
secureResponse({
304+
ok: false,
305+
status: 401,
306+
body: JSON.stringify({ code: 'NotAuthenticated', message }),
307+
})
308+
)
309+
const failure = await sendOciRequest({
310+
destination,
311+
credentials,
312+
method: 'GET',
313+
encodedPath: '/n/',
314+
timeout: 10_000,
315+
maxResponseBytes: 65_536,
316+
}).catch((error: unknown) => error)
317+
expect((failure as Error).message).toBe('OCI request failed with status 401')
318+
})
319+
320+
it('redacts generic and percent-encoded sensitive JSON fields', async () => {
321+
const echoedSecrets = [
322+
'access-value',
323+
'token-value',
324+
'secret-value',
325+
'password-value',
326+
'(request-target) host x-date',
327+
]
328+
secureFetchMock.mockResolvedValueOnce(
329+
secureResponse({
330+
ok: false,
331+
status: 401,
332+
body: JSON.stringify({
333+
code: 'NotAuthenticated',
334+
message: JSON.stringify({
335+
access_token: echoedSecrets[0],
336+
token: echoedSecrets[1],
337+
secret: echoedSecrets[2],
338+
'pass%70hrase': echoedSecrets[3],
339+
signing_string: echoedSecrets[4],
340+
}),
341+
}),
342+
})
343+
)
344+
const failure = await sendOciRequest({
345+
destination,
346+
credentials,
347+
method: 'GET',
348+
encodedPath: '/n/',
349+
timeout: 10_000,
350+
maxResponseBytes: 65_536,
351+
}).catch((error: unknown) => error)
352+
for (const secret of echoedSecrets) expect((failure as Error).message).not.toContain(secret)
353+
})
354+
355+
it.each([
356+
'{"authorization":"Signature version=\\"1\\",signature=\\"echoed\\"',
357+
JSON.stringify({ level1: { level2: { level3: { authorization: 'echoed' } } } }),
358+
JSON.stringify({ 'pass%25252570hrase': 'echoed' }),
359+
])('fails closed for malformed or over-depth structured diagnostics', async (message) => {
360+
secureFetchMock.mockResolvedValueOnce(
361+
secureResponse({
362+
ok: false,
363+
status: 401,
364+
body: JSON.stringify({ code: 'NotAuthenticated', message }),
365+
})
366+
)
367+
const failure = await sendOciRequest({
368+
destination,
369+
credentials,
370+
method: 'GET',
371+
encodedPath: '/n/',
372+
timeout: 10_000,
373+
maxResponseBytes: 65_536,
374+
}).catch((error: unknown) => error)
375+
expect((failure as Error).message).toBe('OCI request failed with status 401')
376+
})
377+
241378
it.each([
242379
'//attacker.example/path',
243380
'/safe//attacker',

apps/sim/lib/internal/oci/client.server.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,8 @@ function validateRequestLimits(timeout: number, maxResponseBytes: number): void
6262

6363
function sensitiveRequestValues(
6464
credentials: OciSigningCredentials,
65-
authorization: string | undefined
65+
authorization: string | undefined,
66+
requestUrl: string
6667
): string[] {
6768
return [
6869
credentials.tenancyId,
@@ -71,6 +72,7 @@ function sensitiveRequestValues(
7172
credentials.privateKey,
7273
credentials.passphrase ?? '',
7374
authorization ?? '',
75+
requestUrl,
7476
].filter(Boolean)
7577
}
7678

@@ -116,7 +118,11 @@ export async function sendOciRequest(params: {
116118
const opcRequestId = response.headers.get('opc-request-id') ?? undefined
117119
if (response.ok) return { response, opcRequestId }
118120

119-
const sensitiveValues = sensitiveRequestValues(params.credentials, signed.headers.authorization)
121+
const sensitiveValues = sensitiveRequestValues(
122+
params.credentials,
123+
signed.headers.authorization,
124+
signed.url
125+
)
120126
const body = await response.text()
121127
const error = parseOciErrorBody(body, sensitiveValues)
122128
throw new OciRequestError({

apps/sim/lib/internal/oci/errors.ts

Lines changed: 95 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,65 +1,118 @@
1+
import {
2+
isSensitiveKey,
3+
REDACTED_MARKER,
4+
redactExactSensitiveValues,
5+
} from '@/lib/core/security/redaction'
6+
17
const MAX_OCI_ERROR_FIELD_LENGTH = 1024
2-
const MAX_OCI_ERROR_INPUT_LENGTH = 8192
8+
const MAX_OCI_ERROR_INPUT_LENGTH = 65_536
39
const MAX_NESTED_JSON_DEPTH = 3
4-
const SENSITIVE_JSON_FIELDS = new Set([
5-
'authorization',
6-
'passphrase',
7-
'privatekey',
8-
'proxyauthorization',
9-
'signingstring',
10-
])
10+
const OCI_SENSITIVE_JSON_FIELDS = new Set(['signingstring'])
11+
const ENCODED_DIAGNOSTIC_SENTINELS = ['-----BEGIN', 'https://']
12+
13+
function normalizeJsonDiagnosticKey(key: string): string | undefined {
14+
let normalized = key
15+
for (let depth = 0; depth < MAX_NESTED_JSON_DEPTH; depth += 1) {
16+
if (!normalized.includes('%')) return normalized
17+
if (!/%[0-9a-f]{2}/i.test(normalized)) return undefined
18+
try {
19+
normalized = decodeURIComponent(normalized)
20+
} catch {
21+
return undefined
22+
}
23+
}
24+
return normalized.includes('%') ? undefined : normalized
25+
}
26+
27+
function looksLikeStructuredJson(value: string): boolean {
28+
const first = value.trimStart()[0]
29+
return first === '{' || first === '[' || first === '"'
30+
}
31+
32+
function isSensitiveOciJsonKey(key: string): boolean {
33+
const compactKey = key.replace(/[^a-z]/gi, '').toLowerCase()
34+
return OCI_SENSITIVE_JSON_FIELDS.has(compactKey) || isSensitiveKey(key)
35+
}
36+
37+
function containsEncodedDiagnosticSentinel(value: string): boolean {
38+
const lowerValue = value.toLowerCase()
39+
return ENCODED_DIAGNOSTIC_SENTINELS.some((sentinel) => {
40+
let encoded = sentinel
41+
for (let depth = 0; depth < MAX_NESTED_JSON_DEPTH; depth += 1) {
42+
encoded = encodeURIComponent(encoded)
43+
if (encoded !== sentinel && lowerValue.includes(encoded.toLowerCase())) return true
44+
}
45+
return false
46+
})
47+
}
1148

1249
function flattenJsonDiagnostic(value: unknown, depth = 0): string | undefined {
13-
if (depth > MAX_NESTED_JSON_DEPTH || value === null) return undefined
14-
if (typeof value === 'string') return value
50+
if (depth > MAX_NESTED_JSON_DEPTH) return undefined
51+
if (value === null) return 'null'
52+
if (typeof value === 'string') {
53+
if (!looksLikeStructuredJson(value)) return value
54+
if (depth === MAX_NESTED_JSON_DEPTH) return undefined
55+
try {
56+
return flattenJsonDiagnostic(JSON.parse(value), depth + 1)
57+
} catch {
58+
return undefined
59+
}
60+
}
1561
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
1662
if (Array.isArray(value)) {
17-
return value
18-
.map((entry) => flattenJsonDiagnostic(entry, depth + 1))
19-
.filter((entry): entry is string => entry !== undefined)
20-
.join(' ')
63+
if (depth === MAX_NESTED_JSON_DEPTH) return undefined
64+
const flattened = value.map((entry) => flattenJsonDiagnostic(entry, depth + 1))
65+
if (flattened.some((entry) => entry === undefined)) return undefined
66+
return flattened.join(' ')
2167
}
2268
if (typeof value !== 'object') return undefined
23-
return Object.entries(value)
24-
.map(([key, entry]) => {
25-
const normalizedKey = key.replace(/[^a-z]/gi, '').toLowerCase()
26-
if (SENSITIVE_JSON_FIELDS.has(normalizedKey)) return `${key}: [redacted]`
27-
const flattened = flattenJsonDiagnostic(entry, depth + 1)
28-
return flattened === undefined ? undefined : `${key}: ${flattened}`
29-
})
30-
.filter((entry): entry is string => entry !== undefined)
31-
.join(' ')
69+
if (depth === MAX_NESTED_JSON_DEPTH) return undefined
70+
const flattened = Object.entries(value).map(([key, entry]) => {
71+
const normalizedKey = normalizeJsonDiagnosticKey(key)
72+
if (normalizedKey === undefined) return undefined
73+
if (isSensitiveOciJsonKey(normalizedKey)) return `${key}: ${REDACTED_MARKER}`
74+
const nested = flattenJsonDiagnostic(entry, depth + 1)
75+
return nested === undefined ? undefined : `${key}: ${nested}`
76+
})
77+
if (flattened.some((entry) => entry === undefined)) return undefined
78+
return flattened.join(' ')
3279
}
3380

34-
function decodeNestedJsonDiagnostic(value: string): string {
35-
let decoded = value.slice(0, MAX_OCI_ERROR_INPUT_LENGTH)
36-
for (let depth = 0; depth < MAX_NESTED_JSON_DEPTH; depth += 1) {
37-
let parsed: unknown
38-
try {
39-
parsed = JSON.parse(decoded)
40-
} catch {
41-
break
42-
}
43-
const flattened = flattenJsonDiagnostic(parsed)
44-
if (flattened === undefined || flattened === decoded) break
45-
decoded = flattened.slice(0, MAX_OCI_ERROR_INPUT_LENGTH)
81+
function decodeNestedJsonDiagnostic(value: string): string | undefined {
82+
if (value.length > MAX_OCI_ERROR_INPUT_LENGTH) return undefined
83+
if (!looksLikeStructuredJson(value)) return value
84+
try {
85+
return flattenJsonDiagnostic(JSON.parse(value))
86+
} catch {
87+
return undefined
4688
}
47-
return decoded
4889
}
4990

5091
function sanitizeOciErrorField(
5192
value: unknown,
5293
sensitiveValues: readonly string[] = []
5394
): string | undefined {
5495
if (typeof value !== 'string') return undefined
55-
let sanitized = decodeNestedJsonDiagnostic(value)
96+
if (value.length > MAX_OCI_ERROR_INPUT_LENGTH) return undefined
97+
if (containsEncodedDiagnosticSentinel(value)) return undefined
98+
const decoded = decodeNestedJsonDiagnostic(value)
99+
if (decoded === undefined) return undefined
100+
const exactValues = sensitiveValues.flatMap((sensitiveValue) => {
101+
const jsonEncoded = JSON.stringify(sensitiveValue).slice(1, -1)
102+
return jsonEncoded === sensitiveValue ? [sensitiveValue] : [sensitiveValue, jsonEncoded]
103+
})
104+
let exactRedacted: string
105+
try {
106+
exactRedacted = redactExactSensitiveValues(decoded, exactValues)
107+
} catch {
108+
return undefined
109+
}
110+
const sanitized = exactRedacted
56111
.replace(/-----BEGIN[\s\S]*/gi, '[redacted-key]')
57112
.replace(/https?:\/\/[^\s"']+/gi, '[redacted-url]')
58-
.replace(/Signature\s+version="1",[^\r\n]*/gi, '[redacted-authorization]')
59-
for (const sensitiveValue of sensitiveValues) {
60-
if (sensitiveValue.length > 0) sanitized = sanitized.split(sensitiveValue).join('[redacted]')
61-
}
62-
sanitized = sanitized.replace(/[\u0000-\u001f\u007f]/g, ' ').trim()
113+
.replace(/Signature\s+version=\\*"1\\*",[^\r\n]*/gi, '[redacted-authorization]')
114+
.replace(/[\u0000-\u001f\u007f]/g, ' ')
115+
.trim()
63116
return sanitized ? sanitized.slice(0, MAX_OCI_ERROR_FIELD_LENGTH) : undefined
64117
}
65118

0 commit comments

Comments
 (0)