@@ -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 ( / i n v a l i d c h a r a c t e r / )
359+ } )
360+
361+ it ( "rejects a '#' in the parent domain (Elastic's inject-host fixture)" , ( ) => {
362+ expect ( ( ) => parseCloudId ( INJECTION_FIXTURES . injectHost ) ) . toThrow ( / i n v a l i d c h a r a c t e r / )
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 ( / i n v a l i d c h a r a c t e r / )
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 ( / i n v a l i d c h a r a c t e r / )
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 ( / i n v a l i d c h a r a c t e r / )
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 ( / i n v a l i d c h a r a c t e r / )
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 ( / i n v a l i d h o s t o r p o r t / )
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+ } )
0 commit comments