Skip to content

Commit 0cbd1c8

Browse files
committed
fix(elasticsearch): reject URL-special characters in decoded Cloud ID parts
The base64-alphabet check validated the encoded payload, not the decoded components, so a Cloud ID could inject into the assembled origin. Elastic's own decoder rejects #@?/ in the host and each UUID, with fixtures named inject-es, inject-kb and inject-host. An '@' is the serious one: it turns the UUID into userinfo and hands the origin to the attacker, so the ApiKey header is sent to their host. The other three truncate the authority to a bare label. Guarded on the decoded component before assembly rather than on the finished string, since checking the assembled URL means re-parsing the parse being subverted. ':' is rejected too, beyond Elastic's set: the port split takes only the last colon, so a second one produces a two-colon authority and a bare Invalid URL -- the same unactionable failure the port fix removed. Both are no-ops on a real hostname and a hex UUID. The Kibana UUID is deliberately not checked: we only ever build the Elasticsearch origin, so rejecting on a component that never reaches our URL would refuse a Cloud ID that works fine for search.
1 parent 55c2c2c commit 0cbd1c8

2 files changed

Lines changed: 187 additions & 0 deletions

File tree

apps/sim/tools/elasticsearch/utils.test.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,3 +328,131 @@ describe('safeIndexPathSegment', () => {
328328
expect(safeIndexPathSegment('logs-*', 'index')).toBe('logs-*')
329329
})
330330
})
331+
332+
/**
333+
* The decoded payloads of Elastic's `inject-es`, `inject-host`, and `inject-kb`
334+
* `TestDecodeError` fixtures, re-encoded under an ordinary label.
335+
*
336+
* Elastic writes those fixtures with a literal `#:` marker before the base64
337+
* ("inject-es:#:<payload>"). Elastic's decoder splits the label at the LAST
338+
* colon so the marker is discarded, while ours splits at the FIRST — a
339+
* deliberate difference pinned by the "takes the payload as everything after
340+
* the FIRST colon" test above, which the base64-alphabet check then rejects.
341+
* Carrying the marker through would make these tests pass on the wrong error,
342+
* so only the decoded payload is reused.
343+
*/
344+
const INJECTION_FIXTURES = {
345+
injectEs: cloudId(
346+
'inject-es',
347+
'us-east-1.aws.found.io$cec6f261#attacker.com$c6c2ca6d042249af0cc7d7a9e9625743'
348+
),
349+
injectHost: cloudId(
350+
'inject-host',
351+
'us-east-1.aws.found.io#attacker.com$cec6f261a74bf24ce33bb8811b84294f$c6c2ca6d042249af0cc7d7a9e9625743'
352+
),
353+
injectKb: cloudId('inject-kb', `us-east-1.aws.found.io$${ES_UUID}$c6c2ca6d#attacker.com`),
354+
} as const
355+
356+
describe('parseCloudId rejects URL-special characters in decoded components', () => {
357+
it("rejects a '#' in the Elasticsearch UUID (Elastic's inject-es fixture)", () => {
358+
expect(() => parseCloudId(INJECTION_FIXTURES.injectEs)).toThrow(/invalid character/)
359+
})
360+
361+
it("rejects a '#' in the parent domain (Elastic's inject-host fixture)", () => {
362+
expect(() => parseCloudId(INJECTION_FIXTURES.injectHost)).toThrow(/invalid character/)
363+
})
364+
365+
it.each([
366+
['#', 'truncates the authority into a fragment'],
367+
['@', 'turns the UUID into userinfo and hands the origin to the rest'],
368+
['?', 'truncates the authority into a query'],
369+
['/', 'truncates the authority and turns the rest into a path'],
370+
])('rejects %j in the Elasticsearch UUID, which otherwise %s', (char) => {
371+
const id = cloudId('evil', `${PARENT_DN}$${ES_UUID}${char}attacker.com$${KIBANA_UUID}`)
372+
373+
expect(() => parseCloudId(id)).toThrow(/invalid character/)
374+
})
375+
376+
it.each(['#', '@', '?', '/'])('rejects %j in the parent domain', (char) => {
377+
const id = cloudId('evil', `${PARENT_DN}${char}attacker.com$${ES_UUID}$${KIBANA_UUID}`)
378+
379+
expect(() => parseCloudId(id)).toThrow(/invalid character/)
380+
})
381+
382+
/**
383+
* A colon needs a companion to survive: `splitCloudIdPort` right-partitions
384+
* the last one as a port, so a single trailing colon is already caught by
385+
* the port guard (`:attacker.com` parses to NaN). Two colons leave one
386+
* behind in the component, which assembles an authority the URL parser
387+
* rejects outright — the case the reject set has to catch.
388+
*/
389+
it('rejects a colon left in the Elasticsearch UUID after the port is split off', () => {
390+
const id = cloudId('evil', `${PARENT_DN}$${ES_UUID}:attacker.com:9243$${KIBANA_UUID}`)
391+
392+
expect(() => parseCloudId(id)).toThrow(/invalid character/)
393+
})
394+
395+
it('rejects a colon left in the parent domain after the port is split off', () => {
396+
const id = cloudId('evil', `${PARENT_DN}:attacker.com:9243$${ES_UUID}$${KIBANA_UUID}`)
397+
398+
expect(() => parseCloudId(id)).toThrow(/invalid character/)
399+
})
400+
401+
it('still catches a single trailing colon through the port guard', () => {
402+
const id = cloudId('evil', `${PARENT_DN}$${ES_UUID}:attacker.com$${KIBANA_UUID}`)
403+
404+
expect(() => parseCloudId(id)).toThrow(/invalid host or port/)
405+
})
406+
407+
it('names the offending component and character in the error', () => {
408+
const id = cloudId('evil', `${PARENT_DN}$${ES_UUID}@attacker.com$${KIBANA_UUID}`)
409+
410+
expect(() => parseCloudId(id)).toThrow(/"@"/)
411+
expect(() => parseCloudId(id)).toThrow(new RegExp(ES_UUID))
412+
})
413+
414+
it('checks the decoded component, not the assembled URL', () => {
415+
/**
416+
* `<es-uuid>@attacker.com` assembles into
417+
* `https://<es-uuid>@attacker.com.us-east-1.aws.found.io`, whose origin is
418+
* `attacker.com.us-east-1.aws.found.io` — a different host, reached with
419+
* the caller's ApiKey or Basic credential attached. An assembled-string
420+
* check would have to re-parse that to notice.
421+
*/
422+
const id = cloudId('evil', `${PARENT_DN}$${ES_UUID}@attacker.com$${KIBANA_UUID}`)
423+
424+
expect(() => parseCloudId(id)).toThrow()
425+
})
426+
427+
it("accepts a '#' in the Kibana UUID, which never reaches an Elasticsearch URL", () => {
428+
/**
429+
* A deliberate divergence from Elastic's decoder, which validates the
430+
* Kibana component because it also builds a Kibana URL. We only ever build
431+
* the Elasticsearch origin from words[0] and words[1], so rejecting on
432+
* words[2] would refuse a Cloud ID that is perfectly usable for search.
433+
*/
434+
expect(parseCloudId(INJECTION_FIXTURES.injectKb)).toBe(`https://${ES_UUID}.${PARENT_DN}`)
435+
})
436+
437+
it('is a no-op on every legitimate fixture, including the ported ones', () => {
438+
expect(parseCloudId(ELASTIC_FIXTURES.customPort)).toBe(
439+
`https://${GCP_ES_UUID}.${GCP_PARENT_DN}:9243`
440+
)
441+
expect(parseCloudId(ELASTIC_FIXTURES.differentEsKbPort)).toBe(
442+
`https://${GCP_ES_UUID}.${GCP_PARENT_DN}:9243`
443+
)
444+
expect(parseCloudId(ELASTIC_FIXTURES.hostAndKbSet)).toBe(
445+
`https://${GCP_ES_UUID}.${GCP_PARENT_DN}:9243`
446+
)
447+
expect(parseCloudId(ELASTIC_FIXTURES.onlyKbSet)).toBe(`https://${GCP_ES_UUID}.${GCP_PARENT_DN}`)
448+
expect(parseCloudId(ELASTIC_FIXTURES.extraItems)).toBe(
449+
`https://${GCP_ES_UUID}.${GCP_PARENT_DN}`
450+
)
451+
})
452+
453+
it('leaves the dots and hyphens a real hostname and hex UUID are made of alone', () => {
454+
const id = cloudId('ok', `es-prod.eu-west-1.aws.found.io$${ES_UUID}$${KIBANA_UUID}`)
455+
456+
expect(parseCloudId(id)).toBe(`https://${ES_UUID}.es-prod.eu-west-1.aws.found.io`)
457+
})
458+
})

