diff --git a/doc/release-notes/Filter-efficiency.md b/doc/release-notes/Filter-efficiency.md new file mode 100644 index 00000000000..aacf5e82cdc --- /dev/null +++ b/doc/release-notes/Filter-efficiency.md @@ -0,0 +1,70 @@ +### Improved efficiency for per-request Filters + +This release improves the performance of Dataverse's per-request handling of CORS Headers and API calls + +It adds new jvm-options/Microprofile settings replacing the now deprecated database settings. + +Additional changes: + +- CORS headers can now be configured with a list of desired origins, methods, and allowed and exposed headers. +- An 'X-Dataverse-unblock-key' header has been added that can be used instead of the less secure 'unblock-key' query parameter when the :BlockedApiPolicy is set to 'unblock-key' +- Warnings have been added to the log if the Blocked Api settings are misconfigured or if the key is weak (when the "unblock-key" policy is used) +- The new `dataverse.api.blocked.key` can be configured using Payara password aliases or other secure storage options. + +New JvmSettings: +- `dataverse.cors.origin`: Allowed origins for CORS requests +- `dataverse.cors.methods`: Allowed HTTP methods for CORS requests +- `dataverse.cors.headers.allow`: Allowed headers for CORS requests +- `dataverse.cors.headers.expose`: Headers to expose in CORS responses +- `dataverse.api.blocked.policy`: Policy for blocking API endpoints +- `dataverse.api.blocked.endpoints`: List of API endpoints to be blocked (comma-separated) +- `dataverse.api.blocked.key`: Key for unblocking API endpoints + +Deprecated database settings: +- `:AllowCors` +- `:BlockedApiPolicy` +- `:BlockedApiEndpoints` +- `:BlockedApiKey` + + +Upgrade instructions: + +The deprecated database settings will continue to work in this version. To use the new settings (which are more efficient), + +If :AllowCors is not set or is true: +bin/asadmin create-jvm-options -Ddataverse.cors.origin=* + +Optionally set origin to a list of hosts and/or set other CORS JvmSettings +Your currently blocked API endpoints can be found at http://localhost:8080/api/admin/settings/:BlockedApiEndpoints + +Copy them into the new setting with the following command. As with the deprecated setting, the endpoints should be comma-separated. + +bin/asadmin create-jvm-options '-Ddataverse.api.blocked.endpoints=' + +If :BlockedApiPolicy is set and is not 'drop' +bin/asadmin create-jvm-options '-Ddataverse.api.blocked.policy=' + +If :BlockedApiPolicy is 'unblock-key' and :BlockedApiKey is set + + `echo "API_BLOCKED_KEY_ALIAS=" > /tmp/dataverse.api.blocked.key.txt` + + `sudo -u dataverse /usr/local/payara6/bin/asadmin create-password-alias --passwordfile /tmp/dataverse.api.blocked.key.txt` + + When you are prompted "Enter the value for the aliasname operand", enter `api_blocked_key_alias` + + You should see "Command create-password-alias executed successfully." + + bin/asadmin create-jvm-options '-Ddataverse.api.blocked.key=${ALIAS=api_blocked_key_alias}' + + Restart Payara: + +service payara restart + +Check server.log to verify that your new settings are in effect. + +Cleanup: delete deprecated settings: +curl -X DELETE http://localhost:8080/api/admin/settings/:AllowCors +curl -X DELETE http://localhost:8080/api/admin/settings/:BlockedApiEndpoints +curl -X DELETE http://localhost:8080/api/admin/settings/:BlockedApiPolicy +curl -X DELETE http://localhost:8080/api/admin/settings/:BlockedApiKey + diff --git a/doc/sphinx-guides/source/installation/config.rst b/doc/sphinx-guides/source/installation/config.rst index d22ffb64b24..ac6b9e48347 100644 --- a/doc/sphinx-guides/source/installation/config.rst +++ b/doc/sphinx-guides/source/installation/config.rst @@ -25,20 +25,47 @@ The default password for the "dataverseAdmin" superuser account is "admin", as m Blocking API Endpoints ++++++++++++++++++++++ -The :doc:`/api/native-api` contains a useful but potentially dangerous API endpoint called "admin" that allows you to change system settings, make ordinary users into superusers, and more. The "builtin-users" endpoint lets admins create a local/builtin user account if they know the key defined in :ref:`BuiltinUsers.KEY`. +The :doc:`/api/native-api` contains a useful but potentially dangerous set of API endpoints called "admin" that allows you to change system settings, make ordinary users into superusers, and more. The "builtin-users" endpoints let admins do tasks such as creating a local/builtin user account if they know the key defined in :ref:`BuiltinUsers.KEY`. -By default, most APIs can be operated on remotely and a number of endpoints do not require authentication. The endpoints "admin" and "builtin-users" are limited to localhost out of the box by the settings :ref:`:BlockedApiEndpoints` and :ref:`:BlockedApiPolicy`. +By default in the code, most of these API endpoints can be operated on remotely and a number of endpoints do not require authentication. However, the endpoints "admin" and "builtin-users" are limited to localhost out of the box by the installer, using the JvmSettings :ref:`dataverse.api.blocked.endpoints` and :ref:`dataverse.api.blocked.policy`. -It is very important to keep the block in place for the "admin" endpoint, and to leave the "builtin-users" endpoint blocked unless you need to access it remotely. Documentation for the "admin" endpoint is spread across the :doc:`/api/native-api` section of the API Guide and the :doc:`/admin/index`. +.. note:: + The database settings :ref:`:BlockedApiEndpoints` and :ref:`:BlockedApiPolicy` are deprecated and will be removed in a future version. Please use the JvmSettings mentioned above instead. -It's also possible to prevent file uploads via API by adjusting the :ref:`:UploadMethods` database setting. +It is **very important** to keep the block in place for the "admin" endpoint, and to leave the "builtin-users" endpoint blocked unless you need to access it remotely. Documentation for the "admin" endpoint is spread across the :doc:`/api/native-api` section of the API Guide and the :doc:`/admin/index`. +Given how important it is to avoid exposing the "admin" and "builtin-user" APIs, sites using a proxy, e.g. Apache or Nginx, should also consider blocking them through rules in the proxy. +The following examples may be useful: + +Apache/Httpd Rule: + +Rewrite lines added to /etc/httpd/conf.d/ssl.conf. They can be the first lines inserted after the RewriteEngine On statement: + +.. code-block:: apache + + RewriteRule ^/api/(admin|builtin-users) - [R=403,L] + RewriteRule ^/api/(v[0-9]*)/(admin|builtin-users) - [R=403,L] + +Nginx Configuration Rule: + +.. code-block:: nginx + + location ~ ^/api/(admin|v1/admin|builtin-users|v1/builtin-users) { + deny all; + return 403; + } + If you are using a load balancer or a reverse proxy, there are some additional considerations. If no additional configurations are made and the upstream is configured to redirect to localhost, the API will be accessible from the outside, as your installation will register as origin the localhost for any requests to the endpoints "admin" and "builtin-users". To prevent this, you have two options: - If your upstream is configured to redirect to localhost, you will need to set the :ref:`JVM option ` to one of the following values ``%client.name% %datetime% %request% %status% %response.length% %header.referer% %header.x-forwarded-for%`` and configure from the load balancer side the chosen header to populate with the client IP address. - Another solution is to set the upstream to the client IP address. In this case no further configuration is needed. +For more information on configuring blocked API endpoints, see :ref:`dataverse.api.blocked.endpoints` and :ref:`dataverse.api.blocked.policy` in the JvmSettings documentation. + +.. note:: + It's also possible to prevent file uploads via API by adjusting the :ref:`:UploadMethods` database setting. + Forcing HTTPS +++++++++++++ @@ -3153,6 +3180,64 @@ Defaults to ``false``. Can also be set via any `supported MicroProfile Config API source`_, e.g. the environment variable ``DATAVERSE_API_ALLOW_INCOMPLETE_METADATA``. Will accept ``[tT][rR][uU][eE]|1|[oO][nN]`` as "true" expressions. +.. _dataverse.api.blocked.endpoints: + +dataverse.api.blocked.endpoints ++++++++++++++++++++++++++++++++ + +A comma-separated list of API endpoints that should be blocked. A minimal example that blocks endpoints for security reasons: + +``./asadmin create-jvm-options '-Ddataverse.api.blocked.endpoints=api/admin,api/builtin-users'`` + +Another example: + +``./asadmin create-jvm-options '-Ddataverse.api.blocked.endpoints=api/admin,api/builtin-users,api/datasets/:persistentId/versions/:versionId/files,api/files/:id'`` + +Defaults to an empty string (no endpoints blocked), but, in almost all cases, should include at least ``admin, builtin-users`` as a security measure. + +For more information on API blocking, see :ref:`blocking-api-endpoints` in the Admin Guide. + +Can also be set via any `supported MicroProfile Config API source`_, e.g. the environment variable ``DATAVERSE_API_BLOCKED_ENDPOINTS``. + +.. _dataverse.api.blocked.policy: + +dataverse.api.blocked.policy +++++++++++++++++++++++++++++ + +Specifies how to treat blocked API endpoints. Valid values are: + +- ``drop``: Blocked requests are dropped (default). +- ``localhost-only``: Blocked requests are only allowed from localhost. +- ``unblock-key``: Blocked requests are allowed if they include a valid unblock key. + +For example: + +``./asadmin create-jvm-options '-Ddataverse.api.blocked.policy=localhost-only'`` + +Can also be set via any `supported MicroProfile Config API source`_, e.g. the environment variable ``DATAVERSE_API_BLOCKED_POLICY``. + +.. note:: + This setting will be ignored unless the :ref:`dataverse.api.blocked.endpoints` and, for the unblock-key policy, the :ref:`dataverse.api.blocked.key` are also set. Otherwise the deprecated :ref:`:BlockedApiPolicy` will be used + +.. _dataverse.api.blocked.key: + +dataverse.api.blocked.key ++++++++++++++++++++++++++ + +When the blocked API policy is set to ``unblock-key``, this setting specifies the key that allows access to blocked endpoints. For example: + +``./asadmin create-jvm-options '-Ddataverse.api.blocked.key=your-secret-key-here'`` + +**WARNING**: +*Since the blocked API key is sensitive, you should treat it like a password.* +*See* :ref:`secure-password-storage` *to learn about ways to safeguard it.* + +Can also be set via any `supported MicroProfile Config API source`_, e.g. the environment variable ``DATAVERSE_API_BLOCKED_KEY`` (although you shouldn't use environment variables for sensitive information). + +.. note:: + This setting will be ignored unless the :ref:`dataverse.api.blocked.policy` is set to ``unblock-key``. Otherwise the deprecated :ref:`:BlockedApiKey` will be used + + .. _dataverse.ui.show-validity-label-when-published: dataverse.ui.show-validity-label-when-published @@ -3481,6 +3566,70 @@ This setting allows admins to highlight a few of the 1000+ CSL citation styles a These will be listed above the alphabetical list of all styles in the "View Styled Citations" pop-up. The default value when not set is "chicago-author-date, ieee". +.. _dataverse.cors: + +CORS Settings +------------- + +The following settings control Cross-Origin Resource Sharing (CORS) for your Dataverse installation. + +.. _dataverse.cors.origin: + +dataverse.cors.origin ++++++++++++++++++++++ + +Allowed origins for CORS requests. The default with no value set is to not include CORS headers. However, if the deprecated :AllowCors setting is explicitly set to true the default is "\*" (all origins). +When the :AllowsCors setting is not used, you must set this setting to "\*" or a list of origins to enable CORS headers. + +Multiple origins can be specified as a comma-separated list. + +Example: + +``./asadmin create-jvm-options '-Ddataverse.cors.origin=https://example.com,https://subdomain.example.com'`` + +Can also be set via any `supported MicroProfile Config API source`_, e.g. the environment variable ``DATAVERSE_CORS_ORIGIN``. + +.. _dataverse.cors.methods: + +dataverse.cors.methods +++++++++++++++++++++++ + +Allowed HTTP methods for CORS requests. The default when this setting is missing is "GET,POST,OPTIONS,PUT,DELETE". +Multiple methods can be specified as a comma-separated list. + +Example: + +``./asadmin create-jvm-options '-Ddataverse.cors.methods=GET,POST,OPTIONS'`` + +Can also be set via any `supported MicroProfile Config API source`_, e.g. the environment variable ``DATAVERSE_CORS_METHODS``. + +.. _dataverse.cors.headers.allow: + +dataverse.cors.headers.allow +++++++++++++++++++++++++++++ + +Allowed headers for CORS requests. The default when this setting is missing is "Accept,Content-Type,X-Dataverse-key,Range". +Multiple headers can be specified as a comma-separated list. + +Example: + +``./asadmin create-jvm-options '-Ddataverse.cors.headers.allow=Accept,Content-Type,X-Custom-Header'`` + +Can also be set via any `supported MicroProfile Config API source`_, e.g. the environment variable ``DATAVERSE_CORS_HEADERS_ALLOW``. + +.. _dataverse.cors.headers.expose: + +dataverse.cors.headers.expose ++++++++++++++++++++++++++++++ + +Headers to expose in CORS responses. The default when this setting is missing is "Accept-Ranges,Content-Range,Content-Encoding". +Multiple headers can be specified as a comma-separated list. + +Example: + +``./asadmin create-jvm-options '-Ddataverse.cors.headers.expose=Accept-Ranges,Content-Range,X-Custom-Header'`` + +Can also be set via any `supported MicroProfile Config API source`_, e.g. the environment variable ``DATAVERSE_CORS_HEADERS_EXPOSE``. .. _feature-flags: @@ -3614,8 +3763,11 @@ The pattern you will observe in curl examples below is that an HTTP ``PUT`` is u .. _:BlockedApiPolicy: -:BlockedApiPolicy -+++++++++++++++++ +:BlockedApiPolicy (Deprecated) +++++++++++++++++++++++++++++++ + +.. note:: + This setting is deprecated. Please use the JvmSetting :ref:`dataverse.api.blocked.policy` instead. This legacy setting will only be used if the newer JvmSettings are not set. ``:BlockedApiPolicy`` affects access to the list of API endpoints defined in :ref:`:BlockedApiEndpoints`. @@ -3631,8 +3783,11 @@ Below is an example of setting ``localhost-only``. .. _:BlockedApiEndpoints: -:BlockedApiEndpoints -++++++++++++++++++++ +:BlockedApiEndpoints (Deprecated) ++++++++++++++++++++++++++++++++++ + +.. note:: + This setting is deprecated. Please use the JvmSetting :ref:`dataverse.api.blocked.endpoints` instead. This legacy setting will only be used if the newer JvmSettings are not set. A comma-separated list of API endpoints to be blocked. For a standard production installation, the installer blocks both "admin" and "builtin-users" by default per the security section above: @@ -3642,8 +3797,11 @@ See the :ref:`list-of-dataverse-apis` for lists of API endpoints. .. _:BlockedApiKey: -:BlockedApiKey -++++++++++++++ +:BlockedApiKey (Deprecated) ++++++++++++++++++++++++++++ + +.. note:: + This setting is deprecated. Please use the JvmSetting :ref:`dataverse.api.blocked.key` instead. This legacy setting will only be used if the newer JvmSettings are not set. ``:BlockedApiKey`` is used in conjunction with :ref:`:BlockedApiEndpoints` and :ref:`:BlockedApiPolicy` and will not be enabled unless the policy is set to ``unblock-key`` as demonstrated below. Please note that the order is significant. You should set ``:BlockedApiKey`` first to prevent locking yourself out. @@ -3651,7 +3809,9 @@ See the :ref:`list-of-dataverse-apis` for lists of API endpoints. ``curl -X PUT -d unblock-key http://localhost:8080/api/admin/settings/:BlockedApiPolicy`` -Now that ``:BlockedApiKey`` has been enabled, blocked APIs can be accessed using the query parameter ``unblock-key=theKeyYouChose`` as in the example below. +Now that ``:BlockedApiKey`` has been enabled, blocked APIs can be accessed using the header ``X-Dataverse-unblock-key: theKeyYouChoose`` or, less securely, the query parameter ``unblock-key=theKeyYouChose`` as in the examples below. + +``curl -H 'X-Dataverse-unblock-key:theKeyYouChoose' https://demo.dataverse.org/api/admin/settings`` ``curl https://demo.dataverse.org/api/admin/settings?unblock-key=theKeyYouChose`` @@ -4665,14 +4825,19 @@ This can be helpful in situations where multiple organizations are sharing one D or ``curl -X PUT -d '*' http://localhost:8080/api/admin/settings/:InheritParentRoleAssignments`` -:AllowCors -++++++++++ +:AllowCors (Deprecated) ++++++++++++++++++++++++ + +.. note:: + This setting is deprecated. Please use the JVM settings above instead. + This legacy setting will only be used if the newer JVM settings are not set. -Allows Cross-Origin Resource sharing(CORS). By default this setting is absent and the Dataverse Software assumes it to be true. +Enable or disable support for Cross-Origin Resource Sharing (CORS) by setting ``:AllowCors`` to ``true`` or ``false``. -If you don’t want to allow CORS for your installation, set: +``curl -X PUT -d true http://localhost:8080/api/admin/settings/:AllowCors`` -``curl -X PUT -d 'false' http://localhost:8080/api/admin/settings/:AllowCors`` +.. note:: + New values for this setting will only be used after a server restart. :ChronologicalDateFacets ++++++++++++++++++++++++ diff --git a/modules/container-configbaker/scripts/bootstrap/demo/init.sh b/modules/container-configbaker/scripts/bootstrap/demo/init.sh index f9718c83f65..aa73cb5edff 100644 --- a/modules/container-configbaker/scripts/bootstrap/demo/init.sh +++ b/modules/container-configbaker/scripts/bootstrap/demo/init.sh @@ -33,6 +33,7 @@ echo "" echo "Revoke the key that allows for creation of builtin users..." curl -sS -X DELETE "${DATAVERSE_URL}/api/admin/settings/BuiltinUsers.KEY" +# TODO: stop using these deprecated database settings. See https://github.com/IQSS/dataverse/pull/11454 echo "" echo "Set key for accessing blocked API endpoints..." curl -sS -X PUT -d "$BLOCKED_API_KEY" "${DATAVERSE_URL}/api/admin/settings/:BlockedApiKey" diff --git a/src/main/java/edu/harvard/iq/dataverse/api/Admin.java b/src/main/java/edu/harvard/iq/dataverse/api/Admin.java index d0e5e0eda41..ac52b5d9fbf 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/Admin.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/Admin.java @@ -20,7 +20,6 @@ import edu.harvard.iq.dataverse.settings.JvmSettings; import edu.harvard.iq.dataverse.util.StringUtil; import edu.harvard.iq.dataverse.util.cache.CacheFactoryBean; -import edu.harvard.iq.dataverse.util.cache.RateLimitUtil; import edu.harvard.iq.dataverse.util.json.NullSafeJsonBuilder; import edu.harvard.iq.dataverse.validation.EMailValidator; import edu.harvard.iq.dataverse.EjbDataverseEngine; diff --git a/src/main/java/edu/harvard/iq/dataverse/api/ApiBlockingFilter.java b/src/main/java/edu/harvard/iq/dataverse/api/ApiBlockingFilter.java deleted file mode 100644 index b51b1aa2612..00000000000 --- a/src/main/java/edu/harvard/iq/dataverse/api/ApiBlockingFilter.java +++ /dev/null @@ -1,211 +0,0 @@ -package edu.harvard.iq.dataverse.api; - -import edu.harvard.iq.dataverse.authorization.groups.impl.ipaddress.ip.IpAddress; -import edu.harvard.iq.dataverse.engine.command.DataverseRequest; -import edu.harvard.iq.dataverse.settings.SettingsServiceBean; -import java.io.IOException; -import java.util.Map; -import java.util.Set; -import java.util.TreeMap; -import java.util.TreeSet; -import java.util.logging.Level; -import java.util.logging.Logger; -import jakarta.ejb.EJB; -import jakarta.servlet.Filter; -import jakarta.servlet.FilterChain; -import jakarta.servlet.FilterConfig; -import jakarta.servlet.ServletException; -import jakarta.servlet.ServletRequest; -import jakarta.servlet.ServletResponse; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; - - -/** - * A web filter to block API administration calls. - * @author michael - */ -public class ApiBlockingFilter implements Filter { - public static final String UNBLOCK_KEY_QUERYPARAM = "unblock-key"; - - interface BlockPolicy { - public void doBlock(ServletRequest sr, ServletResponse sr1, FilterChain fc) throws IOException, ServletException; - } - - /** - * A policy that allows all requests. - */ - private static final BlockPolicy ALLOW = new BlockPolicy(){ - @Override - public void doBlock(ServletRequest sr, ServletResponse sr1, FilterChain fc) throws IOException, ServletException { - fc.doFilter(sr, sr1); - } - }; - - /** - * A policy that drops blocked requests. - */ - private static final BlockPolicy DROP = new BlockPolicy(){ - @Override - public void doBlock(ServletRequest sr, ServletResponse sr1, FilterChain fc) throws IOException, ServletException { - HttpServletResponse httpResponse = (HttpServletResponse) sr1; - httpResponse.getWriter().println("{ \"status\":\"error\", \"message\":\"Endpoint blocked. Please contact the dataverse administrator\"}" ); - httpResponse.setStatus(HttpServletResponse.SC_SERVICE_UNAVAILABLE); - httpResponse.setContentType("application/json"); - } - }; - - /** - * Allow only from localhost. - */ - private static final BlockPolicy LOCAL_HOST_ONLY = new BlockPolicy() { - - @Override - public void doBlock(ServletRequest sr, ServletResponse sr1, FilterChain fc) throws IOException, ServletException { - IpAddress origin = new DataverseRequest( null, (HttpServletRequest)sr ).getSourceAddress(); - if ( origin.isLocalhost() ) { - fc.doFilter(sr, sr1); - } else { - HttpServletResponse httpResponse = (HttpServletResponse) sr1; - httpResponse.getWriter().println("{ \"status\":\"error\", \"message\":\"Endpoint available from localhost only. Please contact the dataverse administrator\"}" ); - httpResponse.setStatus(HttpServletResponse.SC_SERVICE_UNAVAILABLE); - httpResponse.setContentType("application/json"); - } - } - }; - - /** - * Allow only for requests that have the {@link #UNBLOCK_KEY_QUERYPARAM} param with - * value from {@link SettingsServiceBean.Key.BlockedApiKey} - */ - private final BlockPolicy unblockKey = new BlockPolicy() { - - @Override - public void doBlock(ServletRequest sr, ServletResponse sr1, FilterChain fc) throws IOException, ServletException { - boolean block = true; - - String masterKey = settingsSvc.getValueForKey(SettingsServiceBean.Key.BlockedApiKey); - if ( masterKey != null ) { - String queryString = ((HttpServletRequest)sr).getQueryString(); - if ( queryString != null ) { - for ( String paramPair : queryString.split("&") ) { - String[] curPair = paramPair.split("=",-1); - if ( (curPair.length >= 2 ) - && UNBLOCK_KEY_QUERYPARAM.equals(curPair[0]) - && masterKey.equals(curPair[1]) ) { - block = false; - break; - } - } - } - } - - if ( block ) { - HttpServletResponse httpResponse = (HttpServletResponse) sr1; - httpResponse.getWriter().println("{ \"status\":\"error\", \"message\":\"Endpoint available using API key only. Please contact the dataverse administrator\"}" ); - httpResponse.setStatus(HttpServletResponse.SC_SERVICE_UNAVAILABLE); - httpResponse.setContentType("application/json"); - } else { - fc.doFilter(sr, sr1); - } - } - }; - - private static final Logger logger = Logger.getLogger(ApiBlockingFilter.class.getName()); - - @EJB - protected SettingsServiceBean settingsSvc; - - final Set blockedApiEndpoints = new TreeSet<>(); - private String lastEndpointList; - private final Map policies = new TreeMap<>(); - - @Override - public void init(FilterConfig fc) throws ServletException { - updateBlockedPoints(); - policies.put("allow", ALLOW); - policies.put("drop", DROP); - policies.put("localhost-only", LOCAL_HOST_ONLY); - policies.put("unblock-key", unblockKey); - } - - private void updateBlockedPoints() { - blockedApiEndpoints.clear(); - String endpointList = settingsSvc.getValueForKey(SettingsServiceBean.Key.BlockedApiEndpoints, ""); - for ( String endpoint : endpointList.split(",") ) { - String endpointPrefix = canonize(endpoint); - if ( ! endpointPrefix.isEmpty() ) { - endpointPrefix = endpointPrefix + "/"; - logger.log(Level.INFO, "Blocking API endpoint: {0}", endpointPrefix); - blockedApiEndpoints.add(endpointPrefix); - } - } - lastEndpointList = endpointList; - } - - @Override - public void doFilter(ServletRequest sr, ServletResponse sr1, FilterChain fc) throws IOException, ServletException { - - String endpointList = settingsSvc.getValueForKey(SettingsServiceBean.Key.BlockedApiEndpoints, ""); - if ( ! endpointList.equals(lastEndpointList) ) { - updateBlockedPoints(); - } - - HttpServletRequest hsr = (HttpServletRequest) sr; - String requestURI = hsr.getRequestURI(); - String apiEndpoint = canonize(requestURI.substring(hsr.getServletPath().length())); - for ( String prefix : blockedApiEndpoints ) { - if ( apiEndpoint.startsWith(prefix) ) { - getBlockPolicy().doBlock(sr, sr1, fc); - return; - } - } - try { - if (settingsSvc.isTrueForKey(SettingsServiceBean.Key.AllowCors, true )) { - ((HttpServletResponse) sr1).addHeader("Access-Control-Allow-Origin", "*"); - ((HttpServletResponse) sr1).addHeader("Access-Control-Allow-Methods", "PUT, GET, POST, DELETE, OPTIONS"); - ((HttpServletResponse) sr1).addHeader("Access-Control-Allow-Headers", "Accept, Content-Type, X-Dataverse-Key, Range"); - ((HttpServletResponse) sr1).addHeader("Access-Control-Expose-Headers", "Accept-Ranges, Content-Range, Content-Encoding"); - } - fc.doFilter(sr, sr1); - } catch ( ServletException se ) { - logger.log(Level.WARNING, "Error processing " + requestURI +": " + se.getMessage(), se); - HttpServletResponse resp = (HttpServletResponse) sr1; - resp.setStatus(500); - resp.setHeader("PROCUDER", "ApiBlockingFilter"); - resp.getWriter().append("Error: " + se.getMessage()); - } - } - - @Override - public void destroy() {} - - private BlockPolicy getBlockPolicy() { - String blockPolicyName = settingsSvc.getValueForKey(SettingsServiceBean.Key.BlockedApiPolicy, ""); - BlockPolicy p = policies.get(blockPolicyName.trim()); - if ( p != null ) { - return p; - } else { - logger.log(Level.WARNING, "Undefined block policy {0}. Available policies are {1}", - new Object[]{blockPolicyName, policies.keySet()}); - return ALLOW; - } - } - - /** - * Creates a canonical representation of {@code in}: trimmed spaces and slashes - * @param in the raw string - * @return {@code in} with no trailing and leading spaces and slashes. - */ - private String canonize( String in ) { - in = in.trim(); - if ( in.startsWith("/") ) { - in = in.substring(1); - } - if ( in.endsWith("/") ) { - in = in.substring(0, in.length()-1); - } - return in; - } - -} diff --git a/src/main/java/edu/harvard/iq/dataverse/api/ApiConfiguration.java b/src/main/java/edu/harvard/iq/dataverse/api/ApiConfiguration.java index d076ab8f973..f7c4e7e105f 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/ApiConfiguration.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/ApiConfiguration.java @@ -2,7 +2,6 @@ import jakarta.ws.rs.ApplicationPath; -import edu.harvard.iq.dataverse.api.auth.AuthFilter; import org.glassfish.jersey.media.multipart.MultiPartFeature; import org.glassfish.jersey.server.ResourceConfig; @@ -13,6 +12,5 @@ public ApiConfiguration() { packages("edu.harvard.iq.dataverse.api"); packages("edu.harvard.iq.dataverse.mydata"); register(MultiPartFeature.class); - register(AuthFilter.class); } } diff --git a/src/main/java/edu/harvard/iq/dataverse/api/ApiRouter.java b/src/main/java/edu/harvard/iq/dataverse/api/ApiRouter.java index 193e1059415..28b5592f0dc 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/ApiRouter.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/ApiRouter.java @@ -10,12 +10,14 @@ import jakarta.servlet.ServletRequest; import jakarta.servlet.ServletResponse; import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.annotation.WebFilter; /** * Routes API calls that don't have a version number to the latest API version * * @author michael */ +@WebFilter("/api/*") public class ApiRouter implements Filter { private static final Logger logger = Logger.getLogger(ApiRouter.class.getName()); diff --git a/src/main/java/edu/harvard/iq/dataverse/api/BuiltinUsers.java b/src/main/java/edu/harvard/iq/dataverse/api/BuiltinUsers.java index e6991072d76..317f7d6c870 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/BuiltinUsers.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/BuiltinUsers.java @@ -4,7 +4,6 @@ import edu.harvard.iq.dataverse.UserNotification; import edu.harvard.iq.dataverse.actionlogging.ActionLogRecord; import edu.harvard.iq.dataverse.api.auth.ApiKeyAuthMechanism; -import edu.harvard.iq.dataverse.authorization.AuthenticationServiceBean; import edu.harvard.iq.dataverse.authorization.UserRecordIdentifier; import edu.harvard.iq.dataverse.authorization.providers.builtin.BuiltinAuthenticationProvider; import edu.harvard.iq.dataverse.authorization.providers.builtin.BuiltinUser; @@ -19,7 +18,6 @@ import jakarta.ejb.EJB; import jakarta.ejb.EJBException; -import jakarta.inject.Inject; import jakarta.json.Json; import jakarta.json.JsonObjectBuilder; import jakarta.ws.rs.GET; @@ -47,9 +45,6 @@ public class BuiltinUsers extends AbstractApiBean { @EJB protected BuiltinUserServiceBean builtinUserSvc; - @Inject - private AuthenticationServiceBean authenticationService; - @GET @Path("{username}/api-token") public Response getApiToken( @PathParam("username") String username, @QueryParam("password") String password ) { diff --git a/src/main/java/edu/harvard/iq/dataverse/api/filter/ApiBlockingFilter.java b/src/main/java/edu/harvard/iq/dataverse/api/filter/ApiBlockingFilter.java new file mode 100644 index 00000000000..39efbb97aaa --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/api/filter/ApiBlockingFilter.java @@ -0,0 +1,245 @@ +package edu.harvard.iq.dataverse.api.filter; + +import edu.harvard.iq.dataverse.authorization.groups.impl.ipaddress.ip.IpAddress; +import edu.harvard.iq.dataverse.engine.command.DataverseRequest; +import edu.harvard.iq.dataverse.settings.JvmSettings; +import edu.harvard.iq.dataverse.settings.SettingsServiceBean; +import edu.harvard.iq.dataverse.validation.PasswordValidatorServiceBean; +import jakarta.annotation.PostConstruct; +import jakarta.inject.Inject; +import jakarta.json.Json; +import jakarta.json.JsonObject; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.container.ContainerRequestContext; +import jakarta.ws.rs.container.ContainerRequestFilter; +import jakarta.ws.rs.container.ResourceInfo; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.ext.Provider; +import java.io.IOException; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.regex.Pattern; + +import org.eclipse.jetty.util.StringUtil; + +@Provider +public class ApiBlockingFilter implements ContainerRequestFilter { + + private static final Logger logger = Logger.getLogger(ApiBlockingFilter.class.getName()); + + public static final String UNBLOCK_KEY_QUERYPARAM = "unblock-key"; + public static final String UNBLOCK_KEY_HEADER = "X-Dataverse-unblock-key"; + // Policies + private static final String DROP = "drop"; + private static final String LOCALHOST_ONLY = "localhost-only"; + private static final String UNBLOCK_KEY = "unblock-key"; + + private static final Map POLICY_ERROR_MESSAGES = new HashMap<>(); + static { + POLICY_ERROR_MESSAGES.put(DROP, "Endpoint blocked. Access denied."); + POLICY_ERROR_MESSAGES.put(LOCALHOST_ONLY, "Endpoint restricted to localhost access only."); + POLICY_ERROR_MESSAGES.put(UNBLOCK_KEY, "Endpoint requires an unblock key for access."); + } + + @Inject + private SettingsServiceBean settingsService; + + @Inject + private PasswordValidatorServiceBean passwordValidatorService; + + @Context + private ResourceInfo resourceInfo; + + @Context + private HttpServletRequest httpServletRequest; + + private String policy = null; + + private JsonObject errorJson = null; + + private List blockedApiEndpointPatterns = new ArrayList<>(); + + private String key; + + // If any of the JvmSettings are not set, revert to checking the db settings on + // every call + private boolean checkSettings = false; + + private String endpointList = null; + + @PostConstruct + public void init() { + // Check JvmSettings first for BlockedApiPolicy + policy = JvmSettings.API_BLOCKED_POLICY.lookupOptional().orElse(settingsService.getValueForKey(SettingsServiceBean.Key.BlockedApiPolicy, DROP)); + + if(!(DROP.equals(policy) || LOCALHOST_ONLY.equals(policy) || UNBLOCK_KEY.equals(policy))) { + logger.severe("Invalid BlockedApiPolicy setting: " + policy + ". Using policy 'drop'"); + policy = DROP; + } + Optional jvmEndpointList = JvmSettings.API_BLOCKED_ENDPOINTS.lookupOptional(); + if (!jvmEndpointList.isPresent()) { + checkSettings = true; + } + endpointList = jvmEndpointList + .orElse(settingsService.getValueForKey(SettingsServiceBean.Key.BlockedApiEndpoints, "")); + logger.info("Using policy: " + policy + " to block API endpoints: " + endpointList); + if (!(endpointList.contains("admin") && endpointList.contains("builtin-users"))) { + logger.warning( + "Not blocking admin and builtin-user endpoints is a security issue unless you are blocking them in an external proxy."); + } + if (UNBLOCK_KEY.equals(policy)) { + Optional jvmKey = JvmSettings.API_BLOCKED_KEY.lookupOptional(); + if (!jvmKey.isPresent()) { + checkSettings = true; + } + key = jvmKey.orElse(settingsService.getValueForKey(SettingsServiceBean.Key.BlockedApiKey)); + if (StringUtil.isBlank(key)) { + logger.severe( + "Using unblock-key policy and no unblock key found in JvmSettings.API_BLOCKED_KEY or SettingsService.BlockedApiKey"); + } else if (passwordValidatorService.validate(key).size() == 0) { + logger.warning("Weak unblock key detected. Please use a stronger key for better security."); + } + } + updateBlockedPoints(endpointList); + if(checkSettings) { + logger.warning("Not all required dataverse.api.blocked.* settings not found. Dataverse use deprecated db settings and check for updates on every API call."); + } + + } + + @Override + public void filter(ContainerRequestContext requestContext) throws IOException { + + Method method = resourceInfo.getResourceMethod(); + Class clazz = resourceInfo.getResourceClass(); + + String classPath = ""; + String methodPath = ""; + + if (clazz.isAnnotationPresent(Path.class)) { + classPath = clazz.getAnnotation(Path.class).value(); + } + + if (method.isAnnotationPresent(Path.class)) { + methodPath = method.getAnnotation(Path.class).value(); + } + + if (checkSettings) { + // Backward compatibility, e.g. for setup scripts, dev environments where + // dynamic update from the db settings is expected + policy = settingsService.getValueForKey(SettingsServiceBean.Key.BlockedApiPolicy, + JvmSettings.API_BLOCKED_POLICY.lookupOptional().orElse(DROP)); + String newEndpointList = settingsService.getValueForKey(SettingsServiceBean.Key.BlockedApiEndpoints, + JvmSettings.API_BLOCKED_ENDPOINTS.lookupOptional().orElse("")); + if (!endpointList.equals(newEndpointList)) { + endpointList = newEndpointList; + updateBlockedPoints(endpointList); + } + if (policy.equals(UNBLOCK_KEY)) { + key = settingsService.getValueForKey(SettingsServiceBean.Key.BlockedApiKey, + JvmSettings.API_BLOCKED_KEY.lookupOptional().orElse("")); + if (StringUtil.isBlank(key)) { + logger.severe( + "Using unblock-key policy and no unblock key found in JvmSettings.API_BLOCKED_KEY or SettingsService.BlockedApiKey"); + } + } + } + String fullPath = (classPath + "/" + methodPath).replaceAll("//", "/"); + logger.fine("Full path is " + fullPath); + + boolean isBlockableEndpoint = false; + for (Pattern blockedEndpointPattern : blockedApiEndpointPatterns) { + if (blockedEndpointPattern.matcher(fullPath).matches()) { + isBlockableEndpoint = true; + break; + } + } + if (!isBlockableEndpoint) { + return; + } + // Blocakble endpoint - now check policy + if (isBlocked(policy, requestContext)) { + logger.fine("Blocked " + fullPath); + requestContext.abortWith(Response.status(Response.Status.SERVICE_UNAVAILABLE).entity(errorJson) + .type(jakarta.ws.rs.core.MediaType.APPLICATION_JSON).build()); + return; + } + } + + private boolean isBlocked(String policy, ContainerRequestContext requestContext) { + switch (policy) { + case DROP: + return true; + case LOCALHOST_ONLY: + if (httpServletRequest == null) { + logger.warning("Unable to obtain HttpServletRequest from ContainerRequestContext"); + // Handle the case where HttpServletRequest is not available + return true; + } + IpAddress origin = new DataverseRequest(null, httpServletRequest).getSourceAddress(); + if (!origin.isLocalhost()) { + return true; + } + break; + case UNBLOCK_KEY: + String providedKey = requestContext.getHeaderString(UNBLOCK_KEY_HEADER); + if (StringUtil.isBlank(providedKey)) { + providedKey = requestContext.getUriInfo().getQueryParameters().getFirst(UNBLOCK_KEY_QUERYPARAM); + } + // Must have a non-blank key defined and the query param must match it + if (StringUtil.isNotBlank(key) && key.equals(providedKey)) { + return false; + } + // Otherwise we have a blocked endpoint and the key doesn't work (not set or + // doesn't match what's sent) + return true; + } + return false; + } + + private void updateBlockedPoints(String endpointList) { + blockedApiEndpointPatterns.clear(); + + String currentErrorMessage = POLICY_ERROR_MESSAGES.getOrDefault(policy, + "Endpoint blocked. Please contact the dataverse administrator."); + + errorJson = Json.createObjectBuilder().add("status", "error").add("message", currentErrorMessage).build(); + + for (String endpoint : endpointList.split(",")) { + String endpointPrefix = canonicalize(endpoint); + if (!endpointPrefix.isEmpty()) { + logger.log(Level.INFO, "Blocking API endpoint: {0}", endpointPrefix); + blockedApiEndpointPatterns.add(Pattern.compile(convertPathToRegex(endpointPrefix))); + } + } + } + + private String convertPathToRegex(String path) { + return "^" + path.replaceAll("\\{[^}]+\\}", "[^/]+").replace("/", "\\/") + "(\\/.*)?$"; + } + + /** + * Creates a canonical representation of {@code in}: trimmed spaces and slashes + * + * @param in the raw string + * @return {@code in} with no trailing and leading spaces and slashes. + */ + private String canonicalize(String in) { + in = in.trim(); + if (in.startsWith("/")) { + in = in.substring(1); + } + if (in.endsWith("/")) { + in = in.substring(0, in.length() - 1); + } + return in; + } +} \ No newline at end of file diff --git a/src/main/java/edu/harvard/iq/dataverse/api/util/JsonResponseBuilder.java b/src/main/java/edu/harvard/iq/dataverse/api/util/JsonResponseBuilder.java index 71a010b7e6d..a80d54508fd 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/util/JsonResponseBuilder.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/util/JsonResponseBuilder.java @@ -1,7 +1,5 @@ package edu.harvard.iq.dataverse.api.util; -import edu.harvard.iq.dataverse.api.ApiBlockingFilter; - import jakarta.json.Json; import jakarta.json.JsonValue; import jakarta.json.JsonObjectBuilder; @@ -13,6 +11,8 @@ import org.apache.commons.lang3.exception.ExceptionUtils; +import edu.harvard.iq.dataverse.api.filter.ApiBlockingFilter; + import java.io.IOException; import java.util.Optional; import java.util.UUID; diff --git a/src/main/java/edu/harvard/iq/dataverse/filter/CorsFilter.java b/src/main/java/edu/harvard/iq/dataverse/filter/CorsFilter.java new file mode 100644 index 00000000000..7d99d9ee4d2 --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/filter/CorsFilter.java @@ -0,0 +1,69 @@ +package edu.harvard.iq.dataverse.filter; + +import jakarta.inject.Inject; +import jakarta.servlet.*; +import jakarta.servlet.annotation.WebFilter; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; + +import edu.harvard.iq.dataverse.settings.JvmSettings; +import edu.harvard.iq.dataverse.settings.SettingsServiceBean; + +/** + * CorsFilter is a servlet filter that handles Cross-Origin Resource Sharing (CORS) for the Dataverse application. + * It configures and applies CORS headers to HTTP responses based on application settings. + * + * This filter: + * 1. Reads CORS configuration from JVM settings or (deprecated) the SettingsServiceBean. See the Dataverse Configuration Guide for more details. + * 2. Determines whether CORS should be allowed based on these settings. + * 3. If CORS is allowed, it adds the appropriate CORS headers to all HTTP responses. The JVMSettings allow customization of the header contents if desired. + * + * The filter is applied to all paths ("/*") in the application. + */ + +@WebFilter("/*") +public class CorsFilter implements Filter { + + @Inject + private SettingsServiceBean settingsSvc; + + private boolean allowCors; + private String origin; + private String methods; + private String allowHeaders; + private String exposeHeaders; + + @Override + public void init(FilterConfig filterConfig) throws ServletException { + origin = JvmSettings.CORS_ORIGIN.lookupOptional().orElse(null); + boolean corsSetting = settingsSvc.isTrueForKey(SettingsServiceBean.Key.AllowCors, true); + + if (origin == null && !corsSetting) { + allowCors = false; + } else { + allowCors = true; + origin = (origin != null) ? origin : "*"; + } + + if (allowCors) { + methods = JvmSettings.CORS_METHODS.lookupOptional().orElse("PUT, GET, POST, DELETE, OPTIONS"); + allowHeaders = JvmSettings.CORS_ALLOW_HEADERS.lookupOptional() + .orElse("Accept, Content-Type, X-Dataverse-key, Range"); + exposeHeaders = JvmSettings.CORS_EXPOSE_HEADERS.lookupOptional() + .orElse("Accept-Ranges, Content-Range, Content-Encoding"); + } + } + + @Override + public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain) + throws IOException, ServletException { + if (allowCors) { + HttpServletResponse response = (HttpServletResponse) servletResponse; + response.addHeader("Access-Control-Allow-Origin", origin); + response.addHeader("Access-Control-Allow-Methods", methods); + response.addHeader("Access-Control-Allow-Headers", allowHeaders); + response.addHeader("Access-Control-Expose-Headers", exposeHeaders); + } + chain.doFilter(servletRequest, servletResponse); + } +} diff --git a/src/main/java/edu/harvard/iq/dataverse/settings/JvmSettings.java b/src/main/java/edu/harvard/iq/dataverse/settings/JvmSettings.java index 5b35a2e68b7..07de576a0eb 100644 --- a/src/main/java/edu/harvard/iq/dataverse/settings/JvmSettings.java +++ b/src/main/java/edu/harvard/iq/dataverse/settings/JvmSettings.java @@ -87,6 +87,11 @@ public enum JvmSettings { SCOPE_API(PREFIX, "api"), API_SIGNING_SECRET(SCOPE_API, "signing-secret"), API_ALLOW_INCOMPLETE_METADATA(SCOPE_API, "allow-incomplete-metadata"), + // API: BLOCKED_API SETTINGS + SCOPE_API_BLOCKED(SCOPE_API, "blocked"), + API_BLOCKED_ENDPOINTS(SCOPE_API_BLOCKED, "endpoints"), + API_BLOCKED_POLICY(SCOPE_API_BLOCKED, "policy"), + API_BLOCKED_KEY(SCOPE_API_BLOCKED, "key"), // SIGNPOSTING SETTINGS SCOPE_SIGNPOSTING(PREFIX, "signposting"), @@ -266,6 +271,14 @@ public enum JvmSettings { //CSL CITATION SETTINGS SCOPE_CSL(PREFIX, "csl"), CSL_COMMON_STYLES(SCOPE_CSL, "common-styles"), + + // CORS SETTINGS + SCOPE_CORS(PREFIX, "cors"), + CORS_ORIGIN(SCOPE_CORS, "origin"), + CORS_METHODS(SCOPE_CORS, "methods"), + SCOPE_CORS_HEADERS(SCOPE_CORS, "headers"), + CORS_ALLOW_HEADERS(SCOPE_CORS_HEADERS, "allow"), + CORS_EXPOSE_HEADERS(SCOPE_CORS_HEADERS, "expose"), ; private static final String SCOPE_SEPARATOR = "."; diff --git a/src/main/java/edu/harvard/iq/dataverse/settings/SettingsServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/settings/SettingsServiceBean.java index 6d96ad4abf6..ff45b0efed9 100644 --- a/src/main/java/edu/harvard/iq/dataverse/settings/SettingsServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/settings/SettingsServiceBean.java @@ -2,7 +2,7 @@ import edu.harvard.iq.dataverse.actionlogging.ActionLogRecord; import edu.harvard.iq.dataverse.actionlogging.ActionLogServiceBean; -import edu.harvard.iq.dataverse.api.ApiBlockingFilter; +import edu.harvard.iq.dataverse.api.filter.ApiBlockingFilter; import edu.harvard.iq.dataverse.util.StringUtil; import edu.harvard.iq.dataverse.util.json.JsonUtil; import jakarta.ejb.EJB; @@ -138,19 +138,25 @@ public enum Key { /** * API endpoints that are not accessible. Comma separated list. + * @see JvmSettings#API_BLOCKED_ENDPOINTS */ + @Deprecated(forRemoval = true, since = "2025-04-29") BlockedApiEndpoints, /** * A key that, with the right {@link ApiBlockingFilter.BlockPolicy}, * allows calling blocked APIs. + * @see JvmSettings#API_BLOCKED_KEY */ + @Deprecated(forRemoval = true, since = "2025-04-29") BlockedApiKey, /** * How to treat blocked APIs. One of drop, localhost-only, unblock-key + * @see JvmSettings#API_BLOCKED_POLICY */ + @Deprecated(forRemoval = true, since = "2025-04-29") BlockedApiPolicy, /** @@ -456,7 +462,15 @@ Whether Harvesting (OAI) service is enabled /** * Allow CORS flag (true or false). It is true by default * + * The allowed origin for CORS requests. + * + * @see JvmSettings#CORS_ORIGIN + * @see JvmSettings#CORS_METHODS + * @see JvmSettings#CORS_ALLOW_HEADERS + * @see JvmSettings#CORS_EXPOSE_HEADERS */ + @Deprecated(forRemoval = true, since = "2025-04-29") + AllowCors, /** diff --git a/src/main/webapp/WEB-INF/web.xml b/src/main/webapp/WEB-INF/web.xml index 732c634205f..78e5a5f104f 100644 --- a/src/main/webapp/WEB-INF/web.xml +++ b/src/main/webapp/WEB-INF/web.xml @@ -67,26 +67,6 @@ ${MPCONFIG=dataverse.jsf.refresh-period:-1} - - Router - edu.harvard.iq.dataverse.api.ApiRouter - - - Blocker - edu.harvard.iq.dataverse.api.ApiBlockingFilter - - - Router - /api/* - REQUEST - FORWARD - - - Blocker - /api/* - REQUEST - FORWARD - Faces Servlet jakarta.faces.webapp.FacesServlet diff --git a/src/test/java/edu/harvard/iq/dataverse/api/util/JsonResponseBuilderTest.java b/src/test/java/edu/harvard/iq/dataverse/api/util/JsonResponseBuilderTest.java index 51586127041..748b4587a94 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/util/JsonResponseBuilderTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/util/JsonResponseBuilderTest.java @@ -1,12 +1,12 @@ package edu.harvard.iq.dataverse.api.util; -import edu.harvard.iq.dataverse.api.ApiBlockingFilter; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.EmptySource; import org.junit.jupiter.params.provider.NullSource; import org.junit.jupiter.params.provider.ValueSource; import org.mockito.Mockito; +import edu.harvard.iq.dataverse.api.filter.ApiBlockingFilter; import jakarta.servlet.http.HttpServletRequest; import static org.junit.jupiter.api.Assertions.*;