diff --git a/build.gradle b/build.gradle index 815c2f6cdf..cbb1e76f36 100644 --- a/build.gradle +++ b/build.gradle @@ -65,7 +65,7 @@ plugins { id 'com.diffplug.spotless' version '6.25.0' id 'checkstyle' id 'com.netflix.nebula.ospackage' version "11.10.0" - id "org.gradle.test-retry" version "1.5.10" + id "org.gradle.test-retry" version "1.6.0" id 'eclipse' id "com.github.spotbugs" version "5.2.5" id "com.google.osdetector" version "1.7.3" @@ -482,7 +482,7 @@ configurations { force "io.netty:netty-transport:${versions.netty}" force "io.netty:netty-transport-native-unix-common:${versions.netty}" force "com.github.luben:zstd-jni:${versions.zstd}" - force "org.xerial.snappy:snappy-java:1.1.10.6" + force "org.xerial.snappy:snappy-java:1.1.10.7" force "com.google.guava:guava:${guava_version}" // for spotbugs dependency conflict @@ -495,9 +495,9 @@ configurations { // For integrationTest force "org.apache.httpcomponents:httpclient:4.5.14" force "org.apache.httpcomponents:httpcore:4.4.16" - force "com.google.errorprone:error_prone_annotations:2.31.0" - force "org.checkerframework:checker-qual:3.46.0" - force "ch.qos.logback:logback-classic:1.5.7" + force "com.google.errorprone:error_prone_annotations:2.32.0" + force "org.checkerframework:checker-qual:3.47.0" + force "ch.qos.logback:logback-classic:1.5.8" } } @@ -580,7 +580,7 @@ dependencies { implementation 'commons-cli:commons-cli:1.9.0' implementation "org.bouncycastle:bcprov-jdk18on:${versions.bouncycastle}" implementation 'org.ldaptive:ldaptive:1.2.3' - implementation 'com.nimbusds:nimbus-jose-jwt:9.40' + implementation 'com.nimbusds:nimbus-jose-jwt:9.41.1' implementation 'com.rfksystems:blake2b:2.0.0' implementation 'com.password4j:password4j:1.8.2' //JWT @@ -602,7 +602,7 @@ dependencies { runtimeOnly 'com.eclipsesource.minimal-json:minimal-json:0.9.5' runtimeOnly 'commons-codec:commons-codec:1.17.1' runtimeOnly 'org.cryptacular:cryptacular:1.2.7' - compileOnly 'com.google.errorprone:error_prone_annotations:2.31.0' + compileOnly 'com.google.errorprone:error_prone_annotations:2.32.0' runtimeOnly 'com.sun.istack:istack-commons-runtime:4.2.0' runtimeOnly 'jakarta.xml.bind:jakarta.xml.bind-api:4.0.2' runtimeOnly 'org.ow2.asm:asm:9.7' @@ -639,14 +639,14 @@ dependencies { runtimeOnly 'org.lz4:lz4-java:1.8.0' runtimeOnly 'org.slf4j:slf4j-api:1.7.36' runtimeOnly "org.apache.logging.log4j:log4j-slf4j-impl:${versions.log4j}" - runtimeOnly 'org.xerial.snappy:snappy-java:1.1.10.6' + runtimeOnly 'org.xerial.snappy:snappy-java:1.1.10.7' runtimeOnly 'org.codehaus.woodstox:stax2-api:4.2.2' runtimeOnly "org.glassfish.jaxb:txw2:${jaxb_version}" runtimeOnly 'com.fasterxml.woodstox:woodstox-core:6.7.0' runtimeOnly 'org.apache.ws.xmlschema:xmlschema-core:2.3.1' runtimeOnly 'org.apache.santuario:xmlsec:2.3.4' runtimeOnly "com.github.luben:zstd-jni:${versions.zstd}" - runtimeOnly 'org.checkerframework:checker-qual:3.46.0' + runtimeOnly 'org.checkerframework:checker-qual:3.47.0' runtimeOnly "org.bouncycastle:bcpkix-jdk18on:${versions.bouncycastle}" runtimeOnly 'org.scala-lang.modules:scala-java8-compat_3:1.0.2' diff --git a/config/roles.yml b/config/roles.yml index f821df2204..51cd9006a1 100644 --- a/config/roles.yml +++ b/config/roles.yml @@ -32,6 +32,7 @@ alerting_read_access: - 'cluster:admin/opendistro/alerting/destination/get' - 'cluster:admin/opendistro/alerting/monitor/get' - 'cluster:admin/opendistro/alerting/monitor/search' + - 'cluster:admin/opensearch/alerting/comments/search' - 'cluster:admin/opensearch/alerting/findings/get' - 'cluster:admin/opensearch/alerting/remote/indexes/get' - 'cluster:admin/opensearch/alerting/workflow/get' @@ -44,6 +45,7 @@ alerting_ack_alerts: - 'cluster:admin/opendistro/alerting/alerts/*' - 'cluster:admin/opendistro/alerting/chained_alerts/*' - 'cluster:admin/opendistro/alerting/workflow_alerts/*' + - 'cluster:admin/opensearch/alerting/comments/*' # Allows users to use all alerting functionality alerting_full_access: @@ -84,7 +86,12 @@ anomaly_full_access: - '*' allowed_actions: - 'indices:admin/aliases/get' + - 'indices:admin/mappings/fields/get' + - 'indices:admin/mappings/fields/get*' - 'indices:admin/mappings/get' + - 'indices:admin/resolve/index' + - 'indices:data/read/field_caps*' + - 'indices:data/read/search' - 'indices_monitor' # Allow users to execute read only k-NN actions @@ -409,7 +416,7 @@ security_analytics_ack_alerts: reserved: true cluster_permissions: - 'cluster:admin/opensearch/securityanalytics/alerts/*' - - 'cluster:admin/opensearch/securityanalytics/correlationAlerts/ack' + - 'cluster:admin/opensearch/securityanalytics/correlationAlerts/*' - 'cluster:admin/opensearch/securityanalytics/threatintel/alerts/*' # Allows users to use all Flow Framework functionality diff --git a/release-notes/opensearch-security.release-notes-2.17.0.0.md b/release-notes/opensearch-security.release-notes-2.17.0.0.md new file mode 100644 index 0000000000..a1a40d9c09 --- /dev/null +++ b/release-notes/opensearch-security.release-notes-2.17.0.0.md @@ -0,0 +1,39 @@ +## Version 2.17.0 Release Notes + +Compatible with OpenSearch and OpenSearch Dashboards version 2.17.0 + +### Enhancements +* Add `ignore_hosts` config option for auth failure listener ([#4538](https://github.com/opensearch-project/security/pull/4538)) +* added API roles for correlationAlerts ([#4689](https://github.com/opensearch-project/security/pull/4689)) +* Allow multiple signing keys to be provided ([#4666](https://github.com/opensearch-project/security/pull/4666)) +* adding alerting comments security actions to roles.yml ([#4700](https://github.com/opensearch-project/security/pull/4700)) +* Permission changes for correlationAlerts ([#4704](https://github.com/opensearch-project/security/pull/4704)) + +### Bug Fixes +* Addresses a bug with `plugins.security.allow_unsafe_democertificates` setting ([#4603](https://github.com/opensearch-project/security/pull/4603)) +* Fix covereage-report workflow ([#4684](https://github.com/opensearch-project/security/pull/4684), [#4683](https://github.com/opensearch-project/security/pull/4683)) +* Handle the audit config being null ([#4664](https://github.com/opensearch-project/security/pull/4664)) +* Fixes authtoken endpoint ([#4631](https://github.com/opensearch-project/security/pull/4631)) +* Fixed READ_ACTIONS required by TermsAggregationEvaluator ([#4607](https://github.com/opensearch-project/security/pull/4607)) +* Sort the DNS Names in the SANs ([#4640](https://github.com/opensearch-project/security/pull/4640)) + +### Maintenance +* Bump com.google.errorprone:error_prone_annotations from 2.30.0 to 2.31.0 ([#4696](https://github.com/opensearch-project/security/pull/4696)) +* Bump org.passay:passay from 1.6.4 to 1.6.5 ([#4682](https://github.com/opensearch-project/security/pull/4682)) +* Bump spring_version from 5.3.37 to 5.3.39 ([#4661](https://github.com/opensearch-project/security/pull/4661)) +* Bump commons-cli:commons-cli from 1.8.0 to 1.9.0 ([#4659](https://github.com/opensearch-project/security/pull/4659)) +* Bump org.junit.jupiter:junit-jupiter from 5.10.3 to 5.11.0 ([#4657](https://github.com/opensearch-project/security/pull/4657)) +* Bump org.cryptacular:cryptacular from 1.2.6 to 1.2.7 ([#4656](https://github.com/opensearch-project/security/pull/4656)) +* Update Gradle to 8.10 ([#4646](https://github.com/opensearch-project/security/pull/4646)) +* Bump org.xerial.snappy:snappy-java from 1.1.10.5 to 1.1.10.6 ([#4639](https://github.com/opensearch-project/security/pull/4639)) +* Bump com.google.googlejavaformat:google-java-format from 1.22.0 to 1.23.0 ([#4622](https://github.com/opensearch-project/security/pull/4622)) +* Increment version to 2.17.0-SNAPSHOT ([#4615](https://github.com/opensearch-project/security/pull/4615)) +* Backports PRs with `backport-failed` labels that weren't actually backported ([#4610](https://github.com/opensearch-project/security/pull/4610)) +* Bump io.dropwizard.metrics:metrics-core from 4.2.26 to 4.2.27 ([#4660](https://github.com/opensearch-project/security/pull/4660)) +* Bump com.netflix.nebula.ospackage from 11.9.1 to 11.10.0 ([#4681](https://github.com/opensearch-project/security/pull/4681)) +* Interim build fix for PluginSubject related changes ([#4694](https://github.com/opensearch-project/security/pull/4694)) +* Add Nils Bandener (Github: nibix) as a maintainer ([#4673](https://github.com/opensearch-project/security/pull/4673)) +* Remove usages of org.apache.logging.log4j.util.Strings ([#4653](https://github.com/opensearch-project/security/pull/4653)) +* Update backport section of PR template ([#4625](https://github.com/opensearch-project/security/pull/4625)) +* Bump org.checkerframework:checker-qual from 3.45.0 to 3.46.0 ([#4623](https://github.com/opensearch-project/security/pull/4623)) +* Refactor security provider instantiation ([#4611](https://github.com/opensearch-project/security/pull/4611)) \ No newline at end of file diff --git a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java index 2009efd23d..4860fbf0a0 100644 --- a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java +++ b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java @@ -2121,7 +2121,7 @@ public PluginSubject getPluginSubject(Plugin plugin) { @Override public Optional getSecureSettingFactory(Settings settings) { - return Optional.of(new OpenSearchSecureSettingsFactory(threadPool, sks, sslExceptionHandler, securityRestHandler)); + return Optional.of(new OpenSearchSecureSettingsFactory(threadPool, sks, evaluateSslExceptionHandler(), securityRestHandler)); } @SuppressWarnings("removal") diff --git a/src/main/java/org/opensearch/security/configuration/ClusterInfoHolder.java b/src/main/java/org/opensearch/security/configuration/ClusterInfoHolder.java index d7429c5d1d..a9f08eb5f1 100644 --- a/src/main/java/org/opensearch/security/configuration/ClusterInfoHolder.java +++ b/src/main/java/org/opensearch/security/configuration/ClusterInfoHolder.java @@ -29,6 +29,7 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.opensearch.Version; import org.opensearch.cluster.ClusterChangedEvent; import org.opensearch.cluster.ClusterStateListener; import org.opensearch.cluster.node.DiscoveryNode; @@ -67,6 +68,17 @@ public boolean isInitialized() { return initialized; } + public Version getMinNodeVersion() { + if (nodes == null) { + if (log.isDebugEnabled()) { + log.debug("Cluster Info Holder not initialized yet for 'nodes'"); + } + return null; + } + + return nodes.getMinNodeVersion(); + } + public Boolean hasNode(DiscoveryNode node) { if (nodes == null) { if (log.isDebugEnabled()) { diff --git a/src/main/java/org/opensearch/security/dlic/rest/api/Endpoint.java b/src/main/java/org/opensearch/security/dlic/rest/api/Endpoint.java index 84a447bcac..ecc9dcbc59 100644 --- a/src/main/java/org/opensearch/security/dlic/rest/api/Endpoint.java +++ b/src/main/java/org/opensearch/security/dlic/rest/api/Endpoint.java @@ -24,6 +24,7 @@ public enum Endpoint { PERMISSIONSINFO, AUTHTOKEN, TENANTS, + RATELIMITERS, MIGRATE, VALIDATE, WHITELIST, diff --git a/src/main/java/org/opensearch/security/dlic/rest/api/RateLimitersApiAction.java b/src/main/java/org/opensearch/security/dlic/rest/api/RateLimitersApiAction.java new file mode 100644 index 0000000000..6dc51bf6e1 --- /dev/null +++ b/src/main/java/org/opensearch/security/dlic/rest/api/RateLimitersApiAction.java @@ -0,0 +1,264 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.dlic.rest.api; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import org.opensearch.action.index.IndexResponse; +import org.opensearch.cluster.service.ClusterService; +import org.opensearch.common.settings.Settings; +import org.opensearch.core.rest.RestStatus; +import org.opensearch.core.xcontent.ToXContent; +import org.opensearch.security.DefaultObjectMapper; +import org.opensearch.security.dlic.rest.validation.EndpointValidator; +import org.opensearch.security.dlic.rest.validation.RequestContentValidator; +import org.opensearch.security.dlic.rest.validation.RequestContentValidator.DataType; +import org.opensearch.security.dlic.rest.validation.ValidationResult; +import org.opensearch.security.securityconf.impl.CType; +import org.opensearch.security.securityconf.impl.v7.ConfigV7; +import org.opensearch.security.support.SecurityJsonNode; +import org.opensearch.threadpool.ThreadPool; + +import static org.opensearch.rest.RestRequest.Method.DELETE; +import static org.opensearch.rest.RestRequest.Method.GET; +import static org.opensearch.rest.RestRequest.Method.PUT; +import static org.opensearch.security.dlic.rest.api.Responses.badRequest; +import static org.opensearch.security.dlic.rest.api.Responses.badRequestMessage; +import static org.opensearch.security.dlic.rest.api.Responses.notFound; +import static org.opensearch.security.dlic.rest.api.Responses.ok; +import static org.opensearch.security.dlic.rest.api.Responses.response; +import static org.opensearch.security.dlic.rest.support.Utils.addRoutesPrefix; +import static org.opensearch.security.securityconf.impl.v7.ConfigV7.ALLOWED_TRIES_DEFAULT; +import static org.opensearch.security.securityconf.impl.v7.ConfigV7.BLOCK_EXPIRY_SECONDS_DEFAULT; +import static org.opensearch.security.securityconf.impl.v7.ConfigV7.MAX_BLOCKED_CLIENTS_DEFAULT; +import static org.opensearch.security.securityconf.impl.v7.ConfigV7.MAX_TRACKED_CLIENTS_DEFAULT; +import static org.opensearch.security.securityconf.impl.v7.ConfigV7.TIME_WINDOW_SECONDS_DEFAULT; + +public class RateLimitersApiAction extends AbstractApiAction { + + public static final String IP_TYPE = "ip"; + + public static final String USERNAME_TYPE = "username"; + + public static final String NAME_JSON_PROPERTY = "name"; + + public static final String TYPE_JSON_PROPERTY = "type"; + public static final String IGNORE_HOSTS_JSON_PROPERTY = "ignore_hosts"; + public static final String AUTHENTICATION_BACKEND_JSON_PROPERTY = "authentication_backend"; + public static final String ALLOWED_TRIES_JSON_PROPERTY = "allowed_tries"; + public static final String TIME_WINDOW_SECONDS_JSON_PROPERTY = "time_window_seconds"; + public static final String BLOCK_EXPIRY_JSON_PROPERTY = "block_expiry_seconds"; + public static final String MAX_BLOCKED_CLIENTS_JSON_PROPERTY = "max_blocked_clients"; + public static final String MAX_TRACKED_CLIENTS_JSON_PROPERTY = "max_tracked_clients"; + + private static final List ROUTES = addRoutesPrefix( + ImmutableList.of( + new Route(GET, "/authfailurelisteners"), + new Route(DELETE, "/authfailurelisteners/{name}"), + new Route(PUT, "/authfailurelisteners/{name}") + ) + ); + + protected RateLimitersApiAction(ClusterService clusterService, ThreadPool threadPool, SecurityApiDependencies securityApiDependencies) { + super(Endpoint.RATELIMITERS, clusterService, threadPool, securityApiDependencies); + this.requestHandlersBuilder.configureRequestHandlers(this::authFailureConfigApiRequestHandlers); + } + + @Override + public String getName() { + return "Rate limiter actions to retrieve / update configs."; + } + + @Override + public List routes() { + return ROUTES; + } + + @Override + protected CType getConfigType() { + return CType.CONFIG; + } + + @Override + protected EndpointValidator createEndpointValidator() { + return new EndpointValidator() { + + @Override + public Endpoint endpoint() { + return endpoint; + } + + @Override + public RestApiAdminPrivilegesEvaluator restApiAdminPrivilegesEvaluator() { + return securityApiDependencies.restApiAdminPrivilegesEvaluator(); + } + + @Override + public RequestContentValidator createRequestContentValidator(Object... params) { + return RequestContentValidator.of(new RequestContentValidator.ValidationContext() { + @Override + public Object[] params() { + return params; + } + + @Override + public Settings settings() { + return securityApiDependencies.settings(); + } + + @Override + public Map allowedKeys() { + final ImmutableMap.Builder allowedKeys = ImmutableMap.builder(); + + return allowedKeys.put(TYPE_JSON_PROPERTY, DataType.STRING) + .put(IGNORE_HOSTS_JSON_PROPERTY, DataType.ARRAY) + .put(AUTHENTICATION_BACKEND_JSON_PROPERTY, DataType.STRING) + .put(ALLOWED_TRIES_JSON_PROPERTY, DataType.INTEGER) + .put(TIME_WINDOW_SECONDS_JSON_PROPERTY, DataType.INTEGER) + .put(BLOCK_EXPIRY_JSON_PROPERTY, DataType.INTEGER) + .put(MAX_BLOCKED_CLIENTS_JSON_PROPERTY, DataType.INTEGER) + .put(MAX_TRACKED_CLIENTS_JSON_PROPERTY, DataType.INTEGER) + .build(); + } + }); + } + }; + } + + private ToXContent authFailureContent(final ConfigV7 config) { + return (builder, params) -> { + builder.startObject(); + for (String name : config.dynamic.auth_failure_listeners.getListeners().keySet()) { + ConfigV7.AuthFailureListener listener = config.dynamic.auth_failure_listeners.getListeners().get(name); + builder.startObject(name); + builder.field(NAME_JSON_PROPERTY, name) + .field(TYPE_JSON_PROPERTY, listener.type) + .field(IGNORE_HOSTS_JSON_PROPERTY, listener.ignore_hosts) + .field(AUTHENTICATION_BACKEND_JSON_PROPERTY, listener.authentication_backend) + .field(ALLOWED_TRIES_JSON_PROPERTY, listener.allowed_tries) + .field(TIME_WINDOW_SECONDS_JSON_PROPERTY, listener.time_window_seconds) + .field(BLOCK_EXPIRY_JSON_PROPERTY, listener.block_expiry_seconds) + .field(MAX_BLOCKED_CLIENTS_JSON_PROPERTY, listener.max_blocked_clients) + .field(MAX_TRACKED_CLIENTS_JSON_PROPERTY, listener.max_tracked_clients); + builder.endObject(); + } + builder.endObject(); + return builder; + }; + } + + private void authFailureConfigApiRequestHandlers(RequestHandler.RequestHandlersBuilder requestHandlersBuilder) { + + requestHandlersBuilder.override( + GET, + (channel, request, client) -> loadConfiguration(getConfigType(), false, false).valid(configuration -> { + final var config = (ConfigV7) configuration.getCEntry(CType.CONFIG.toLCString()); + ok(channel, authFailureContent(config)); + }).error((status, toXContent) -> response(channel, status, toXContent)) + ).override(DELETE, (channel, request, client) -> loadConfiguration(getConfigType(), false, false).valid(configuration -> { + ConfigV7 config = (ConfigV7) configuration.getCEntry(CType.CONFIG.toLCString()); + + String listenerName = request.param(NAME_JSON_PROPERTY); + + // Try to remove the listener by name + if (config.dynamic.auth_failure_listeners.getListeners().remove(listenerName) == null) { + notFound(channel, "listener not found"); + } + saveOrUpdateConfiguration(client, configuration, new OnSucessActionListener<>(channel) { + @Override + public void onResponse(IndexResponse indexResponse) { + ok(channel, authFailureContent(config)); + } + }); + }).error((status, toXContent) -> response(channel, status, toXContent))) + .override(PUT, (channel, request, client) -> loadConfiguration(getConfigType(), false, false).valid(configuration -> { + ConfigV7 config = (ConfigV7) configuration.getCEntry(CType.CONFIG.toLCString()); + + String listenerName = request.param(NAME_JSON_PROPERTY); + + ObjectNode body = (ObjectNode) DefaultObjectMapper.readTree(request.content().utf8ToString()); + SecurityJsonNode authFailureListener = new SecurityJsonNode(body); + ValidationResult validationResult = validateAuthFailureListener(authFailureListener, listenerName); + + if (!validationResult.isValid()) { + badRequest(channel, validationResult.toString()); + return; + } + + // Try to put the listener by name + config.dynamic.auth_failure_listeners.getListeners() + .put(listenerName, createAuthFailureListenerWithDefaults(authFailureListener)); + saveOrUpdateConfiguration(client, configuration, new OnSucessActionListener<>(channel) { + @Override + public void onResponse(IndexResponse indexResponse) { + + ok(channel, authFailureContent(config)); + } + }); + }).error((status, toXContent) -> response(channel, status, toXContent))); + + } + + private ConfigV7.AuthFailureListener createAuthFailureListenerWithDefaults(SecurityJsonNode authFailureListener) { + List ignoreHosts = authFailureListener.get(IGNORE_HOSTS_JSON_PROPERTY).isNull() + ? Collections.emptyList() + : authFailureListener.get(IGNORE_HOSTS_JSON_PROPERTY).asList(); + + return new ConfigV7.AuthFailureListener( + authFailureListener.get(TYPE_JSON_PROPERTY).asString(), + authFailureListener.get(AUTHENTICATION_BACKEND_JSON_PROPERTY).asString(), + ignoreHosts, + authFailureListener.get(ALLOWED_TRIES_JSON_PROPERTY).asInt(ALLOWED_TRIES_DEFAULT), + authFailureListener.get(TIME_WINDOW_SECONDS_JSON_PROPERTY).asInt(TIME_WINDOW_SECONDS_DEFAULT), + authFailureListener.get(BLOCK_EXPIRY_JSON_PROPERTY).asInt(BLOCK_EXPIRY_SECONDS_DEFAULT), + authFailureListener.get(MAX_BLOCKED_CLIENTS_JSON_PROPERTY).asInt(MAX_BLOCKED_CLIENTS_DEFAULT), + authFailureListener.get(MAX_TRACKED_CLIENTS_JSON_PROPERTY).asInt(MAX_TRACKED_CLIENTS_DEFAULT) + ); + + } + + private ValidationResult validateAuthFailureListener(SecurityJsonNode authFailureListener, String name) { + if (name == null) { + return ValidationResult.error(RestStatus.BAD_REQUEST, badRequestMessage("name is required")); + } + if (authFailureListener.get(TYPE_JSON_PROPERTY).isNull()) { + return ValidationResult.error(RestStatus.BAD_REQUEST, badRequestMessage("type is required")); + } + if (!(Set.of(IP_TYPE, USERNAME_TYPE).contains(authFailureListener.get(TYPE_JSON_PROPERTY).asString()))) { + return ValidationResult.error(RestStatus.BAD_REQUEST, badRequestMessage("type must be username or ip")); + } + if (authFailureListener.get(TYPE_JSON_PROPERTY).asString().equals(USERNAME_TYPE) + && (authFailureListener.get(AUTHENTICATION_BACKEND_JSON_PROPERTY).isNull() + || !authFailureListener.get(AUTHENTICATION_BACKEND_JSON_PROPERTY).asString().equals("internal"))) { + return ValidationResult.error( + RestStatus.BAD_REQUEST, + badRequestMessage("username auth failure listeners must have 'internal' authentication backend") + ); + } + if (authFailureListener.get(TYPE_JSON_PROPERTY).asString().equals(IP_TYPE) + && !authFailureListener.get(AUTHENTICATION_BACKEND_JSON_PROPERTY).isNull()) { + return ValidationResult.error( + RestStatus.BAD_REQUEST, + badRequestMessage("ip auth failure listeners should not have an authentication backend") + ); + } + + return ValidationResult.success(authFailureListener); + } +} diff --git a/src/main/java/org/opensearch/security/dlic/rest/api/RestApiAdminPrivilegesEvaluator.java b/src/main/java/org/opensearch/security/dlic/rest/api/RestApiAdminPrivilegesEvaluator.java index a80d029f13..faa0217db2 100644 --- a/src/main/java/org/opensearch/security/dlic/rest/api/RestApiAdminPrivilegesEvaluator.java +++ b/src/main/java/org/opensearch/security/dlic/rest/api/RestApiAdminPrivilegesEvaluator.java @@ -66,6 +66,7 @@ default String build() { .put(Endpoint.CONFIG, action -> buildEndpointActionPermission(Endpoint.CONFIG, action)) .put(Endpoint.INTERNALUSERS, action -> buildEndpointPermission(Endpoint.INTERNALUSERS)) .put(Endpoint.NODESDN, action -> buildEndpointPermission(Endpoint.NODESDN)) + .put(Endpoint.RATELIMITERS, action -> buildEndpointPermission(Endpoint.RATELIMITERS)) .put(Endpoint.ROLES, action -> buildEndpointPermission(Endpoint.ROLES)) .put(Endpoint.ROLESMAPPING, action -> buildEndpointPermission(Endpoint.ROLESMAPPING)) .put(Endpoint.TENANTS, action -> buildEndpointPermission(Endpoint.TENANTS)) @@ -98,13 +99,6 @@ public boolean isCurrentUserAdminFor(final Endpoint endpoint, final String actio return false; } if (adminDNs.isAdmin(userAndRemoteAddress.getLeft())) { - if (logger.isDebugEnabled()) { - logger.debug( - "Security admin permissions required for endpoint {} but {} is not an admin", - endpoint, - userAndRemoteAddress.getLeft().getName() - ); - } return true; } if (!ENDPOINTS_WITH_PERMISSIONS.containsKey(endpoint)) { diff --git a/src/main/java/org/opensearch/security/dlic/rest/api/SecurityRestApiActions.java b/src/main/java/org/opensearch/security/dlic/rest/api/SecurityRestApiActions.java index 8ccf494d3d..ceb99a9401 100644 --- a/src/main/java/org/opensearch/security/dlic/rest/api/SecurityRestApiActions.java +++ b/src/main/java/org/opensearch/security/dlic/rest/api/SecurityRestApiActions.java @@ -97,6 +97,7 @@ public static Collection getHandler( new AllowlistApiAction(Endpoint.ALLOWLIST, clusterService, threadPool, securityApiDependencies), new AuditApiAction(clusterService, threadPool, securityApiDependencies), new MultiTenancyConfigApiAction(clusterService, threadPool, securityApiDependencies), + new RateLimitersApiAction(clusterService, threadPool, securityApiDependencies), new ConfigUpgradeApiAction(clusterService, threadPool, securityApiDependencies), new SecuritySSLCertsApiAction(clusterService, threadPool, securityKeyStore, certificatesReloadEnabled, securityApiDependencies), new CertificatesApiAction(clusterService, threadPool, securityApiDependencies) diff --git a/src/main/java/org/opensearch/security/dlic/rest/validation/RequestContentValidator.java b/src/main/java/org/opensearch/security/dlic/rest/validation/RequestContentValidator.java index db6d3b4883..097b953690 100644 --- a/src/main/java/org/opensearch/security/dlic/rest/validation/RequestContentValidator.java +++ b/src/main/java/org/opensearch/security/dlic/rest/validation/RequestContentValidator.java @@ -81,6 +81,7 @@ public static enum DataType { STRING, ARRAY, OBJECT, + INTEGER, BOOLEAN; } @@ -179,6 +180,7 @@ protected ValidationResult validateJsonKeys(final JsonNode jsonContent final Set allowed = new HashSet<>(validationContext.allowedKeys().keySet()); requestedKeys.removeAll(allowed); invalidKeys.addAll(requestedKeys); + if (!missingMandatoryKeys.isEmpty() || !invalidKeys.isEmpty() || !missingMandatoryOrKeys.isEmpty()) { this.validationError = ValidationError.INVALID_CONFIGURATION; return ValidationResult.error(RestStatus.BAD_REQUEST, this); @@ -196,6 +198,11 @@ private ValidationResult validateDataType(final JsonNode jsonContent) if (dataType != null) { JsonToken valueToken = parser.nextToken(); switch (dataType) { + case INTEGER: + if (valueToken != JsonToken.VALUE_NUMBER_INT) { + wrongDataTypes.put(currentName, "Integer expected"); + } + break; case STRING: if (valueToken != JsonToken.VALUE_STRING) { wrongDataTypes.put(currentName, "String expected"); diff --git a/src/main/java/org/opensearch/security/filter/SecurityFilter.java b/src/main/java/org/opensearch/security/filter/SecurityFilter.java index ccfbc620c5..9b3cda74b3 100644 --- a/src/main/java/org/opensearch/security/filter/SecurityFilter.java +++ b/src/main/java/org/opensearch/security/filter/SecurityFilter.java @@ -185,7 +185,7 @@ private void ap } if (threadContext.getTransient(ConfigConstants.USE_JDK_SERIALIZATION) == null) { - threadContext.putTransient(ConfigConstants.USE_JDK_SERIALIZATION, false); + threadContext.putTransient(ConfigConstants.USE_JDK_SERIALIZATION, true); } final ComplianceConfig complianceConfig = auditLog.getComplianceConfig(); diff --git a/src/main/java/org/opensearch/security/securityconf/impl/v7/ConfigV7.java b/src/main/java/org/opensearch/security/securityconf/impl/v7/ConfigV7.java index 0638d7a884..fb406fd83a 100644 --- a/src/main/java/org/opensearch/security/securityconf/impl/v7/ConfigV7.java +++ b/src/main/java/org/opensearch/security/securityconf/impl/v7/ConfigV7.java @@ -52,6 +52,12 @@ public class ConfigV7 { + public static int ALLOWED_TRIES_DEFAULT = 10; + public static int TIME_WINDOW_SECONDS_DEFAULT = 60 * 60; + public static int BLOCK_EXPIRY_SECONDS_DEFAULT = 60 * 10; + public static int MAX_BLOCKED_CLIENTS_DEFAULT = 100_000; + public static int MAX_TRACKED_CLIENTS_DEFAULT = 100_000; + public Dynamic dynamic; public ConfigV7() { @@ -227,11 +233,11 @@ public static class AuthFailureListener { public String type; public String authentication_backend; public List ignore_hosts; - public int allowed_tries = 10; - public int time_window_seconds = 60 * 60; - public int block_expiry_seconds = 60 * 10; - public int max_blocked_clients = 100_000; - public int max_tracked_clients = 100_000; + public int allowed_tries = ALLOWED_TRIES_DEFAULT; + public int time_window_seconds = TIME_WINDOW_SECONDS_DEFAULT; + public int block_expiry_seconds = BLOCK_EXPIRY_SECONDS_DEFAULT; + public int max_blocked_clients = MAX_BLOCKED_CLIENTS_DEFAULT; + public int max_tracked_clients = MAX_TRACKED_CLIENTS_DEFAULT; public AuthFailureListener() { super(); @@ -248,6 +254,26 @@ public AuthFailureListener(ConfigV6.AuthFailureListener v6) { this.max_tracked_clients = v6.max_tracked_clients; } + public AuthFailureListener( + String type, + String authentication_backend, + List ignore_hosts, + int allowed_tries, + int time_window_seconds, + int block_expiry_seconds, + int max_blocked_clients, + int max_tracked_clients + ) { + this.type = type; + this.authentication_backend = authentication_backend; + this.ignore_hosts = ignore_hosts; + this.allowed_tries = allowed_tries; + this.time_window_seconds = time_window_seconds; + this.block_expiry_seconds = block_expiry_seconds; + this.max_blocked_clients = max_blocked_clients; + this.max_tracked_clients = max_tracked_clients; + } + @JsonIgnore public String asJson() { try { diff --git a/src/main/java/org/opensearch/security/ssl/DefaultSecurityKeyStore.java b/src/main/java/org/opensearch/security/ssl/DefaultSecurityKeyStore.java index 9be2582b7f..8dbd2f139a 100644 --- a/src/main/java/org/opensearch/security/ssl/DefaultSecurityKeyStore.java +++ b/src/main/java/org/opensearch/security/ssl/DefaultSecurityKeyStore.java @@ -18,6 +18,7 @@ package org.opensearch.security.ssl; import java.io.File; +import java.lang.reflect.Method; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.LinkOption; @@ -1223,9 +1224,10 @@ private List getOtherName(List altName) { final ASN1Sequence sequence = ASN1Sequence.getInstance(asn1Primitive); final ASN1ObjectIdentifier asn1ObjectIdentifier = ASN1ObjectIdentifier.getInstance(sequence.getObjectAt(0)); final ASN1TaggedObject asn1TaggedObject = ASN1TaggedObject.getInstance(sequence.getObjectAt(1)); - ASN1Object maybeTaggedAsn1Primitive = asn1TaggedObject.getBaseObject(); + Method getObjectMethod = getObjectMethod(); + ASN1Object maybeTaggedAsn1Primitive = (ASN1Primitive) getObjectMethod.invoke(asn1TaggedObject); if (maybeTaggedAsn1Primitive instanceof ASN1TaggedObject) { - maybeTaggedAsn1Primitive = ASN1TaggedObject.getInstance(maybeTaggedAsn1Primitive).getBaseObject(); + maybeTaggedAsn1Primitive = (ASN1Primitive) getObjectMethod.invoke(maybeTaggedAsn1Primitive); } if (maybeTaggedAsn1Primitive instanceof ASN1String) { return ImmutableList.of(asn1ObjectIdentifier.getId(), maybeTaggedAsn1Primitive.toString()); @@ -1237,4 +1239,13 @@ private List getOtherName(List altName) { throw new RuntimeException("Couldn't parse subject alternative names", ioe); } } + + static Method getObjectMethod() throws ClassNotFoundException, NoSuchMethodException { + Class asn1TaggedObjectClass = Class.forName("org.bouncycastle.asn1.ASN1TaggedObject"); + try { + return asn1TaggedObjectClass.getMethod("getBaseObject"); + } catch (NoSuchMethodException ex) { + return asn1TaggedObjectClass.getMethod("getObject"); + } + } } diff --git a/src/main/java/org/opensearch/security/support/Base64Helper.java b/src/main/java/org/opensearch/security/support/Base64Helper.java index a5fbab8515..7e104ace54 100644 --- a/src/main/java/org/opensearch/security/support/Base64Helper.java +++ b/src/main/java/org/opensearch/security/support/Base64Helper.java @@ -35,11 +35,11 @@ public static String serializeObject(final Serializable object, final boolean us } public static String serializeObject(final Serializable object) { - return serializeObject(object, false); + return serializeObject(object, true); } public static Serializable deserializeObject(final String string) { - return deserializeObject(string, false); + return deserializeObject(string, true); } public static Serializable deserializeObject(final String string, final boolean useJDKDeserialization) { @@ -69,4 +69,28 @@ public static String ensureJDKSerialized(final String string) { // If we see an exception now, we want the caller to see it - return Base64Helper.serializeObject(serializable, true); } + + /** + * Ensures that the returned string is custom serialized. + * + * If the supplied string is a JDK serialized representation, will deserialize it and further serialize using + * custom, otherwise returns the string as is. + * + * @param string original string, can be JDK or custom serialized + * @return custom serialized string + */ + public static String ensureCustomSerialized(final String string) { + Serializable serializable; + try { + serializable = Base64Helper.deserializeObject(string, true); + } catch (Exception e) { + // We received an exception when de-serializing the given string. It is probably custom serialized. + // Try to deserialize using custom + Base64Helper.deserializeObject(string, false); + // Since we could deserialize the object using custom, the string is already custom serialized, return as is + return string; + } + // If we see an exception now, we want the caller to see it - + return Base64Helper.serializeObject(serializable, false); + } } diff --git a/src/main/java/org/opensearch/security/support/SecurityJsonNode.java b/src/main/java/org/opensearch/security/support/SecurityJsonNode.java index 04a6fabf5c..ffd4fbd68a 100644 --- a/src/main/java/org/opensearch/security/support/SecurityJsonNode.java +++ b/src/main/java/org/opensearch/security/support/SecurityJsonNode.java @@ -52,6 +52,14 @@ public String asString() { } } + public Integer asInt(Integer defaultValue) { + if (isNull(node)) { + return defaultValue; + } else { + return node.asInt(0); + } + } + private static boolean isNull(JsonNode node) { return node == null || node.isNull(); } diff --git a/src/main/java/org/opensearch/security/transport/SecurityInterceptor.java b/src/main/java/org/opensearch/security/transport/SecurityInterceptor.java index e88b91487f..099e025c31 100644 --- a/src/main/java/org/opensearch/security/transport/SecurityInterceptor.java +++ b/src/main/java/org/opensearch/security/transport/SecurityInterceptor.java @@ -241,13 +241,22 @@ && getThreadContext().getHeader(ConfigConstants.OPENDISTRO_SECURITY_INJECTED_ROL } try { - if (serializationFormat == SerializationFormat.JDK) { - Map jdkSerializedHeaders = new HashMap<>(); - HeaderHelper.getAllSerializedHeaderNames() - .stream() - .filter(k -> headerMap.get(k) != null) - .forEach(k -> jdkSerializedHeaders.put(k, Base64Helper.ensureJDKSerialized(headerMap.get(k)))); - headerMap.putAll(jdkSerializedHeaders); + if (clusterInfoHolder.getMinNodeVersion() == null || clusterInfoHolder.getMinNodeVersion().before(Version.V_2_14_0)) { + if (serializationFormat == SerializationFormat.JDK) { + Map jdkSerializedHeaders = new HashMap<>(); + HeaderHelper.getAllSerializedHeaderNames() + .stream() + .filter(k -> headerMap.get(k) != null) + .forEach(k -> jdkSerializedHeaders.put(k, Base64Helper.ensureJDKSerialized(headerMap.get(k)))); + headerMap.putAll(jdkSerializedHeaders); + } else if (serializationFormat == SerializationFormat.CustomSerializer_2_11) { + Map customSerializedHeaders = new HashMap<>(); + HeaderHelper.getAllSerializedHeaderNames() + .stream() + .filter(k -> headerMap.get(k) != null) + .forEach(k -> customSerializedHeaders.put(k, Base64Helper.ensureCustomSerialized(headerMap.get(k)))); + headerMap.putAll(customSerializedHeaders); + } } getThreadContext().putHeader(headerMap); } catch (IllegalArgumentException iae) { diff --git a/src/test/java/org/opensearch/security/dlic/rest/api/AbstractApiActionValidationTest.java b/src/test/java/org/opensearch/security/dlic/rest/api/AbstractApiActionValidationTest.java index b91374e725..39ff609c06 100644 --- a/src/test/java/org/opensearch/security/dlic/rest/api/AbstractApiActionValidationTest.java +++ b/src/test/java/org/opensearch/security/dlic/rest/api/AbstractApiActionValidationTest.java @@ -145,7 +145,8 @@ protected List restApiAdminPermissions() { "restapi:admin/rolesmapping", "restapi:admin/ssl/certs/info", "restapi:admin/ssl/certs/reload", - "restapi:admin/tenants" + "restapi:admin/tenants", + "restapi:admin/ratelimiters" ); } diff --git a/src/test/java/org/opensearch/security/dlic/rest/api/RateLimitersApiActionTest.java b/src/test/java/org/opensearch/security/dlic/rest/api/RateLimitersApiActionTest.java new file mode 100644 index 0000000000..9b2bad983a --- /dev/null +++ b/src/test/java/org/opensearch/security/dlic/rest/api/RateLimitersApiActionTest.java @@ -0,0 +1,209 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.dlic.rest.api; + +import org.apache.hc.core5.http.Header; +import org.apache.http.HttpStatus; +import org.junit.Test; + +import org.opensearch.security.test.helper.rest.RestHelper; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.core.IsEqual.equalTo; +import static org.hamcrest.core.StringContains.containsString; + +public class RateLimitersApiActionTest extends AbstractRestApiUnitTest { + + private static final Header ADMIN_FULL_ACCESS_USER = encodeBasicHeader("admin_all_access", "admin_all_access"); + private static final Header USER_NO_REST_API_ACCESS = encodeBasicHeader("admin", "admin"); + + @Test + public void testForbiddenAccess() throws Exception { + setupWithRestRoles(); + + rh.sendAdminCertificate = false; + RestHelper.HttpResponse getSettingResponse = rh.executeGetRequest( + "/_plugins/_security/api/authfailurelisteners", + USER_NO_REST_API_ACCESS + ); + assertThat(getSettingResponse.getBody(), getSettingResponse.getStatusCode(), equalTo(HttpStatus.SC_FORBIDDEN)); + RestHelper.HttpResponse updateSettingResponse = rh.executePutRequest( + "/_plugins/_security/api/authfailurelisteners/test", + "{\"type\":\"ip\",\"allowed_tries\":10,\"time_window_seconds\":3600,\"block_expiry_seconds\":600,\"max_blocked_clients\":100000,\"max_tracked_clients\":100000}", + USER_NO_REST_API_ACCESS + ); + assertThat(getSettingResponse.getBody(), updateSettingResponse.getStatusCode(), equalTo(HttpStatus.SC_FORBIDDEN)); + RestHelper.HttpResponse deleteSettingResponse = rh.executeDeleteRequest( + "/_plugins/_security/api/authfailurelisteners/test", + USER_NO_REST_API_ACCESS + ); + assertThat(getSettingResponse.getBody(), updateSettingResponse.getStatusCode(), equalTo(HttpStatus.SC_FORBIDDEN)); + } + + @Test + public void testFullAccess() throws Exception { + setupWithRestRoles(); + rh.sendAdminCertificate = true; + // Initial get returns no auth failure listeners + RestHelper.HttpResponse getAuthFailuresResponse = rh.executeGetRequest( + "/_plugins/_security/api/authfailurelisteners", + ADMIN_FULL_ACCESS_USER + ); + assertThat(getAuthFailuresResponse.getBody(), getAuthFailuresResponse.getStatusCode(), equalTo(HttpStatus.SC_OK)); + assertThat(getAuthFailuresResponse.getBody(), getAuthFailuresResponse.getBody(), equalTo("{}")); + + // Put a test auth failure listener + RestHelper.HttpResponse updateAuthFailuresResponse = rh.executePutRequest( + "/_plugins/_security/api/authfailurelisteners/test", + "{\"type\":\"ip\",\"allowed_tries\":10,\"time_window_seconds\":3600,\"block_expiry_seconds\":600,\"max_blocked_clients\":100000,\"max_tracked_clients\":100000}", + ADMIN_FULL_ACCESS_USER + ); + assertThat(updateAuthFailuresResponse.getBody(), updateAuthFailuresResponse.getStatusCode(), equalTo(HttpStatus.SC_OK)); + + // Get after put returns the test auth failure listener + RestHelper.HttpResponse getAuthFailuresResponseAfterPut = rh.executeGetRequest( + "/_plugins/_security/api/authfailurelisteners", + ADMIN_FULL_ACCESS_USER + ); + assertThat(getAuthFailuresResponseAfterPut.getBody(), getAuthFailuresResponseAfterPut.getStatusCode(), equalTo(HttpStatus.SC_OK)); + assertThat( + getAuthFailuresResponseAfterPut.getBody(), + getAuthFailuresResponseAfterPut.getBody(), + equalTo( + "{\"test\":{\"name\":\"test\",\"type\":\"ip\",\"ignore_hosts\":[],\"authentication_backend\":null,\"allowed_tries\":10,\"time_window_seconds\":3600,\"block_expiry_seconds\":600,\"max_blocked_clients\":100000,\"max_tracked_clients\":100000}}" + ) + ); + + // Delete the test auth failure listener + RestHelper.HttpResponse deleteAuthFailuresResponse = rh.executeDeleteRequest( + "/_plugins/_security/api/authfailurelisteners/test", + ADMIN_FULL_ACCESS_USER + ); + assertThat(deleteAuthFailuresResponse.getBody(), deleteAuthFailuresResponse.getStatusCode(), equalTo(HttpStatus.SC_OK)); + + // Get after delete returns no auth failure listener + RestHelper.HttpResponse getAuthFailuresResponseAfterDelete = rh.executeGetRequest( + "/_plugins/_security/api/authfailurelisteners", + ADMIN_FULL_ACCESS_USER + ); + assertThat( + getAuthFailuresResponseAfterDelete.getBody(), + getAuthFailuresResponseAfterDelete.getStatusCode(), + equalTo(HttpStatus.SC_OK) + ); + assertThat(getAuthFailuresResponseAfterDelete.getBody(), getAuthFailuresResponseAfterDelete.getBody(), equalTo("{}")); + } + + @Test + public void testInvalidDeleteScenarios() throws Exception { + setupWithRestRoles(); + + rh.sendAdminCertificate = true; + RestHelper.HttpResponse deleteAuthFailuresResponseNoExist = rh.executeDeleteRequest( + "/_plugins/_security/api/authfailurelisteners/test", + ADMIN_FULL_ACCESS_USER + ); + assertThat( + deleteAuthFailuresResponseNoExist.getBody(), + deleteAuthFailuresResponseNoExist.getStatusCode(), + equalTo(HttpStatus.SC_NOT_FOUND) + ); + assertThat(deleteAuthFailuresResponseNoExist.getBody(), containsString("listener not found")); + + } + + @Test + public void testInvalidPutScenarios() throws Exception { + setupWithRestRoles(); + + rh.sendAdminCertificate = true; + RestHelper.HttpResponse updateAuthFailuresResponseNoBackend = rh.executePutRequest( + "/_plugins/_security/api/authfailurelisteners/test", + "{\"type\":\"username\",\"allowed_tries\":10,\"time_window_seconds\":3600,\"block_expiry_seconds\":600,\"max_blocked_clients\":100000,\"max_tracked_clients\":100000}", + ADMIN_FULL_ACCESS_USER + ); + assertThat( + updateAuthFailuresResponseNoBackend.getBody(), + updateAuthFailuresResponseNoBackend.getStatusCode(), + equalTo(HttpStatus.SC_BAD_REQUEST) + ); + + RestHelper.HttpResponse updateAuthFailuresResponseNoType = rh.executePutRequest( + "/_plugins/_security/api/authfailurelisteners/test", + "{\"allowed_tries\":10,\"time_window_seconds\":3600,\"block_expiry_seconds\":600,\"max_blocked_clients\":100000,\"max_tracked_clients\":100000}", + ADMIN_FULL_ACCESS_USER + ); + assertThat( + updateAuthFailuresResponseNoType.getBody(), + updateAuthFailuresResponseNoType.getStatusCode(), + equalTo(HttpStatus.SC_BAD_REQUEST) + ); + + } + + @Test + public void testPutWithAllDefaults() throws Exception { + setupWithRestRoles(); + rh.sendAdminCertificate = true; + + // Put a test auth failure listener + RestHelper.HttpResponse updateAuthFailuresResponse = rh.executePutRequest( + "/_plugins/_security/api/authfailurelisteners/test", + "{\"type\":\"ip\"}", + ADMIN_FULL_ACCESS_USER + ); + assertThat(updateAuthFailuresResponse.getBody(), updateAuthFailuresResponse.getStatusCode(), equalTo(HttpStatus.SC_OK)); + + // Get after put returns the test auth failure listener with proper defaults set + RestHelper.HttpResponse getAuthFailuresResponseAfterPut = rh.executeGetRequest( + "/_plugins/_security/api/authfailurelisteners", + ADMIN_FULL_ACCESS_USER + ); + assertThat(getAuthFailuresResponseAfterPut.getBody(), getAuthFailuresResponseAfterPut.getStatusCode(), equalTo(HttpStatus.SC_OK)); + assertThat( + getAuthFailuresResponseAfterPut.getBody(), + getAuthFailuresResponseAfterPut.getBody(), + equalTo( + "{\"test\":{\"name\":\"test\",\"type\":\"ip\",\"ignore_hosts\":[],\"authentication_backend\":null,\"allowed_tries\":10,\"time_window_seconds\":3600,\"block_expiry_seconds\":600,\"max_blocked_clients\":100000,\"max_tracked_clients\":100000}}" + ) + ); + } + + @Test + public void testPutWithSomeDefaults() throws Exception { + setupWithRestRoles(); + rh.sendAdminCertificate = true; + + // Put another test auth failure listener with some fields provided + RestHelper.HttpResponse updateAuthFailuresResponse = rh.executePutRequest( + "/_plugins/_security/api/authfailurelisteners/test", + "{\"type\":\"ip\",\"allowed_tries\":88,\"time_window_seconds\":12345}", + ADMIN_FULL_ACCESS_USER + ); + assertThat(updateAuthFailuresResponse.getBody(), updateAuthFailuresResponse.getStatusCode(), equalTo(HttpStatus.SC_OK)); + + // Get after put returns the test auth failure listener with proper defaults set + RestHelper.HttpResponse getAuthFailuresResponseAfterPut = rh.executeGetRequest( + "/_plugins/_security/api/authfailurelisteners", + ADMIN_FULL_ACCESS_USER + ); + assertThat(getAuthFailuresResponseAfterPut.getBody(), getAuthFailuresResponseAfterPut.getStatusCode(), equalTo(HttpStatus.SC_OK)); + assertThat( + getAuthFailuresResponseAfterPut.getBody(), + getAuthFailuresResponseAfterPut.getBody(), + equalTo( + "{\"test\":{\"name\":\"test\",\"type\":\"ip\",\"ignore_hosts\":[],\"authentication_backend\":null,\"allowed_tries\":88,\"time_window_seconds\":12345,\"block_expiry_seconds\":600,\"max_blocked_clients\":100000,\"max_tracked_clients\":100000}}" + ) + ); + } + +} diff --git a/src/test/java/org/opensearch/security/dlic/rest/api/RateLimitersApiActionValidationTest.java b/src/test/java/org/opensearch/security/dlic/rest/api/RateLimitersApiActionValidationTest.java new file mode 100644 index 0000000000..6191a043ff --- /dev/null +++ b/src/test/java/org/opensearch/security/dlic/rest/api/RateLimitersApiActionValidationTest.java @@ -0,0 +1,70 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.dlic.rest.api; + +import java.io.IOException; + +import org.junit.Test; + +import org.opensearch.core.common.bytes.BytesArray; +import org.opensearch.core.rest.RestStatus; +import org.opensearch.rest.RestRequest; +import org.opensearch.security.DefaultObjectMapper; +import org.opensearch.security.securityconf.impl.v7.ConfigV7; +import org.opensearch.security.util.FakeRestRequest; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class RateLimitersApiActionValidationTest extends AbstractApiActionValidationTest { + + @Test + public void validateAllowedFields() throws IOException { + final var authFailureListenerApiActionRequestContentValidator = new RateLimitersApiAction( + clusterService, + threadPool, + securityApiDependencies + ).createEndpointValidator().createRequestContentValidator(); + + final var authFailureListener = new ConfigV7.AuthFailureListener(); + + final var content = DefaultObjectMapper.writeValueAsString(objectMapper.valueToTree(authFailureListener), false); + + var validResult = authFailureListenerApiActionRequestContentValidator.validate( + FakeRestRequest.builder() + .withMethod(RestRequest.Method.PUT) + .withPath("_plugins/_security/api/authfailurelisteners/test") + .withContent(new BytesArray(content)) + .build() + ); + assertTrue(validResult.isValid()); + + final var invalidContent = objectMapper.createObjectNode() + .set( + "blah", + objectMapper.createObjectNode() + + ); + + var inValidResult = authFailureListenerApiActionRequestContentValidator.validate( + FakeRestRequest.builder() + .withMethod(RestRequest.Method.PUT) + .withPath("_plugins/_security/api/authfailurelisteners/test") + .withContent(new BytesArray(invalidContent.toString())) + .build() + ); + assertFalse(inValidResult.isValid()); + assertThat(inValidResult.status(), is(RestStatus.BAD_REQUEST)); + } +} diff --git a/src/test/java/org/opensearch/security/dlic/rest/validation/EndpointValidatorTest.java b/src/test/java/org/opensearch/security/dlic/rest/validation/EndpointValidatorTest.java index 69bdb6a1a3..a38b28fa5c 100644 --- a/src/test/java/org/opensearch/security/dlic/rest/validation/EndpointValidatorTest.java +++ b/src/test/java/org/opensearch/security/dlic/rest/validation/EndpointValidatorTest.java @@ -405,7 +405,8 @@ private List restAdminPermissions() { "restapi:admin/rolesmapping", "restapi:admin/ssl/certs/info", "restapi:admin/ssl/certs/reload", - "restapi:admin/tenants" + "restapi:admin/tenants", + "restapi:admin/ratelimiters" ); } diff --git a/src/test/java/org/opensearch/security/dlic/rest/validation/RequestContentValidatorTest.java b/src/test/java/org/opensearch/security/dlic/rest/validation/RequestContentValidatorTest.java index 561695106d..72ad0ae76a 100644 --- a/src/test/java/org/opensearch/security/dlic/rest/validation/RequestContentValidatorTest.java +++ b/src/test/java/org/opensearch/security/dlic/rest/validation/RequestContentValidatorTest.java @@ -143,12 +143,18 @@ public Map allowedKeys() { "b", RequestContentValidator.DataType.OBJECT, "c", - RequestContentValidator.DataType.ARRAY + RequestContentValidator.DataType.ARRAY, + "d", + RequestContentValidator.DataType.INTEGER ); } }); - final JsonNode payload = DefaultObjectMapper.objectMapper.createObjectNode().put("a", 1).put("b", "[]").put("c", "{}"); + final JsonNode payload = DefaultObjectMapper.objectMapper.createObjectNode() + .put("a", 1) + .put("b", "[]") + .put("c", "{}") + .put("d", "1"); when(httpRequest.content()).thenReturn(new BytesArray(payload.toString())); final ValidationResult validationResult = validator.validate(request); @@ -159,6 +165,7 @@ public Map allowedKeys() { assertThat(errorMessage.get("a").asText(), is("String expected")); assertThat(errorMessage.get("b").asText(), is("Object expected")); assertThat(errorMessage.get("c").asText(), is("Array expected")); + assertThat(errorMessage.get("d").asText(), is("Integer expected")); } @Test @@ -284,14 +291,16 @@ public Map allowedKeys() { "d", RequestContentValidator.DataType.STRING, "e", - RequestContentValidator.DataType.BOOLEAN + RequestContentValidator.DataType.BOOLEAN, + "f", + RequestContentValidator.DataType.INTEGER ); } }); ObjectNode payload = DefaultObjectMapper.objectMapper.createObjectNode().putObject("a"); payload.putArray("a").add("arrray"); - payload.put("b", true).put("d", "some_string").put("e", "true"); + payload.put("b", true).put("d", "some_string").put("e", "true").put("f", 1); payload.putObject("c"); when(httpRequest.content()).thenReturn(new BytesArray(payload.toString())); diff --git a/src/test/java/org/opensearch/security/ssl/SSLTest.java b/src/test/java/org/opensearch/security/ssl/SSLTest.java index 4e497be468..a6013c7823 100644 --- a/src/test/java/org/opensearch/security/ssl/SSLTest.java +++ b/src/test/java/org/opensearch/security/ssl/SSLTest.java @@ -17,6 +17,7 @@ package org.opensearch.security.ssl; +import java.lang.reflect.Method; import java.net.SocketException; import java.nio.file.Paths; import java.security.Security; @@ -1291,4 +1292,18 @@ public void testHttpsAndNodeSSLPemExtendedUsageEnabled() throws Exception { Assert.assertTrue(res.contains("CN=node-0.example.com,OU=SSL,O=Test,L=Test,C=DE")); Assert.assertTrue(rh.executeSimpleRequest("_nodes/settings?pretty").contains(clusterInfo.clustername)); } + + @Test + public void testGetObjectMethod() { + try { + Method method = DefaultSecurityKeyStore.getObjectMethod(); + Assert.assertNotNull("Method should not be null", method); + Assert.assertTrue( + "One of the expected methods should be available", + method.getName().equals("getBaseObject") || method.getName().equals("getObject") + ); + } catch (ClassNotFoundException | NoSuchMethodException e) { + Assert.fail("Exception should not be thrown: " + e.getMessage()); + } + } } diff --git a/src/test/java/org/opensearch/security/support/Base64HelperTest.java b/src/test/java/org/opensearch/security/support/Base64HelperTest.java index de21c67d52..7c7e68b342 100644 --- a/src/test/java/org/opensearch/security/support/Base64HelperTest.java +++ b/src/test/java/org/opensearch/security/support/Base64HelperTest.java @@ -53,6 +53,15 @@ public void testEnsureJDKSerialized() { assertThat(Base64Helper.ensureJDKSerialized(customSerialized), is(jdkSerialized)); } + @Test + public void testEnsureCustomSerialized() { + String test = "string"; + String jdkSerialized = Base64Helper.serializeObject(test, true); + String customSerialized = Base64Helper.serializeObject(test, false); + assertThat(Base64Helper.ensureCustomSerialized(jdkSerialized), is(customSerialized)); + assertThat(Base64Helper.ensureCustomSerialized(customSerialized), is(customSerialized)); + } + @Test public void testDuplicatedItemSizes() { var largeObject = new HashMap(); diff --git a/src/test/java/org/opensearch/security/transport/SecurityInterceptorTests.java b/src/test/java/org/opensearch/security/transport/SecurityInterceptorTests.java index 42884862a2..d12fafb247 100644 --- a/src/test/java/org/opensearch/security/transport/SecurityInterceptorTests.java +++ b/src/test/java/org/opensearch/security/transport/SecurityInterceptorTests.java @@ -119,9 +119,12 @@ public class SecurityInterceptorTests { private Connection connection3; private DiscoveryNode otherRemoteNode; private Connection connection4; + private DiscoveryNode remoteNodeWithCustomSerialization; + private Connection connection5; private AsyncSender sender; - private AsyncSender serializedSender; + private AsyncSender jdkSerializedSender; + private AsyncSender customSerializedSender; private AtomicReference senderLatch = new AtomicReference<>(new CountDownLatch(1)); @Before @@ -199,7 +202,30 @@ public void setup() { otherRemoteNode = new DiscoveryNode("remote-node2", new TransportAddress(remoteAddress, 9876), remoteNodeVersion); connection4 = transportService.getConnection(otherRemoteNode); - serializedSender = new AsyncSender() { + remoteNodeWithCustomSerialization = new DiscoveryNode( + "remote-node-with-custom-serialization", + new TransportAddress(localAddress, 7456), + Version.V_2_12_0 + ); + connection5 = transportService.getConnection(remoteNodeWithCustomSerialization); + + jdkSerializedSender = new AsyncSender() { + @Override + public void sendRequest( + Connection connection, + String action, + TransportRequest request, + TransportRequestOptions options, + TransportResponseHandler handler + ) { + String serializedUserHeader = threadPool.getThreadContext().getHeader(ConfigConstants.OPENDISTRO_SECURITY_USER_HEADER); + User deserializedUser = (User) Base64Helper.deserializeObject(serializedUserHeader, true); + assertThat(deserializedUser, is(user)); + senderLatch.get().countDown(); + } + }; + + customSerializedSender = new AsyncSender() { @Override public void sendRequest( Connection connection, @@ -209,7 +235,7 @@ public void sendRequest( TransportResponseHandler handler ) { String serializedUserHeader = threadPool.getThreadContext().getHeader(ConfigConstants.OPENDISTRO_SECURITY_USER_HEADER); - assertThat(serializedUserHeader, is(Base64Helper.serializeObject(user, true))); + assertThat(serializedUserHeader, is(Base64Helper.serializeObject(user, false))); senderLatch.get().countDown(); } }; @@ -265,6 +291,27 @@ final void completableRequestDecorate( senderLatch.set(new CountDownLatch(1)); } + @SuppressWarnings({ "rawtypes", "unchecked" }) + final void completableRequestDecorateWithPreviouslyPopulatedHeaders( + AsyncSender sender, + Connection connection, + String action, + TransportRequest request, + TransportRequestOptions options, + TransportResponseHandler handler, + DiscoveryNode localNode + ) { + securityInterceptor.sendRequestDecorate(sender, connection, action, request, options, handler, localNode); + try { + senderLatch.get().await(1, TimeUnit.SECONDS); + } catch (final InterruptedException e) { + throw new RuntimeException(e); + } + + // Reset the latch so another request can be processed + senderLatch.set(new CountDownLatch(1)); + } + @Test public void testSendRequestDecorateLocalConnection() { @@ -278,16 +325,44 @@ public void testSendRequestDecorateLocalConnection() { public void testSendRequestDecorateRemoteConnection() { // this is a remote request - completableRequestDecorate(serializedSender, connection3, action, request, options, handler, localNode); + completableRequestDecorate(jdkSerializedSender, connection3, action, request, options, handler, localNode); // this is a remote request where the transport address is different - completableRequestDecorate(serializedSender, connection4, action, request, options, handler, localNode); + completableRequestDecorate(jdkSerializedSender, connection4, action, request, options, handler, localNode); + } + + @Test + public void testSendRequestDecorateRemoteConnectionUsesJDKSerialization() { + threadPool.getThreadContext().putHeader(ConfigConstants.OPENDISTRO_SECURITY_USER_HEADER, Base64Helper.serializeObject(user, false)); + completableRequestDecorateWithPreviouslyPopulatedHeaders( + jdkSerializedSender, + connection3, + action, + request, + options, + handler, + localNode + ); + } + + @Test + public void testSendRequestDecorateRemoteConnectionUsesCustomSerialization() { + threadPool.getThreadContext().putHeader(ConfigConstants.OPENDISTRO_SECURITY_USER_HEADER, Base64Helper.serializeObject(user, true)); + completableRequestDecorateWithPreviouslyPopulatedHeaders( + customSerializedSender, + connection5, + action, + request, + options, + handler, + localNode + ); } @Test public void testSendNoOriginNodeCausesSerialization() { // this is a request where the local node is null; have to use the remote connection since the serialization will fail - completableRequestDecorate(serializedSender, connection3, action, request, options, handler, null); + completableRequestDecorate(jdkSerializedSender, connection3, action, request, options, handler, null); } @Test @@ -296,7 +371,7 @@ public void testSendNoConnectionShouldThrowNPE() { // The completable version swallows the NPE so have to call actual method assertThrows( java.lang.NullPointerException.class, - () -> securityInterceptor.sendRequestDecorate(serializedSender, null, action, request, options, handler, localNode) + () -> securityInterceptor.sendRequestDecorate(jdkSerializedSender, null, action, request, options, handler, localNode) ); } @@ -328,7 +403,7 @@ public void testCustomRemoteAddressCausesSerialization() { ConfigConstants.OPENDISTRO_SECURITY_REMOTE_ADDRESS, String.valueOf(new TransportAddress(new InetSocketAddress("8.8.8.8", 80))) ); - completableRequestDecorate(serializedSender, connection3, action, request, options, handler, localNode); + completableRequestDecorate(jdkSerializedSender, connection3, action, request, options, handler, localNode); } @Test @@ -351,7 +426,7 @@ public void testFakeHeaderIsIgnored() { // this is a local request completableRequestDecorate(sender, connection1, action, request, options, handler, localNode); // this is a remote request - completableRequestDecorate(serializedSender, connection3, action, request, options, handler, localNode); + completableRequestDecorate(jdkSerializedSender, connection3, action, request, options, handler, localNode); } @Test @@ -363,7 +438,7 @@ public void testNullHeaderIsIgnored() { // this is a local request completableRequestDecorate(sender, connection1, action, request, options, handler, localNode); // this is a remote request - completableRequestDecorate(serializedSender, connection3, action, request, options, handler, localNode); + completableRequestDecorate(jdkSerializedSender, connection3, action, request, options, handler, localNode); } @Test