apps/sim/tools/elasticsearch/utils.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,56 @@ function splitCloudIdPort(component: string, fallbackPort: number): { name: stri
2626
}
2727
}
2828

29+
/**
30+
* Characters that must never appear in a decoded Cloud ID component.
31+
*
32+
* `#@?/` is Elastic's own reject set, applied in `decodeCloudID`
33+
* (`beats/libbeat/cloudid/cloudid.go`) to the parent domain and each service
34+
* UUID, with the `inject-es`, `inject-host`, and `inject-kb` fixtures pinning
35+
* it. Each one lets a decoded component escape the authority it is supposed to
36+
* be part of, verified against the WHATWG parser `fetch` uses:
37+
*
38+
* ```
39+
* new URL('https://uuid@attacker.com.host').origin // => 'https://attacker.com.host'
40+
* new URL('https://uuid#attacker.com.host').origin // => 'https://uuid'
41+
* new URL('https://uuid?x.host').origin // => 'https://uuid'
42+
* new URL('https://uuid/p.host').origin // => 'https://uuid'
43+
* ```
44+
*
45+
* `@` is the dangerous one: the UUID becomes userinfo and the origin becomes
46+
* the attacker's host, which the caller's ApiKey or Basic credential is then
47+
* sent to. The other three silently truncate the authority to a bare label.
48+
*
49+
* `:` is a deliberate addition to Elastic's set. {@link splitCloudIdPort}
50+
* right-partitions only the *last* colon, so `a:b:9243` leaves `a:b` as a
51+
* component and assembles an authority with two colons that the URL parser
52+
* rejects outright — the same unactionable "Invalid URL" this module already
53+
* fixes for ported UUIDs. A real parent domain is a hostname and a real
54+
* service UUID is hex, so none of these five can occur in a legitimate value.
55+
*/
56+
const CLOUD_ID_FORBIDDEN_CHARS = ['#', '@', '?', '/', ':'] as const
57+
58+
/**
59+
* Rejects a decoded Cloud ID component that could escape the URL authority.
60+
*
61+
* Runs on the component **after** its port has been split off and **before**
62+
* it is concatenated into the origin. Checking the assembled string instead
63+
* would mean re-parsing the very URL whose parse is being subverted.
64+
*
65+
* @param component - A decoded, port-stripped Cloud ID component.
66+
* @param label - Names the component in the error message.
67+
* @throws If the component contains any of {@link CLOUD_ID_FORBIDDEN_CHARS}.
68+
*/
69+
function assertSafeCloudIdComponent(component: string, label: string): void {
70+
for (const char of CLOUD_ID_FORBIDDEN_CHARS) {
71+
if (component.includes(char)) {
72+
throw new Error(
73+
`Cloud ID is not properly formatted (${label} "${component}" contains the invalid character "${char}")`
74+
)
75+
}
76+
}
77+
}
78+
2979
/**
3080
* Decodes an Elastic Cloud ID into the Elasticsearch origin it addresses.
3181
*
@@ -115,6 +165,15 @@ export function parseCloudId(rawCloudId: string): string {
115165
throw new Error('Cloud ID is not properly formatted (invalid host or port)')
116166
}
117167

168+
/**
169+
* The Kibana UUID (`words[2]`) is deliberately not checked. Elastic validates
170+
* it because it also builds a Kibana URL; we only ever build the
171+
* Elasticsearch origin, so rejecting on a component that never reaches it
172+
* would refuse a Cloud ID that works fine for search.
173+
*/
174+
assertSafeCloudIdComponent(parentDn, 'host')
175+
assertSafeCloudIdComponent(esUuid, 'Elasticsearch UUID')
176+
118177
const origin = `https://${esUuid}.${parentDn}`
119178
return port === 443 ? origin : `${origin}:${port}`
120179
}

0 commit comments

Comments
 (0)