Skip to content

Commit d2d066d

Browse files
fix(oci): harden endpoint and error validation
1 parent 3724ec5 commit d2d066d

4 files changed

Lines changed: 96 additions & 4 deletions

File tree

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,34 @@ describe('OCI request client', () => {
210210
expect((failure as OciRequestError).opcRequestId).toBe('request-502')
211211
})
212212

213+
it('redacts authorization material embedded in a serialized JSON message', async () => {
214+
const echoedAuthorization =
215+
'Signature version="1",keyId="tenant/user/fingerprint",headers="(request-target) host x-date",signature="provider-echo"'
216+
secureFetchMock.mockResolvedValueOnce(
217+
secureResponse({
218+
ok: false,
219+
status: 401,
220+
body: JSON.stringify({
221+
code: 'NotAuthenticated',
222+
message: JSON.stringify({ authorization: echoedAuthorization }),
223+
}),
224+
})
225+
)
226+
const failure = await sendOciRequest({
227+
destination,
228+
credentials,
229+
method: 'GET',
230+
encodedPath: '/n/',
231+
timeout: 10_000,
232+
maxResponseBytes: 65_536,
233+
}).catch((error: unknown) => error)
234+
expect(failure).toBeInstanceOf(OciRequestError)
235+
expect((failure as Error).message).toContain('[redacted]')
236+
expect((failure as Error).message).not.toContain('provider-echo')
237+
expect((failure as Error).message).not.toContain('(request-target)')
238+
expect((failure as Error).message).not.toContain('tenant/user/fingerprint')
239+
})
240+
213241
it.each([
214242
'//attacker.example/path',
215243
'/safe//attacker',

apps/sim/lib/internal/oci/endpoints.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
getOciRegion,
77
isObjectStorageOciHostname,
88
OCI_REGION_IDS,
9+
type OciServiceHostnamePredicate,
910
objectStorageOciDestination,
1011
objectStorageOciHostname,
1112
resolveEffectiveOciRegion,
@@ -27,6 +28,7 @@ describe('OCI region registry', () => {
2728
it('normalizes known regions and fails closed for unknown regions', () => {
2829
expect(getOciRegion(' US-ASHBURN-1 ').id).toBe('us-ashburn-1')
2930
expect(() => getOciRegion('moon-base-1')).toThrow('not recognized')
31+
expect(() => getOciRegion('constructor')).toThrow('not recognized')
3032
})
3133

3234
it('allows only same-realm effective-region overrides', () => {
@@ -105,6 +107,19 @@ describe('validateOciDestination', () => {
105107
).toThrow('not owned')
106108
})
107109

110+
it('rejects a bracketed IPv6 literal before applying the service predicate', () => {
111+
const acceptsEveryHostname = (() => true) as OciServiceHostnamePredicate
112+
expect(() =>
113+
validateOciDestination({
114+
origin: 'https://[2606:4700::1111]',
115+
service: 'objectstorage',
116+
region,
117+
provenance: 'static',
118+
isServiceHostname: acceptsEveryHostname,
119+
})
120+
).toThrow('exact HTTPS origin')
121+
})
122+
108123
it('rejects a forged region-to-realm association', () => {
109124
expect(() =>
110125
validateOciDestination({

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { isIpLiteral } from '@sim/security/ssrf'
1+
import { isIpLiteral, unwrapIpv6Brackets } from '@sim/security/ssrf'
22

33
export type OciDestinationProvenance = 'static' | 'authenticated-discovery'
44

@@ -160,7 +160,9 @@ function normalizeRegionId(regionId: string): string {
160160

161161
export function getOciRegion(regionId: string): OciRegion {
162162
const normalized = normalizeRegionId(regionId)
163-
const realmId = REGION_REALMS[normalized as keyof typeof REGION_REALMS]
163+
const realmId = Object.hasOwn(REGION_REALMS, normalized)
164+
? REGION_REALMS[normalized as keyof typeof REGION_REALMS]
165+
: undefined
164166
if (!realmId) throw new Error('OCI region is not recognized')
165167
return {
166168
id: normalized,
@@ -211,7 +213,7 @@ export function validateOciDestination(params: {
211213
url.pathname !== '/' ||
212214
url.search !== '' ||
213215
url.hash !== '' ||
214-
isIpLiteral(url.hostname) ||
216+
isIpLiteral(unwrapIpv6Brackets(url.hostname)) ||
215217
url.origin !== params.origin
216218
) {
217219
throw new Error('OCI destination must be an exact HTTPS origin with the default port')

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

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,58 @@
11
const MAX_OCI_ERROR_FIELD_LENGTH = 1024
2+
const MAX_OCI_ERROR_INPUT_LENGTH = 8192
3+
const MAX_NESTED_JSON_DEPTH = 3
4+
const SENSITIVE_JSON_FIELDS = new Set([
5+
'authorization',
6+
'passphrase',
7+
'privatekey',
8+
'proxyauthorization',
9+
'signingstring',
10+
])
11+
12+
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
15+
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
16+
if (Array.isArray(value)) {
17+
return value
18+
.map((entry) => flattenJsonDiagnostic(entry, depth + 1))
19+
.filter((entry): entry is string => entry !== undefined)
20+
.join(' ')
21+
}
22+
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(' ')
32+
}
33+
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)
46+
}
47+
return decoded
48+
}
249

350
function sanitizeOciErrorField(
451
value: unknown,
552
sensitiveValues: readonly string[] = []
653
): string | undefined {
754
if (typeof value !== 'string') return undefined
8-
let sanitized = value
55+
let sanitized = decodeNestedJsonDiagnostic(value)
956
.replace(/-----BEGIN[\s\S]*/gi, '[redacted-key]')
1057
.replace(/https?:\/\/[^\s"']+/gi, '[redacted-url]')
1158
.replace(/Signature\s+version="1",[^\r\n]*/gi, '[redacted-authorization]')

0 commit comments

Comments
 (0)