diff --git a/server/src/main/java/org/elasticsearch/index/mapper/DefaultRootObjectMapperNamespaceValidator.java b/server/src/main/java/org/elasticsearch/index/mapper/DefaultRootObjectMapperNamespaceValidator.java new file mode 100644 index 0000000000000..ad7b356e48f3e --- /dev/null +++ b/server/src/main/java/org/elasticsearch/index/mapper/DefaultRootObjectMapperNamespaceValidator.java @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +package org.elasticsearch.index.mapper; + +/** + * No-op Default of RootObjectMapperNamespaceValidator used in non-serverless Elasticsearch envs. + */ +public class DefaultRootObjectMapperNamespaceValidator implements RootObjectMapperNamespaceValidator { + @Override + public void validateNamespace(ObjectMapper.Subobjects subobjects, String name) {} +} diff --git a/server/src/main/java/org/elasticsearch/index/mapper/MapperRegistry.java b/server/src/main/java/org/elasticsearch/index/mapper/MapperRegistry.java index 44f7def74ec0e..03026655cdca4 100644 --- a/server/src/main/java/org/elasticsearch/index/mapper/MapperRegistry.java +++ b/server/src/main/java/org/elasticsearch/index/mapper/MapperRegistry.java @@ -31,12 +31,23 @@ public final class MapperRegistry { private final Map metadataMapperParsers6x; private final Map metadataMapperParsers5x; private final Function fieldFilter; + private final RootObjectMapperNamespaceValidator namespaceValidator; public MapperRegistry( Map mapperParsers, Map runtimeFieldParsers, Map metadataMapperParsers, Function fieldFilter + ) { + this(mapperParsers, runtimeFieldParsers, metadataMapperParsers, fieldFilter, null); + } + + public MapperRegistry( + Map mapperParsers, + Map runtimeFieldParsers, + Map metadataMapperParsers, + Function fieldFilter, + RootObjectMapperNamespaceValidator namespaceValidator ) { this.mapperParsers = Collections.unmodifiableMap(new LinkedHashMap<>(mapperParsers)); this.runtimeFieldParsers = runtimeFieldParsers; @@ -50,6 +61,7 @@ public MapperRegistry( metadata5x.put(LegacyTypeFieldMapper.NAME, LegacyTypeFieldMapper.PARSER); this.metadataMapperParsers5x = metadata5x; this.fieldFilter = fieldFilter; + this.namespaceValidator = namespaceValidator; } /** @@ -72,6 +84,10 @@ public Map getRuntimeFieldParsers() { return runtimeFieldParsers; } + public RootObjectMapperNamespaceValidator getNamespaceValidator() { + return namespaceValidator; + } + /** * Return a map of the meta mappers that have been registered. The * returned map uses the name of the field as a key. diff --git a/server/src/main/java/org/elasticsearch/index/mapper/MapperService.java b/server/src/main/java/org/elasticsearch/index/mapper/MapperService.java index 7958fd8e51525..e0a4aca3d83f0 100644 --- a/server/src/main/java/org/elasticsearch/index/mapper/MapperService.java +++ b/server/src/main/java/org/elasticsearch/index/mapper/MapperService.java @@ -245,7 +245,8 @@ public MapperService( indexAnalyzers, indexSettings, idFieldMapper, - bitSetProducer + bitSetProducer, + mapperRegistry.getNamespaceValidator() ); this.documentParser = new DocumentParser(parserConfiguration, this.mappingParserContextSupplier.get()); Map metadataMapperParsers = mapperRegistry.getMetadataMapperParsers( diff --git a/server/src/main/java/org/elasticsearch/index/mapper/MappingParserContext.java b/server/src/main/java/org/elasticsearch/index/mapper/MappingParserContext.java index f74a257f32921..5e5488c5f9acd 100644 --- a/server/src/main/java/org/elasticsearch/index/mapper/MappingParserContext.java +++ b/server/src/main/java/org/elasticsearch/index/mapper/MappingParserContext.java @@ -43,6 +43,7 @@ public class MappingParserContext { private final Function bitSetProducer; private final long mappingObjectDepthLimit; private long mappingObjectDepth = 0; + private final RootObjectMapperNamespaceValidator namespaceValidator; public MappingParserContext( Function similarityLookupService, @@ -55,7 +56,8 @@ public MappingParserContext( IndexAnalyzers indexAnalyzers, IndexSettings indexSettings, IdFieldMapper idFieldMapper, - Function bitSetProducer + Function bitSetProducer, + RootObjectMapperNamespaceValidator namespaceValidator ) { this.similarityLookupService = similarityLookupService; this.typeParsers = typeParsers; @@ -69,6 +71,40 @@ public MappingParserContext( this.idFieldMapper = idFieldMapper; this.mappingObjectDepthLimit = indexSettings.getMappingDepthLimit(); this.bitSetProducer = bitSetProducer; + this.namespaceValidator = namespaceValidator; + } + + public MappingParserContext( + Function similarityLookupService, + Function typeParsers, + Function runtimeFieldParsers, + IndexVersion indexVersionCreated, + Supplier clusterTransportVersion, + Supplier searchExecutionContextSupplier, + ScriptCompiler scriptCompiler, + IndexAnalyzers indexAnalyzers, + IndexSettings indexSettings, + IdFieldMapper idFieldMapper, + Function bitSetProducer + ) { + this( + similarityLookupService, + typeParsers, + runtimeFieldParsers, + indexVersionCreated, + clusterTransportVersion, + searchExecutionContextSupplier, + scriptCompiler, + indexAnalyzers, + indexSettings, + idFieldMapper, + bitSetProducer, + null + ); + } + + public RootObjectMapperNamespaceValidator getNamespaceValidator() { + return namespaceValidator; } public IndexAnalyzers getIndexAnalyzers() { @@ -170,7 +206,8 @@ private static class MultiFieldParserContext extends MappingParserContext { in.indexAnalyzers, in.indexSettings, in.idFieldMapper, - in.bitSetProducer + in.bitSetProducer, + in.namespaceValidator ); } @@ -200,7 +237,8 @@ private static class DynamicTemplateParserContext extends MappingParserContext { in.indexAnalyzers, in.indexSettings, in.idFieldMapper, - in.bitSetProducer + in.bitSetProducer, + in.namespaceValidator ); this.dateFormatter = dateFormatter; } diff --git a/server/src/main/java/org/elasticsearch/index/mapper/ObjectMapper.java b/server/src/main/java/org/elasticsearch/index/mapper/ObjectMapper.java index aaa484d8153f0..f626390fc4bb2 100644 --- a/server/src/main/java/org/elasticsearch/index/mapper/ObjectMapper.java +++ b/server/src/main/java/org/elasticsearch/index/mapper/ObjectMapper.java @@ -548,12 +548,21 @@ public final Optional sourceKeepMode() { } @Override - public void validate(MappingLookup mappers) { + public final void validate(MappingLookup mappers) { for (Mapper mapper : this.mappers.values()) { - mapper.validate(mappers); + validateSubField(mapper, mappers); } } + /** + * This method is separated out to allow subclasses (such as RootObjectMapper) to + * override it and add in additional validations beyond what the mapper.validate() + * method will check on each mapping. + */ + protected void validateSubField(Mapper mapper, MappingLookup mappers) { + mapper.validate(mappers); + } + protected MapperMergeContext createChildContext(MapperMergeContext mapperMergeContext, String name) { return mapperMergeContext.createChildContext(name, dynamic); } diff --git a/server/src/main/java/org/elasticsearch/index/mapper/RootObjectMapper.java b/server/src/main/java/org/elasticsearch/index/mapper/RootObjectMapper.java index 1b75168614e40..b5e79c561cf31 100644 --- a/server/src/main/java/org/elasticsearch/index/mapper/RootObjectMapper.java +++ b/server/src/main/java/org/elasticsearch/index/mapper/RootObjectMapper.java @@ -76,6 +76,7 @@ public static class Builder extends ObjectMapper.Builder { protected final Map runtimeFields = new HashMap<>(); protected Explicit dateDetection = Defaults.DATE_DETECTION; protected Explicit numericDetection = Defaults.NUMERIC_DETECTION; + protected RootObjectMapperNamespaceValidator namespaceValidator; public Builder(String name) { this(name, ObjectMapper.Defaults.SUBOBJECTS); @@ -85,6 +86,11 @@ public Builder(String name, Explicit subobjects) { super(name, subobjects); } + public Builder addNamespaceValidator(RootObjectMapperNamespaceValidator namespaceValidator) { + this.namespaceValidator = namespaceValidator; + return this; + } + public Builder dynamicDateTimeFormatter(Collection dateTimeFormatters) { this.dynamicDateTimeFormatters = new Explicit<>(dateTimeFormatters.toArray(new DateFormatter[0]), true); return this; @@ -124,7 +130,8 @@ public RootObjectMapper build(MapperBuilderContext context) { dynamicDateTimeFormatters, dynamicTemplates, dateDetection, - numericDetection + numericDetection, + namespaceValidator ); } } @@ -134,6 +141,7 @@ public RootObjectMapper build(MapperBuilderContext context) { private final Explicit numericDetection; private final Explicit dynamicTemplates; private final Map runtimeFields; + private final RootObjectMapperNamespaceValidator namespaceValidator; RootObjectMapper( String name, @@ -146,7 +154,8 @@ public RootObjectMapper build(MapperBuilderContext context) { Explicit dynamicDateTimeFormatters, Explicit dynamicTemplates, Explicit dateDetection, - Explicit numericDetection + Explicit numericDetection, + RootObjectMapperNamespaceValidator namespaceValidator ) { super(name, name, enabled, subobjects, sourceKeepMode, dynamic, mappers); this.runtimeFields = runtimeFields; @@ -154,6 +163,7 @@ public RootObjectMapper build(MapperBuilderContext context) { this.dynamicDateTimeFormatters = dynamicDateTimeFormatters; this.dateDetection = dateDetection; this.numericDetection = numericDetection; + this.namespaceValidator = namespaceValidator == null ? new DefaultRootObjectMapperNamespaceValidator() : namespaceValidator; if (sourceKeepMode.orElse(SourceKeepMode.NONE) == SourceKeepMode.ALL) { throw new MapperParsingException( "root object can't be configured with [" + Mapper.SYNTHETIC_SOURCE_KEEP_PARAM + ":" + SourceKeepMode.ALL + "]" @@ -182,7 +192,8 @@ RootObjectMapper withoutMappers() { dynamicDateTimeFormatters, dynamicTemplates, dateDetection, - numericDetection + numericDetection, + namespaceValidator ); } @@ -298,7 +309,8 @@ public RootObjectMapper merge(Mapper mergeWith, MapperMergeContext parentMergeCo dynamicDateTimeFormatters, dynamicTemplates, dateDetection, - numericDetection + numericDetection, + namespaceValidator ); } @@ -455,6 +467,7 @@ public static RootObjectMapper.Builder parse(String name, Map no throws MapperParsingException { Explicit subobjects = parseSubobjects(node); RootObjectMapper.Builder builder = new Builder(name, subobjects); + builder.addNamespaceValidator(parserContext.getNamespaceValidator()); Iterator> iterator = node.entrySet().iterator(); while (iterator.hasNext()) { Map.Entry entry = iterator.next(); @@ -544,4 +557,14 @@ private static boolean processField( public int getTotalFieldsCount() { return super.getTotalFieldsCount() - 1 + runtimeFields.size(); } + + /** + * Overrides in order to run the namespace validator first and then delegates to the + * standard validateSubField on the parent class + */ + @Override + protected void validateSubField(Mapper mapper, MappingLookup mappers) { + namespaceValidator.validateNamespace(subobjects(), mapper.leafName()); + super.validateSubField(mapper, mappers); + } } diff --git a/server/src/main/java/org/elasticsearch/index/mapper/RootObjectMapperNamespaceValidator.java b/server/src/main/java/org/elasticsearch/index/mapper/RootObjectMapperNamespaceValidator.java new file mode 100644 index 0000000000000..4c5ab9a7e9d1f --- /dev/null +++ b/server/src/main/java/org/elasticsearch/index/mapper/RootObjectMapperNamespaceValidator.java @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +package org.elasticsearch.index.mapper; + +import org.elasticsearch.core.Nullable; + +/** + * SPI to inject additional rules around namespaces (top level fields) that are prohibited + * in Elasticsearch mappings. + */ +public interface RootObjectMapperNamespaceValidator { + /** + * If the namespace in the mapper is not allowed, an Exception should be thrown. + * @param subobjects Whether subobjects are enabled. Null is allowed + * @param name namespace (field name) to validate + */ + void validateNamespace(@Nullable ObjectMapper.Subobjects subobjects, String name); +} diff --git a/server/src/main/java/org/elasticsearch/index/mapper/RuntimeField.java b/server/src/main/java/org/elasticsearch/index/mapper/RuntimeField.java index d7fbb8739d1f9..1e47948bff6e6 100644 --- a/server/src/main/java/org/elasticsearch/index/mapper/RuntimeField.java +++ b/server/src/main/java/org/elasticsearch/index/mapper/RuntimeField.java @@ -189,6 +189,9 @@ static Map parseRuntimeFields( + " Check the documentation." ); } + if (parserContext.getNamespaceValidator() != null) { + parserContext.getNamespaceValidator().validateNamespace(null, fieldName); + } runtimeFields.put(fieldName, builder.apply(typeParser.parse(fieldName, propNode, parserContext))); propNode.remove("type"); MappingParser.checkNoRemainingFields(fieldName, propNode); diff --git a/server/src/main/java/org/elasticsearch/indices/IndicesModule.java b/server/src/main/java/org/elasticsearch/indices/IndicesModule.java index 09be98630d5c4..98734e373ba17 100644 --- a/server/src/main/java/org/elasticsearch/indices/IndicesModule.java +++ b/server/src/main/java/org/elasticsearch/indices/IndicesModule.java @@ -57,6 +57,7 @@ import org.elasticsearch.index.mapper.ObjectMapper; import org.elasticsearch.index.mapper.PassThroughObjectMapper; import org.elasticsearch.index.mapper.RangeType; +import org.elasticsearch.index.mapper.RootObjectMapperNamespaceValidator; import org.elasticsearch.index.mapper.RoutingFieldMapper; import org.elasticsearch.index.mapper.RuntimeField; import org.elasticsearch.index.mapper.SeqNoFieldMapper; @@ -94,15 +95,20 @@ public class IndicesModule extends AbstractModule { private final MapperRegistry mapperRegistry; - public IndicesModule(List mapperPlugins) { + public IndicesModule(List mapperPlugins, RootObjectMapperNamespaceValidator namespaceValidator) { this.mapperRegistry = new MapperRegistry( getMappers(mapperPlugins), getRuntimeFields(mapperPlugins), getMetadataMappers(mapperPlugins), - getFieldFilter(mapperPlugins) + getFieldFilter(mapperPlugins), + namespaceValidator ); } + public IndicesModule(List mapperPlugins) { + this(mapperPlugins, null); + } + public static List getNamedWriteables() { return Arrays.asList( new NamedWriteableRegistry.Entry(Condition.class, MinAgeCondition.NAME, MinAgeCondition::new), diff --git a/server/src/main/java/org/elasticsearch/node/NodeConstruction.java b/server/src/main/java/org/elasticsearch/node/NodeConstruction.java index 57f3dda579819..548ee6f4da22e 100644 --- a/server/src/main/java/org/elasticsearch/node/NodeConstruction.java +++ b/server/src/main/java/org/elasticsearch/node/NodeConstruction.java @@ -118,7 +118,9 @@ import org.elasticsearch.index.SlowLogFields; import org.elasticsearch.index.analysis.AnalysisRegistry; import org.elasticsearch.index.engine.MergeMetrics; +import org.elasticsearch.index.mapper.DefaultRootObjectMapperNamespaceValidator; import org.elasticsearch.index.mapper.MapperMetrics; +import org.elasticsearch.index.mapper.RootObjectMapperNamespaceValidator; import org.elasticsearch.index.mapper.SourceFieldMetrics; import org.elasticsearch.index.search.stats.ShardSearchPhaseAPMMetrics; import org.elasticsearch.index.shard.SearchOperationListener; @@ -698,6 +700,12 @@ private void construct( modules.bindToInstance(Tracer.class, telemetryProvider.getTracer()); + RootObjectMapperNamespaceValidator namespaceValidator = pluginsService.loadSingletonServiceProvider( + RootObjectMapperNamespaceValidator.class, + () -> new DefaultRootObjectMapperNamespaceValidator() + ); + modules.bindToInstance(RootObjectMapperNamespaceValidator.class, namespaceValidator); + assert nodeEnvironment.nodeId() != null : "node ID must be set before constructing the Node"; TaskManager taskManager = new TaskManager( settings, @@ -814,7 +822,7 @@ private void construct( )::onNewInfo ); - IndicesModule indicesModule = new IndicesModule(pluginsService.filterPlugins(MapperPlugin.class).toList()); + IndicesModule indicesModule = new IndicesModule(pluginsService.filterPlugins(MapperPlugin.class).toList(), namespaceValidator); modules.add(indicesModule); modules.add(new GatewayModule()); diff --git a/server/src/test/java/org/elasticsearch/index/mapper/RootObjectMapperTests.java b/server/src/test/java/org/elasticsearch/index/mapper/RootObjectMapperTests.java index f20eaeb5037ba..5fe55b2212ec8 100644 --- a/server/src/test/java/org/elasticsearch/index/mapper/RootObjectMapperTests.java +++ b/server/src/test/java/org/elasticsearch/index/mapper/RootObjectMapperTests.java @@ -9,6 +9,7 @@ package org.elasticsearch.index.mapper; +import org.elasticsearch.ExceptionsHelper; import org.elasticsearch.common.Strings; import org.elasticsearch.common.xcontent.XContentHelper; import org.elasticsearch.core.CheckedConsumer; @@ -370,6 +371,198 @@ public void testEmptyType() throws Exception { assertThat(e.getMessage(), containsString("type cannot be an empty string")); } + public void testWithRootObjectMapperNamespaceValidator() throws Exception { + String errorMessage = "error 1234"; + String disallowed = "_project"; + RootObjectMapperNamespaceValidator validator = new TestRootObjectMapperNamespaceValidator(disallowed, errorMessage); + + String notNested = """ + { + "_doc": { + "properties": { + "": { + "type": "" + } + } + } + }"""; + + // _project should fail, regardless of type + { + String json = notNested.replace("", disallowed); + + String keyword = json.replace("", "keyword"); + Exception e = expectThrows(IllegalArgumentException.class, () -> createMapperServiceWithNamespaceValidator(keyword, validator)); + assertThat(e.getMessage(), equalTo(errorMessage)); + + String text = json.replace("", "text"); + e = expectThrows(IllegalArgumentException.class, () -> createMapperServiceWithNamespaceValidator(text, validator)); + assertThat(e.getMessage(), equalTo(errorMessage)); + + String object = json.replace("", "object"); + e = expectThrows(IllegalArgumentException.class, () -> createMapperServiceWithNamespaceValidator(object, validator)); + assertThat(e.getMessage(), equalTo(errorMessage)); + } + + // _project.subfield should fail + { + String json = notNested.replace("", disallowed + ".subfield") + .replace("", randomFrom("text", "keyword", "object")); + Exception e = expectThrows(IllegalArgumentException.class, () -> createMapperServiceWithNamespaceValidator(json, validator)); + assertThat(e.getMessage(), equalTo(errorMessage)); + } + + // _projectx should pass + { + String json = notNested.replace("", disallowed + "x").replace("", randomFrom("text", "keyword", "object")); + MapperService mapperService = createMapperServiceWithNamespaceValidator(json, validator); + assertNotNull(mapperService); + } + + // _project_subfield should pass + { + String json = notNested.replace("", disallowed + "_subfield"); + json = json.replace("", randomFrom("text", "keyword", "object")); + MapperService mapperService = createMapperServiceWithNamespaceValidator(json, validator); + assertNotNull(mapperService); + } + + // _projectx.subfield should pass + { + String json = notNested.replace("", disallowed + "x.subfield"); + json = json.replace("", randomFrom("text", "keyword", "object")); + MapperService mapperService = createMapperServiceWithNamespaceValidator(json, validator); + assertNotNull(mapperService); + } + + String nested = """ + { + "_doc": { + "properties": { + "": { + "type": "object", + "properties": { + "": { + "type": "keyword" + } + } + } + } + } + }"""; + + // nested _project { my_field } should fail + { + String json = nested.replace("", disallowed).replace("", "my_field"); + Exception e = expectThrows(IllegalArgumentException.class, () -> createMapperServiceWithNamespaceValidator(json, validator)); + assertThat(e.getMessage(), equalTo(errorMessage)); + } + + // nested my_field { _project } should succeed + { + String json = nested.replace("", "my_field").replace("", disallowed); + MapperService mapperService = createMapperServiceWithNamespaceValidator(json, validator); + assertNotNull(mapperService); + } + + // nested _projectx { _project } should succeed + { + String json = nested.replace("", disallowed + "x").replace("", disallowed); + MapperService mapperService = createMapperServiceWithNamespaceValidator(json, validator); + assertNotNull(mapperService); + } + } + + public void testSubobjectsWithRootObjectMapperNamespaceValidator() throws Exception { + String errorMessage = "error 1234"; + String disallowed = "_project"; + RootObjectMapperNamespaceValidator validator = new TestRootObjectMapperNamespaceValidator(disallowed, errorMessage); + + // test with subobjects setting + String withSubobjects = """ + { + "_doc": { + "subobjects": "", + "properties": { + "": { + "type": "object", + "properties": { + "my_field": { + "type": "keyword" + } + } + } + } + } + }"""; + { + String json = withSubobjects.replace("", "false").replace("", "_project"); + Exception e = expectThrows(IllegalArgumentException.class, () -> createMapperServiceWithNamespaceValidator(json, validator)); + assertThat(e.getMessage(), equalTo(errorMessage)); + } + { + String json = withSubobjects.replace("", randomFrom("true", "auto")).replace("", "_project"); + Exception e = expectThrows(IllegalArgumentException.class, () -> createMapperServiceWithNamespaceValidator(json, validator)); + assertThat(e.getMessage(), equalTo(errorMessage)); + } + { + String json = withSubobjects.replace("", randomFrom("false", "true", "auto")) + .replace("", "_project.foo"); + Exception e = expectThrows(IllegalArgumentException.class, () -> createMapperServiceWithNamespaceValidator(json, validator)); + assertThat(e.getMessage(), equalTo(errorMessage)); + } + { + String json = withSubobjects.replace("", randomFrom("false", "true", "auto")) + .replace("", "project.foo"); + MapperService mapperService = createMapperServiceWithNamespaceValidator(json, validator); + assertNotNull(mapperService); + } + } + + public void testRuntimeFieldInMappingWithNamespaceValidator() throws IOException { + String errorMessage = "error 1234"; + String disallowed = "_project"; + RootObjectMapperNamespaceValidator validator = new TestRootObjectMapperNamespaceValidator(disallowed, errorMessage); + + // ensure that things close to the disallowed fields that are allowed + { + String mapping = Strings.toString(runtimeMapping(builder -> { + builder.startObject(disallowed + "_x").field("type", "ip").endObject(); + builder.startObject(disallowed + "x").field("type", "date").endObject(); + builder.startObject("field1." + disallowed).field("type", "double").endObject(); + })); + MapperService mapperService = createMapperServiceWithNamespaceValidator(mapping, validator); + assertEquals(mapping, mapperService.documentMapper().mappingSource().toString()); + assertEquals(3, mapperService.documentMapper().mapping().getRoot().getTotalFieldsCount()); + } + + // _project is rejected + { + String mapping = Strings.toString(runtimeMapping(builder -> { + builder.startObject("field1").field("type", "double").endObject(); + builder.startObject(disallowed).field("type", "date").endObject(); + builder.startObject("field3").field("type", "ip").endObject(); + })); + Exception e = expectThrows(MapperParsingException.class, () -> createMapperServiceWithNamespaceValidator(mapping, validator)); + Throwable cause = ExceptionsHelper.unwrap(e, IllegalArgumentException.class); + assertNotNull(cause); + assertThat(cause.getMessage(), equalTo(errorMessage)); + } + + // _project.my_sub_field is rejected + { + String mapping = Strings.toString(runtimeMapping(builder -> { + builder.startObject("field1").field("type", "double").endObject(); + builder.startObject(disallowed + ".my_sub_field").field("type", "keyword").endObject(); + builder.startObject("field3").field("type", "ip").endObject(); + })); + Exception e = expectThrows(MapperParsingException.class, () -> createMapperServiceWithNamespaceValidator(mapping, validator)); + Throwable cause = ExceptionsHelper.unwrap(e, IllegalArgumentException.class); + assertNotNull(cause); + assertThat(cause.getMessage(), equalTo(errorMessage)); + } + } + public void testSyntheticSourceKeepAllThrows() throws IOException { String mapping = Strings.toString( XContentFactory.jsonBuilder() @@ -430,4 +623,25 @@ private RootObjectMapper createRootObjectMapperWithAllParametersSet( return mapper.mapping().getRoot(); } + static class TestRootObjectMapperNamespaceValidator implements RootObjectMapperNamespaceValidator { + private final String disallowed; + private final String errorMessage; + + TestRootObjectMapperNamespaceValidator(String disallowedNamespace, String errorMessage) { + this.disallowed = disallowedNamespace; + this.errorMessage = errorMessage; + } + + @Override + public void validateNamespace(ObjectMapper.Subobjects subobjects, String name) { + if (name.equals(disallowed)) { + throw new IllegalArgumentException(errorMessage); + } else if (subobjects != ObjectMapper.Subobjects.ENABLED) { + // name here will be something like _project.my_field, rather than just _project + if (name.startsWith(disallowed + ".")) { + throw new IllegalArgumentException(errorMessage); + } + } + } + } } diff --git a/server/src/test/java/org/elasticsearch/index/query/SearchExecutionContextTests.java b/server/src/test/java/org/elasticsearch/index/query/SearchExecutionContextTests.java index 59e4b728bc64e..9bb56c71f4324 100644 --- a/server/src/test/java/org/elasticsearch/index/query/SearchExecutionContextTests.java +++ b/server/src/test/java/org/elasticsearch/index/query/SearchExecutionContextTests.java @@ -61,6 +61,7 @@ import org.elasticsearch.index.mapper.NumberFieldMapper; import org.elasticsearch.index.mapper.ObjectMapper; import org.elasticsearch.index.mapper.RootObjectMapper; +import org.elasticsearch.index.mapper.RootObjectMapperNamespaceValidator; import org.elasticsearch.index.mapper.RuntimeField; import org.elasticsearch.index.mapper.SourceFieldMapper; import org.elasticsearch.index.mapper.TestRuntimeField; @@ -330,6 +331,89 @@ public void testSearchRequestRuntimeFields() { assertThat(context.getMatchingFieldNames("*"), equalTo(Set.of("cat", "dog", "pig", "runtime"))); } + public void testSearchRequestRuntimeFieldsWithNamespaceValidator() { + final String errorMessage = "error 12345"; + final String disallowed = "_project"; + + RootObjectMapperNamespaceValidator validator = new RootObjectMapperNamespaceValidator() { + @Override + public void validateNamespace(ObjectMapper.Subobjects subobjects, String name) { + if (name.equals(disallowed)) { + throw new IllegalArgumentException(errorMessage); + } else if (subobjects != ObjectMapper.Subobjects.ENABLED) { + // name here will be something like _project.my_field, rather than just _project + if (name.startsWith(disallowed + ".")) { + throw new IllegalArgumentException(errorMessage); + } + } + } + }; + + { + Map runtimeMappings = Map.ofEntries( + Map.entry(disallowed, Map.of("type", randomFrom("keyword", "long"))), + Map.entry("dog", Map.of("type", "long")) + ); + + Exception e = expectThrows( + IllegalArgumentException.class, + () -> createSearchExecutionContext( + "uuid", + null, + createMappingLookup( + List.of(new MockFieldMapper.FakeFieldType("pig"), new MockFieldMapper.FakeFieldType("cat")), + List.of(new TestRuntimeField("runtime", "long")) + ), + runtimeMappings, + validator + ) + ); + assertThat(e.getMessage(), equalTo(errorMessage)); + } + + { + Map runtimeMappings = Map.ofEntries( + Map.entry(disallowed + ".subfield", Map.of("type", randomFrom("keyword", "long"))), + Map.entry("dog", Map.of("type", "long")) + ); + + Exception e = expectThrows( + IllegalArgumentException.class, + () -> createSearchExecutionContext( + "uuid", + null, + createMappingLookup( + List.of(new MockFieldMapper.FakeFieldType("pig"), new MockFieldMapper.FakeFieldType("cat")), + List.of(new TestRuntimeField("runtime", "long")) + ), + runtimeMappings, + validator + ) + ); + assertThat(e.getMessage(), equalTo(errorMessage)); + } + + // _projectx should be allowed + { + Map runtimeMappings = Map.ofEntries( + Map.entry(disallowed + "x", Map.of("type", "keyword")), + Map.entry("dog", Map.of("type", "long")) + ); + + SearchExecutionContext searchExecutionContext = createSearchExecutionContext( + "uuid", + null, + createMappingLookup( + List.of(new MockFieldMapper.FakeFieldType("pig"), new MockFieldMapper.FakeFieldType("cat")), + List.of(new TestRuntimeField("runtime", "long")) + ), + runtimeMappings, + validator + ); + assertNotNull(searchExecutionContext); + } + } + public void testSearchRequestRuntimeFieldsWrongFormat() { Map runtimeMappings = new HashMap<>(); runtimeMappings.put("field", Arrays.asList("test1", "test2")); @@ -499,12 +583,22 @@ private static SearchExecutionContext createSearchExecutionContext( String clusterAlias, MappingLookup mappingLookup, Map runtimeMappings + ) { + return createSearchExecutionContext(indexUuid, clusterAlias, mappingLookup, runtimeMappings, null); + } + + private static SearchExecutionContext createSearchExecutionContext( + String indexUuid, + String clusterAlias, + MappingLookup mappingLookup, + Map runtimeMappings, + RootObjectMapperNamespaceValidator namespaceValidator ) { IndexMetadata.Builder indexMetadataBuilder = new IndexMetadata.Builder("index"); indexMetadataBuilder.settings(indexSettings(IndexVersion.current(), 1, 1).put(IndexMetadata.SETTING_INDEX_UUID, indexUuid)); IndexMetadata indexMetadata = indexMetadataBuilder.build(); IndexSettings indexSettings = new IndexSettings(indexMetadata, Settings.EMPTY); - MapperService mapperService = createMapperService(indexSettings, mappingLookup); + MapperService mapperService = createMapperServiceWithNamespaceValidator(indexSettings, mappingLookup, namespaceValidator); final long nowInMillis = randomNonNegativeLong(); return new SearchExecutionContext( 0, @@ -530,9 +624,13 @@ private static SearchExecutionContext createSearchExecutionContext( ); } - private static MapperService createMapperService(IndexSettings indexSettings, MappingLookup mappingLookup) { + private static MapperService createMapperServiceWithNamespaceValidator( + IndexSettings indexSettings, + MappingLookup mappingLookup, + RootObjectMapperNamespaceValidator namespaceValidator + ) { IndexAnalyzers indexAnalyzers = IndexAnalyzers.of(singletonMap("default", new NamedAnalyzer("default", AnalyzerScope.INDEX, null))); - IndicesModule indicesModule = new IndicesModule(Collections.emptyList()); + IndicesModule indicesModule = new IndicesModule(Collections.emptyList(), namespaceValidator); MapperRegistry mapperRegistry = indicesModule.getMapperRegistry(); Supplier searchExecutionContextSupplier = () -> { throw new UnsupportedOperationException(); }; MapperService mapperService = mock(MapperService.class); @@ -551,7 +649,8 @@ private static MapperService createMapperService(IndexSettings indexSettings, Ma indexSettings.getMode().buildIdFieldMapper(() -> true), query -> { throw new UnsupportedOperationException(); - } + }, + namespaceValidator ) ); when(mapperService.isMultiField(anyString())).then( diff --git a/test/framework/src/main/java/org/elasticsearch/index/mapper/MapperServiceTestCase.java b/test/framework/src/main/java/org/elasticsearch/index/mapper/MapperServiceTestCase.java index 9d045edf52eb0..e332de965b6eb 100644 --- a/test/framework/src/main/java/org/elasticsearch/index/mapper/MapperServiceTestCase.java +++ b/test/framework/src/main/java/org/elasticsearch/index/mapper/MapperServiceTestCase.java @@ -237,6 +237,18 @@ protected final MapperService createMapperService(IndexVersion version, Settings return new TestMapperServiceBuilder().indexVersion(version).settings(settings).idFieldDataEnabled(idFieldDataEnabled).build(); } + public MapperService createMapperServiceWithNamespaceValidator(String mappings, RootObjectMapperNamespaceValidator validator) + throws IOException { + MapperService mapperService = new TestMapperServiceBuilder().indexVersion(getVersion()) + .settings(getIndexSettings()) + .idFieldDataEnabled(() -> true) + .namespaceValidator(validator) + .build(); + + merge(mapperService, mappings); + return mapperService; + } + protected final MapperService withMapping(MapperService mapperService, XContentBuilder mapping) throws IOException { merge(mapperService, mapping); return mapperService; @@ -249,6 +261,7 @@ protected class TestMapperServiceBuilder { private ScriptCompiler scriptCompiler; private MapperMetrics mapperMetrics; private boolean applyDefaultMapping; + private RootObjectMapperNamespaceValidator namespaceValidator; public TestMapperServiceBuilder() { indexVersion = getVersion(); @@ -284,6 +297,11 @@ public TestMapperServiceBuilder applyDefaultMapping(boolean applyDefaultMapping) return this; } + public TestMapperServiceBuilder namespaceValidator(RootObjectMapperNamespaceValidator validator) { + this.namespaceValidator = validator; + return this; + } + public MapperService build() { Collection plugins = getPlugins(); Collection> pluginIndexSettings = plugins.stream() @@ -293,7 +311,8 @@ public MapperService build() { IndexSettings indexSettings = createIndexSettings(indexVersion, settings, pluginIndexSettings); SimilarityService similarityService = new SimilarityService(indexSettings, null, Map.of()); MapperRegistry mapperRegistry = new IndicesModule( - plugins.stream().filter(p -> p instanceof MapperPlugin).map(p -> (MapperPlugin) p).collect(toList()) + plugins.stream().filter(p -> p instanceof MapperPlugin).map(p -> (MapperPlugin) p).collect(toList()), + namespaceValidator ).getMapperRegistry(); BitsetFilterCache bitsetFilterCache = new BitsetFilterCache(indexSettings, BitsetFilterCache.Listener.NOOP);