Remove unnecessary API module dependencies (#2842)

Signed-off-by: Avgustin Marinov <Avgustin.Marinov@bosch.com>
This commit is contained in:
Avgustin Marinov
2025-12-02 13:53:36 +02:00
committed by GitHub
parent 6988f5eafb
commit 29da04f6da
29 changed files with 103 additions and 185 deletions

View File

@@ -0,0 +1,86 @@
<!--
Copyright (c) 2015 Bosch Software Innovations GmbH and others
This program and the accompanying materials are made
available under the terms of the Eclipse Public License 2.0
which is available at https://www.eclipse.org/legal/epl-2.0/
SPDX-License-Identifier: EPL-2.0
-->
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.eclipse.hawkbit</groupId>
<artifactId>hawkbit-rest</artifactId>
<version>${revision}</version>
</parent>
<artifactId>hawkbit-rest-core</artifactId>
<name>hawkBit :: REST :: Core</name>
<dependencies>
<dependency>
<groupId>org.eclipse.hawkbit</groupId>
<artifactId>hawkbit-rest-api</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.eclipse.hawkbit</groupId>
<artifactId>hawkbit-core</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.eclipse.hawkbit</groupId>
<artifactId>hawkbit-artifact-api</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-tx</artifactId>
</dependency>
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>${commons-io.version}</version>
</dependency>
<!-- Test -->
<dependency>
<groupId>org.eclipse.hawkbit</groupId>
<artifactId>hawkbit-repository-jpa</artifactId>
<version>${project.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.eclipse.hawkbit</groupId>
<artifactId>hawkbit-repository-test</artifactId>
<version>${project.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>test-jar</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,357 @@
/**
* Copyright (c) 2015 Bosch Software Innovations GmbH and others
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.rest;
import java.io.IOException;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.ConstraintViolationException;
import jakarta.validation.ValidationException;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.eclipse.hawkbit.exception.AbstractServerRtException;
import org.eclipse.hawkbit.exception.SpServerError;
import org.eclipse.hawkbit.rest.exception.MessageNotReadableException;
import org.eclipse.hawkbit.rest.exception.MultiPartFileUploadException;
import org.eclipse.hawkbit.rest.json.model.ExceptionInfo;
import org.eclipse.hawkbit.rest.exception.FileStreamingFailedException;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.hateoas.config.EnableHypermediaSupport;
import org.springframework.hateoas.config.EnableHypermediaSupport.HypermediaType;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.util.AntPathMatcher;
import org.springframework.util.ObjectUtils;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.filter.ShallowEtagHeaderFilter;
import org.springframework.web.method.annotation.HandlerMethodValidationException;
import org.springframework.web.multipart.MultipartException;
/**
* Configuration for Rest api.
*/
@Configuration
@EnableHypermediaSupport(type = { HypermediaType.HAL })
public class RestConfiguration {
/**
* {@link ControllerAdvice} for mapping {@link RuntimeException}s from the repository to {@link HttpStatus} codes.
*
* @return a controller advice for handling exceptions
*/
@Bean
ResponseExceptionHandler responseExceptionHandler() {
return new ResponseExceptionHandler();
}
/**
* Filter registration bean for spring etag filter.
*
* @return the spring filter registration bean for registering an etag filter in the filter chain
*/
@Bean
FilterRegistrationBean<ExcludePathAwareShallowETagFilter> eTagFilter() {
final FilterRegistrationBean<ExcludePathAwareShallowETagFilter> filterRegBean = new FilterRegistrationBean<>();
// Exclude the URLs for downloading artifacts, so no eTag is generated
// in the ShallowEtagHeaderFilter, just using the SHA1 hash of the
// artifact itself as 'ETag', because otherwise the file will be copied in memory!
filterRegBean.setFilter(new ExcludePathAwareShallowETagFilter(
"/rest/v1/softwaremodules/{smId}/artifacts/{artId}/download",
"/{tenant}/controller/v1/{controllerId}/softwaremodules/{softwareModuleId}/artifacts/**",
"/api/v1/downloadserver/**"));
return filterRegBean;
}
/**
* General controller advice for exception handling.
*/
@Slf4j
@ControllerAdvice
public static class ResponseExceptionHandler {
private static final Map<SpServerError, HttpStatus> ERROR_TO_HTTP_STATUS = new EnumMap<>(SpServerError.class);
private static final HttpStatus DEFAULT_RESPONSE_STATUS = HttpStatus.INTERNAL_SERVER_ERROR;
private static final String MESSAGE_FORMATTER_SEPARATOR = " ";
private static final String LOG_EXCEPTION_FORMAT = "Handling exception {} of request {}";
static {
ERROR_TO_HTTP_STATUS.put(SpServerError.SP_REPO_ENTITY_NOT_EXISTS, HttpStatus.NOT_FOUND);
ERROR_TO_HTTP_STATUS.put(SpServerError.SP_REPO_ENTITY_ALREADY_EXISTS, HttpStatus.CONFLICT);
ERROR_TO_HTTP_STATUS.put(SpServerError.SP_REPO_ENTITY_READ_ONLY, HttpStatus.FORBIDDEN);
ERROR_TO_HTTP_STATUS.put(SpServerError.SP_REST_SORT_PARAM_INVALID_DIRECTION, HttpStatus.BAD_REQUEST);
ERROR_TO_HTTP_STATUS.put(SpServerError.SP_REST_SORT_PARAM_INVALID_FIELD, HttpStatus.BAD_REQUEST);
ERROR_TO_HTTP_STATUS.put(SpServerError.SP_REST_SORT_PARAM_SYNTAX, HttpStatus.BAD_REQUEST);
ERROR_TO_HTTP_STATUS.put(SpServerError.SP_REST_RSQL_PARAM_INVALID_FIELD, HttpStatus.BAD_REQUEST);
ERROR_TO_HTTP_STATUS.put(SpServerError.SP_REST_RSQL_SEARCH_PARAM_SYNTAX, HttpStatus.BAD_REQUEST);
ERROR_TO_HTTP_STATUS.put(SpServerError.SP_INSUFFICIENT_PERMISSION, HttpStatus.FORBIDDEN);
ERROR_TO_HTTP_STATUS.put(SpServerError.SP_ARTIFACT_UPLOAD_FAILED, HttpStatus.INTERNAL_SERVER_ERROR);
ERROR_TO_HTTP_STATUS.put(SpServerError.SP_ARTIFACT_ENCRYPTION_NOT_SUPPORTED, HttpStatus.BAD_REQUEST);
ERROR_TO_HTTP_STATUS.put(SpServerError.SP_ARTIFACT_ENCRYPTION_FAILED, HttpStatus.INTERNAL_SERVER_ERROR);
ERROR_TO_HTTP_STATUS.put(SpServerError.SP_ARTIFACT_UPLOAD_FAILED_SHA1_MATCH, HttpStatus.BAD_REQUEST);
ERROR_TO_HTTP_STATUS.put(SpServerError.SP_ARTIFACT_UPLOAD_FAILED_SHA256_MATCH, HttpStatus.BAD_REQUEST);
ERROR_TO_HTTP_STATUS.put(SpServerError.SP_ARTIFACT_UPLOAD_FAILED_MD5_MATCH, HttpStatus.BAD_REQUEST);
ERROR_TO_HTTP_STATUS.put(SpServerError.SP_ARTIFACT_DELETE_FAILED, HttpStatus.INTERNAL_SERVER_ERROR);
ERROR_TO_HTTP_STATUS.put(SpServerError.SP_ARTIFACT_BINARY_DELETED, HttpStatus.GONE);
ERROR_TO_HTTP_STATUS.put(SpServerError.SP_ARTIFACT_LOAD_FAILED, HttpStatus.INTERNAL_SERVER_ERROR);
ERROR_TO_HTTP_STATUS.put(SpServerError.SP_QUOTA_EXCEEDED, HttpStatus.TOO_MANY_REQUESTS);
ERROR_TO_HTTP_STATUS.put(SpServerError.SP_FILE_SIZE_QUOTA_EXCEEDED, HttpStatus.TOO_MANY_REQUESTS);
ERROR_TO_HTTP_STATUS.put(SpServerError.SP_STORAGE_QUOTA_EXCEEDED, HttpStatus.TOO_MANY_REQUESTS);
ERROR_TO_HTTP_STATUS.put(SpServerError.SP_ACTION_NOT_CANCELABLE, HttpStatus.METHOD_NOT_ALLOWED);
ERROR_TO_HTTP_STATUS.put(SpServerError.SP_ACTION_NOT_FORCE_QUITABLE, HttpStatus.METHOD_NOT_ALLOWED);
ERROR_TO_HTTP_STATUS.put(SpServerError.SP_DS_CREATION_FAILED_MISSING_MODULE, HttpStatus.BAD_REQUEST);
ERROR_TO_HTTP_STATUS.put(SpServerError.SP_DS_MODULE_UNSUPPORTED, HttpStatus.BAD_REQUEST);
ERROR_TO_HTTP_STATUS.put(SpServerError.SP_DS_TYPE_UNDEFINED, HttpStatus.BAD_REQUEST);
ERROR_TO_HTTP_STATUS.put(SpServerError.SP_REPO_TENANT_NOT_EXISTS, HttpStatus.BAD_REQUEST);
ERROR_TO_HTTP_STATUS.put(SpServerError.SP_ENTITY_LOCKED, HttpStatus.LOCKED);
ERROR_TO_HTTP_STATUS.put(SpServerError.SP_ROLLOUT_ILLEGAL_STATE, HttpStatus.BAD_REQUEST);
ERROR_TO_HTTP_STATUS.put(SpServerError.SP_CONFIGURATION_VALUE_INVALID, HttpStatus.BAD_REQUEST);
ERROR_TO_HTTP_STATUS.put(SpServerError.SP_CONFIGURATION_KEY_INVALID, HttpStatus.BAD_REQUEST);
ERROR_TO_HTTP_STATUS.put(SpServerError.SP_REPO_INVALID_TARGET_ADDRESS, HttpStatus.BAD_REQUEST);
ERROR_TO_HTTP_STATUS.put(SpServerError.SP_REPO_CONSTRAINT_VIOLATION, HttpStatus.BAD_REQUEST);
ERROR_TO_HTTP_STATUS.put(SpServerError.SP_REPO_OPERATION_NOT_SUPPORTED, HttpStatus.GONE);
ERROR_TO_HTTP_STATUS.put(SpServerError.SP_REPO_CONCURRENT_MODIFICATION, HttpStatus.CONFLICT);
ERROR_TO_HTTP_STATUS.put(SpServerError.SP_MAINTENANCE_SCHEDULE_INVALID, HttpStatus.BAD_REQUEST);
ERROR_TO_HTTP_STATUS.put(SpServerError.SP_TARGET_ATTRIBUTES_INVALID, HttpStatus.BAD_REQUEST);
ERROR_TO_HTTP_STATUS.put(SpServerError.SP_REPO_AUTO_CONFIRMATION_ALREADY_ACTIVE, HttpStatus.CONFLICT);
ERROR_TO_HTTP_STATUS.put(SpServerError.SP_AUTO_ASSIGN_ACTION_TYPE_INVALID, HttpStatus.BAD_REQUEST);
ERROR_TO_HTTP_STATUS.put(SpServerError.SP_CONFIGURATION_VALUE_CHANGE_NOT_ALLOWED, HttpStatus.FORBIDDEN);
ERROR_TO_HTTP_STATUS.put(SpServerError.SP_MULTIASSIGNMENT_NOT_ENABLED, HttpStatus.BAD_REQUEST);
ERROR_TO_HTTP_STATUS.put(SpServerError.SP_NO_WEIGHT_PROVIDED_IN_MULTIASSIGNMENT_MODE, HttpStatus.BAD_REQUEST);
ERROR_TO_HTTP_STATUS.put(SpServerError.SP_TARGET_TYPE_IN_USE, HttpStatus.CONFLICT);
ERROR_TO_HTTP_STATUS.put(SpServerError.SP_TARGET_TYPE_INCOMPATIBLE, HttpStatus.BAD_REQUEST);
ERROR_TO_HTTP_STATUS.put(SpServerError.SP_TARGET_TYPE_KEY_OR_NAME_REQUIRED, HttpStatus.BAD_REQUEST);
ERROR_TO_HTTP_STATUS.put(SpServerError.SP_DS_INVALID, HttpStatus.BAD_REQUEST);
ERROR_TO_HTTP_STATUS.put(SpServerError.SP_DS_INCOMPLETE, HttpStatus.BAD_REQUEST);
ERROR_TO_HTTP_STATUS.put(SpServerError.SP_LOCKED, HttpStatus.LOCKED);
ERROR_TO_HTTP_STATUS.put(SpServerError.SP_DELETED, HttpStatus.NOT_FOUND);
ERROR_TO_HTTP_STATUS.put(SpServerError.SP_STOP_ROLLOUT_FAILED, HttpStatus.LOCKED);
}
/**
* method for handling exception of type AbstractServerRtException. Called by the Spring-Framework for exception handling.
*
* @param request the Http request
* @param ex the exception which occurred
* @return the entity to be responded containing the exception information as entity.
*/
@ExceptionHandler(AbstractServerRtException.class)
public ResponseEntity<ExceptionInfo> handleSpServerRtExceptions(final HttpServletRequest request, final Exception ex) {
logRequest(request, ex);
final ExceptionInfo response = createExceptionInfo(ex);
final HttpStatus responseStatus;
if (ex instanceof AbstractServerRtException abstractServerRtException) {
responseStatus = getStatusOrDefault(abstractServerRtException.getError());
} else {
responseStatus = DEFAULT_RESPONSE_STATUS;
}
return new ResponseEntity<>(response, responseStatus);
}
/**
* Method for handling exception of type {@link FileStreamingFailedException} which is thrown in case the streaming of a file failed
* due to an internal server error. As the streaming of the file has already begun, no JSON response but only the ResponseStatus 500
* is returned. Called by the Spring-Framework for exception handling.
*
* @param request the Http request
* @param ex the exception which occurred
* @return the entity to be responded containing the response status 500
*/
@ExceptionHandler({ FileStreamingFailedException.class, IllegalStateException.class })
public ResponseEntity<Object> handleFileStreamingFailedException(final HttpServletRequest request, final Exception ex) {
logRequest(request, ex);
log.warn("File streaming failed: {}", ex.getMessage());
return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);
}
/**
* Method for handling exception of type HttpMessageNotReadableException and MethodArgumentNotValidException which are thrown in case
* the request body is not well-formed (e.g. syntax failures, missing/invalid parameters) and cannot be deserialized.
* Called by the Spring-Framework for exception handling.
*
* @param request the Http request
* @param ex the exception which occurred
* @return the entity to be responded containing the exception information as entity.
*/
@ExceptionHandler({
HttpMessageNotReadableException.class,
MethodArgumentNotValidException.class, HandlerMethodValidationException.class,
IllegalArgumentException.class })
public ResponseEntity<ExceptionInfo> handleExceptionCausedByIncorrectRequestBody(final HttpServletRequest request, final Exception ex) {
logRequest(request, ex);
final ExceptionInfo response = createExceptionInfo(new MessageNotReadableException());
return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
}
/**
* Method for handling exception of type ConstraintViolationException which is thrown in case the request is rejected due to a
* constraint violation. Called by the Spring-Framework for exception handling.
*
* @param request the Http request
* @param ex the exception which occurred
* @return the entity to be responded containing the exception information as entity.
*/
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ExceptionInfo> handleConstraintViolationException(
final HttpServletRequest request, final ConstraintViolationException ex) {
logRequest(request, ex);
final ExceptionInfo response = new ExceptionInfo();
response.setMessage(ex.getConstraintViolations().stream()
.map(violation -> violation.getPropertyPath() + MESSAGE_FORMATTER_SEPARATOR + violation.getMessage() + ".")
.collect(Collectors.joining(MESSAGE_FORMATTER_SEPARATOR)));
response.setExceptionClass(ex.getClass().getName());
response.setErrorCode(SpServerError.SP_REPO_CONSTRAINT_VIOLATION.getKey());
return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
}
/**
* Method for handling exception of type ValidationException which is thrown in case the request is rejected due to invalid requests.
* Called by the Spring-Framework for exception handling.
*
* @param request the Http request
* @param ex the exception which occurred
* @return the entity to be responded containing the exception information as entity.
*/
@ExceptionHandler(ValidationException.class)
public ResponseEntity<ExceptionInfo> handleValidationException(final HttpServletRequest request, final ValidationException ex) {
logRequest(request, ex);
final ExceptionInfo response = new ExceptionInfo();
response.setMessage(ex.getMessage());
response.setExceptionClass(ex.getClass().getName());
response.setErrorCode(SpServerError.SP_REPO_CONSTRAINT_VIOLATION.getKey());
return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
}
/**
* Method for handling exception of type {@link MultipartException} which is thrown in case the request body is not well-formed and
* cannot be deserialized. Called by the Spring-Framework for exception handling.
*
* @param request the Http request
* @param ex the exception which occurred
* @return the entity to be responded containing the exception information as entity.
*/
@ExceptionHandler(MultipartException.class)
public ResponseEntity<ExceptionInfo> handleMultipartException(final HttpServletRequest request, final Exception ex) {
logRequest(request, ex);
final List<Throwable> throwables = ExceptionUtils.getThrowableList(ex);
final Throwable responseCause = throwables.get(throwables.size() - 1);
if (ObjectUtils.isEmpty(responseCause.getMessage())) {
log.warn("Request {} lead to MultipartException without root cause message:\n{}", request.getRequestURL(), ex.getStackTrace());
}
return new ResponseEntity<>(createExceptionInfo(new MultiPartFileUploadException(responseCause)), HttpStatus.BAD_REQUEST);
}
@ExceptionHandler({ DataIntegrityViolationException.class })
public ResponseEntity<ExceptionInfo> handleDataAccessException(final HttpServletRequest request,
final DataIntegrityViolationException ex) {
if (log.isDebugEnabled()) {
logRequest(request, ex);
} else {
log.error(LOG_EXCEPTION_FORMAT, ex.getClass().getName(), request.getRequestURL());
}
final ExceptionInfo response = new ExceptionInfo();
response.setMessage("The data provided violates integrity rules. Please ensure all required fields are valid.");
response.setExceptionClass(ex.getClass().getName());
return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
}
private static HttpStatus getStatusOrDefault(final SpServerError error) {
return ERROR_TO_HTTP_STATUS.getOrDefault(error, DEFAULT_RESPONSE_STATUS);
}
// enable certain level of debug with
// -> logging.level.org.eclipse.hawkbit.rest.RestConfiguration=DEBUG
// or for more detailed log
// -> logging.level.org.eclipse.hawkbit.rest.RestConfiguration=TRACE
private void logRequest(final HttpServletRequest request, final Exception ex) {
if (log.isTraceEnabled()) {
log.trace(LOG_EXCEPTION_FORMAT, ex.getClass().getName(), request.getRequestURL(), ex);
} else {
log.debug(LOG_EXCEPTION_FORMAT, ex.getClass().getName(), request.getRequestURL());
}
}
private ExceptionInfo createExceptionInfo(final Exception ex) {
final ExceptionInfo response = new ExceptionInfo();
response.setMessage(ex.getMessage());
response.setExceptionClass(ex.getClass().getName());
if (ex instanceof AbstractServerRtException abstractServerRtException) {
response.setErrorCode(abstractServerRtException.getError().getKey());
response.setInfo(abstractServerRtException.getInfo());
}
return response;
}
}
/**
* An {@link ShallowEtagHeaderFilter} with exclusion paths to exclude some paths
* where no ETag header should be generated due that calculating the ETag is an
* expensive operation and the response output need to be copied in memory which
* should be excluded in case of artifact downloads which could be big of size.
*/
static class ExcludePathAwareShallowETagFilter extends ShallowEtagHeaderFilter {
private final String[] excludeAntPaths;
private final AntPathMatcher antMatcher = new AntPathMatcher();
public ExcludePathAwareShallowETagFilter(final String... excludeAntPaths) {
this.excludeAntPaths = excludeAntPaths;
}
@Override
protected void doFilterInternal(final HttpServletRequest request, final HttpServletResponse response, final FilterChain filterChain)
throws ServletException, IOException {
final boolean shouldExclude = shouldExclude(request);
if (shouldExclude) {
filterChain.doFilter(request, response);
} else {
super.doFilterInternal(request, response, filterChain);
}
}
private boolean shouldExclude(final HttpServletRequest request) {
for (final String pattern : excludeAntPaths) {
if (antMatcher.match(request.getContextPath() + pattern, request.getRequestURI())) {
// exclude this request from eTag filter
return true;
}
}
return false;
}
}
}

View File

@@ -0,0 +1,123 @@
/**
* Copyright (c) 2015 Bosch Software Innovations GmbH and others
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.rest;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.hawkbit.rest.security.DosFilter;
import org.eclipse.hawkbit.security.HawkbitSecurityProperties;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.PropertySource;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.firewall.FirewalledRequest;
import org.springframework.security.web.firewall.HttpFirewall;
import org.springframework.security.web.firewall.StrictHttpFirewall;
import org.springframework.util.CollectionUtils;
/**
* All configurations related to HawkBit's authentication and authorization layer.
*/
@Slf4j
@Configuration
@EnableWebSecurity
@Order(Ordered.HIGHEST_PRECEDENCE)
@PropertySource("classpath:hawkbit-security-defaults.properties")
public class SecurityManagedConfiguration {
public static final String ANONYMOUS_CONTROLLER_SECURITY_ENABLED_SHOULD_ONLY_BE_USED_FOR_DEVELOPMENT_PURPOSES = """
******************
** Anonymous controller security enabled, should only be used for development purposes **
******************""";
public static final int DOS_FILTER_ORDER = -200;
public static FilterRegistrationBean<DosFilter> dosFilter(final Collection<String> includeAntPaths,
final HawkbitSecurityProperties.Dos.Filter filterProperties,
final HawkbitSecurityProperties.Clients clientProperties) {
final FilterRegistrationBean<DosFilter> filterRegBean = new FilterRegistrationBean<>();
filterRegBean.setFilter(new DosFilter(includeAntPaths, filterProperties.getMaxRead(),
filterProperties.getMaxWrite(), filterProperties.getWhitelist(), clientProperties.getBlacklist(),
clientProperties.getRemoteIpHeader()));
return filterRegBean;
}
/**
* Filter to protect the hawkBit server system management interface against too many requests.
*
* @param securityProperties for filter configuration
* @return the spring filter registration bean for registering a denial of service protection filter in the filter chain
*/
@Bean
@ConditionalOnProperty(prefix = "hawkbit.server.security.dos.filter", name = "enabled", matchIfMissing = true)
public FilterRegistrationBean<DosFilter> dosSystemFilter(final HawkbitSecurityProperties securityProperties) {
final FilterRegistrationBean<DosFilter> filterRegBean = dosFilter(Collections.emptyList(),
securityProperties.getDos().getFilter(), securityProperties.getClients());
filterRegBean.setUrlPatterns(List.of("/system/*"));
filterRegBean.setOrder(DOS_FILTER_ORDER);
filterRegBean.setName("dosSystemFilter");
return filterRegBean;
}
/**
* HttpFirewall which enables to define a list of allowed host names.
*
* @return the http firewall.
*/
@Bean
public HttpFirewall httpFirewall(final HawkbitSecurityProperties hawkbitSecurityProperties) {
final List<String> allowedHostNames = hawkbitSecurityProperties.getAllowedHostNames();
final IgnorePathsStrictHttpFirewall firewall = new IgnorePathsStrictHttpFirewall(
hawkbitSecurityProperties.getHttpFirewallIgnoredPaths());
if (!CollectionUtils.isEmpty(allowedHostNames)) {
firewall.setAllowedHostnames(hostName -> {
log.debug("Firewall check host: {}, allowed: {}", hostName, allowedHostNames.contains(hostName));
return allowedHostNames.contains(hostName);
});
}
return firewall;
}
private static class IgnorePathsStrictHttpFirewall extends StrictHttpFirewall {
private final Collection<String> pathsToIgnore;
public IgnorePathsStrictHttpFirewall(final Collection<String> pathsToIgnore) {
super();
this.pathsToIgnore = pathsToIgnore;
}
@Override
public FirewalledRequest getFirewalledRequest(final HttpServletRequest request) {
if (pathsToIgnore != null && pathsToIgnore.contains(request.getRequestURI())) {
return new FirewalledRequest(request) {
@Override
public void reset() {
// nothing to do
}
};
}
return super.getFirewalledRequest(request);
}
}
}

View File

@@ -0,0 +1,43 @@
/**
* Copyright (c) 2015 Bosch Software Innovations GmbH and others
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.rest.exception;
import java.io.Serial;
import org.eclipse.hawkbit.exception.AbstractServerRtException;
import org.eclipse.hawkbit.exception.SpServerError;
/**
* Thrown if artifact content streaming to client failed.
*/
public final class FileStreamingFailedException extends AbstractServerRtException {
@Serial
private static final long serialVersionUID = 1L;
/**
* Constructor with error string.
*
* @param message of the error
*/
public FileStreamingFailedException(final String message) {
super(SpServerError.SP_ARTIFACT_LOAD_FAILED, message);
}
/**
* Constructor with error string and cause.
*
* @param message of the error
* @param cause for the exception
*/
public FileStreamingFailedException(final String message, final Throwable cause) {
super(SpServerError.SP_ARTIFACT_LOAD_FAILED, message, cause);
}
}

View File

@@ -0,0 +1,32 @@
/**
* Copyright (c) 2015 Bosch Software Innovations GmbH and others
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.rest.exception;
import java.io.Serial;
import org.eclipse.hawkbit.exception.AbstractServerRtException;
import org.eclipse.hawkbit.exception.SpServerError;
/**
* Exception which is thrown in case an request body is not well formatted and cannot be parsed.
*/
public class MessageNotReadableException extends AbstractServerRtException {
@Serial
private static final long serialVersionUID = 1L;
/**
* Creates a new MessageNotReadableException with
* {@link SpServerError#SP_REST_BODY_NOT_READABLE} error.
*/
public MessageNotReadableException() {
super(SpServerError.SP_REST_BODY_NOT_READABLE);
}
}

View File

@@ -0,0 +1,28 @@
/**
* Copyright (c) 2015 Bosch Software Innovations GmbH and others
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.rest.exception;
import java.io.Serial;
import org.eclipse.hawkbit.exception.AbstractServerRtException;
import org.eclipse.hawkbit.exception.SpServerError;
/**
* Thrown if a multipart exception occurred.
*/
public final class MultiPartFileUploadException extends AbstractServerRtException {
@Serial
private static final long serialVersionUID = 1L;
public MultiPartFileUploadException(final Throwable cause) {
super(SpServerError.SP_ARTIFACT_UPLOAD_FAILED, cause.getMessage(), cause);
}
}

View File

@@ -0,0 +1,183 @@
/**
* Copyright (c) 2015 Bosch Software Innovations GmbH and others
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.rest.security;
import static org.eclipse.hawkbit.audit.SecurityLogger.LOGGER;
import java.io.IOException;
import java.util.Collection;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.regex.Pattern;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.hawkbit.utils.IpUtil;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.filter.OncePerRequestFilter;
/**
* Filter for protection against Denial-of-Service (DoS) attacks. It reduces the maximum number of request per seconds which can be separately
* configured for read (GET) and write (PUT/POST/DELETE) requests.
*/
@Slf4j
public class DosFilter extends OncePerRequestFilter {
private final AntPathMatcher antMatcher = new AntPathMatcher();
private final Collection<String> includeAntPaths;
private final Pattern ipAddressBlacklist;
private final Cache<String, AtomicInteger> readCountCache = Caffeine.newBuilder().expireAfterWrite(1, TimeUnit.SECONDS).build();
private final Cache<String, AtomicInteger> writeCountCache = Caffeine.newBuilder().expireAfterWrite(1, TimeUnit.SECONDS).build();
private final int maxRead;
private final int maxWrite;
private final Pattern whitelist;
private final String forwardHeader;
/**
* Filter constructor including configuration.
*
* @param includeAntPaths paths where filter should hit
* @param maxRead Maximum number of allowed REST read/GET requests per second per client
* @param maxWrite Maximum number of allowed REST write/(PUT/POST/etc.) requests per second per client
* @param ipDosWhiteListPattern {@link Pattern} with with white list of peer IP addresses for DOS filter
* @param ipBlackListPattern {@link Pattern} with black listed IP addresses
* @param forwardHeader the header containing the forwarded IP address e.g. {@code x-forwarded-for}
*/
public DosFilter(
final Collection<String> includeAntPaths, final int maxRead, final int maxWrite,
final String ipDosWhiteListPattern, final String ipBlackListPattern, final String forwardHeader) {
this.includeAntPaths = includeAntPaths;
this.maxRead = maxRead;
this.maxWrite = maxWrite;
this.forwardHeader = forwardHeader;
if (ipBlackListPattern != null && !ipBlackListPattern.isEmpty()) {
ipAddressBlacklist = Pattern.compile(ipBlackListPattern);
} else {
ipAddressBlacklist = null;
}
if (ipDosWhiteListPattern != null && !ipDosWhiteListPattern.isEmpty()) {
whitelist = Pattern.compile(ipDosWhiteListPattern);
} else {
whitelist = null;
}
}
@Override
protected void doFilterInternal(final HttpServletRequest request, final HttpServletResponse response, final FilterChain filterChain)
throws ServletException, IOException {
if (!shouldInclude(request)) {
filterChain.doFilter(request, response);
return;
}
boolean processChain;
final String ip = IpUtil.getClientIpFromRequest(request, forwardHeader).getHost();
if (checkIpFails(ip)) {
processChain = handleMissingIpAddress(response);
} else {
processChain = checkAgainstBlacklist(response, ip);
if (processChain && (whitelist == null || !whitelist.matcher(ip).find())) {
// read request
if (HttpMethod.valueOf(request.getMethod()) == HttpMethod.GET) {
processChain = handleReadRequest(response, ip);
}
// write request
else {
processChain = handleWriteRequest(response, ip);
}
}
}
if (processChain) {
filterChain.doFilter(request, response);
}
}
private static boolean checkIpFails(final String ip) {
return ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip);
}
private static boolean handleMissingIpAddress(final HttpServletResponse response) {
log.error("Failed to get peer IP address");
response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
return false;
}
private boolean shouldInclude(final HttpServletRequest request) {
if (includeAntPaths == null || includeAntPaths.isEmpty()) {
return true;
}
return includeAntPaths.stream()
.anyMatch(pattern -> antMatcher.match(request.getContextPath() + pattern, request.getRequestURI()));
}
/**
* @return false if the given ip address is on the blacklist and further
* processing of the request if forbidden
*/
private boolean checkAgainstBlacklist(final HttpServletResponse response, final String ip) {
if (ipAddressBlacklist != null && ipAddressBlacklist.matcher(ip).find()) {
LOGGER.info("[BLACKLIST] Blacklisted client ({}) tries to access the server!", ip);
response.setStatus(HttpStatus.FORBIDDEN.value());
return false;
}
return true;
}
private boolean handleWriteRequest(final HttpServletResponse response, final String ip) {
boolean processChain = true;
final AtomicInteger count = writeCountCache.getIfPresent(ip);
if (count == null) {
writeCountCache.put(ip, new AtomicInteger());
} else if (count.getAndIncrement() > maxWrite) {
LOGGER.info("[DOS] Registered DOS attack! Client {} is above configured WRITE request threshold ({})!", ip, maxWrite);
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
processChain = false;
}
return processChain;
}
private boolean handleReadRequest(final HttpServletResponse response, final String ip) {
boolean processChain = true;
final AtomicInteger count = readCountCache.getIfPresent(ip);
if (count == null) {
readCountCache.put(ip, new AtomicInteger());
} else if (count.getAndIncrement() > maxRead) {
LOGGER.info("[DOS] Registered DOS attack! Client {} is above configured READ request threshold ({})!", ip, maxRead);
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
processChain = false;
}
return processChain;
}
}

View File

@@ -0,0 +1,422 @@
/**
* Copyright (c) 2015 Bosch Software Innovations GmbH and others
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.rest.util;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import jakarta.servlet.ServletOutputStream;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.IOUtils;
import org.eclipse.hawkbit.artifact.model.ArtifactStream;
import org.eclipse.hawkbit.rest.exception.FileStreamingFailedException;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
/**
* Utility class for artifact file streaming.
*/
@NoArgsConstructor(access = AccessLevel.PRIVATE)
@Slf4j
public final class FileStreamingUtil {
private static final int BUFFER_SIZE = 0x2000; // 8k
/**
* <p>
* Write response with target relation and publishes events concerning the
* download progress based on given update action status.
* </p>
* <p>
* The request supports RFC7233 range requests.
* </p>
*
* @param artifact the artifact
* @param filename to be written to the client response
* @param lastModified unix timestamp of the artifact
* @param response to be sent back to the requesting client
* @param request from the client
* @param progressListener to write progress updates to
* @return http response
* @throws FileStreamingFailedException if streaming fails
* @see <a href="https://tools.ietf.org/html/rfc7233">https://tools.ietf.org/html/rfc7233</a>
*/
public static ResponseEntity<InputStream> writeFileResponse(
final ArtifactStream artifact, final String filename,
final long lastModified, final HttpServletResponse response, final HttpServletRequest request,
final FileStreamingProgressListener progressListener) {
ResponseEntity<InputStream> result;
final String etag = artifact.getSha1Hash();
final long length = artifact.getSize();
resetResponseExceptHeaders(response);
response.setHeader(HttpHeaders.CONTENT_DISPOSITION, "attachment;filename=" + filename);
response.setHeader(HttpHeaders.ETAG, etag);
response.setHeader(HttpHeaders.ACCEPT_RANGES, "bytes");
// set the x-content-type options header to prevent browsers from doing
// MIME-sniffing when downloading an artifact, as this could cause a
// security vulnerability
response.setHeader("X-Content-Type-Options", "nosniff");
if (lastModified > 0) {
response.setDateHeader(HttpHeaders.LAST_MODIFIED, lastModified);
}
response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE);
response.setBufferSize(BUFFER_SIZE);
final ByteRange full = new ByteRange(0, length - 1, length);
final List<ByteRange> ranges = new ArrayList<>();
// Validate and process Range and If-Range headers.
final String range = request.getHeader("Range");
if (lastModified > 0 && range != null) {
log.debug("range header for filename ({}) is: {}", filename, range);
// Range header matches"bytes=n-n,n-n,n-n..."
if (!range.matches("^bytes=\\d*-\\d*(,\\d*-\\d*)*+$")) {
response.setHeader(HttpHeaders.CONTENT_RANGE, "bytes */" + length);
log.debug("range header for filename ({}) is not satisfiable: ", filename);
return new ResponseEntity<>(HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE);
}
// RFC: if the representation is unchanged, send me the part(s) that I am requesting in
// Range; otherwise, send me the entire representation.
checkForShortcut(request, etag, lastModified, full, ranges);
// it seems there are valid ranges
result = extractRange(response, length, ranges, range);
// return if range extraction turned out to be invalid
if (result != null) {
return result;
}
}
try (final InputStream inputStream = artifact) {
// full request - no range
if (ranges.isEmpty() || ranges.get(0).equals(full)) {
log.debug("filename ({}) results into a full request: ", filename);
result = handleFullFileRequest(inputStream, filename, response, progressListener, full);
} else if (ranges.size() == 1) { // standard range request
log.debug("filename ({}) results into a standard range request: ", filename);
result = handleStandardRangeRequest(inputStream, filename, response, progressListener, ranges.get(0));
} else { // multipart range request
log.debug("filename ({}) results into a multipart range request: ", filename);
result = handleMultipartRangeRequest(inputStream, filename, response, progressListener, ranges);
}
} catch (final IOException e) {
log.error("streaming of file ({}) failed!", filename, e);
throw new FileStreamingFailedException(filename, e);
}
return result;
}
private static void resetResponseExceptHeaders(final HttpServletResponse response) {
// do backup the current headers (like CORS related)
final Map<String, String> storedHeaders = new HashMap<>();
for (final String header : response.getHeaderNames()) {
storedHeaders.put(header, response.getHeader(header));
}
// resetting the response is needed only partially. Headers set before e.b. by
// the CORS security config needs to be persisted.
response.reset();
// restore headers again
storedHeaders.forEach(response::addHeader);
}
private static ResponseEntity<InputStream> handleFullFileRequest(
final InputStream inputStream, final String filename, final HttpServletResponse response,
final FileStreamingProgressListener progressListener, final ByteRange full) {
response.setHeader(HttpHeaders.CONTENT_RANGE, "bytes " + full.getStart() + "-" + full.getEnd() + "/" + full.getTotal());
response.setContentLengthLong(full.getLength());
try {
final ServletOutputStream to = response.getOutputStream();
copyStreams(inputStream, to, progressListener, full.getStart(), full.getLength(), filename);
} catch (final IOException e) {
throw new FileStreamingFailedException("fullfileRequest " + filename, e);
}
return ResponseEntity.ok().build();
}
private static ResponseEntity<InputStream> extractRange(final HttpServletResponse response, final long length,
final List<ByteRange> ranges, final String range) {
if (ranges.isEmpty()) {
for (final String part : range.substring(6).split(",")) {
long start = sublong(part, 0, part.indexOf('-'));
long end = sublong(part, part.indexOf('-') + 1, part.length());
if (start == -1) {
start = length - end;
end = length - 1;
} else if (end == -1 || end > length - 1) {
end = length - 1;
}
// Check if Range is syntactically valid. If not, then return
// 416.
if (start > end) {
response.setHeader(HttpHeaders.CONTENT_RANGE, "bytes */" + length);
return new ResponseEntity<>(HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE);
}
// Add range.
ranges.add(new ByteRange(start, end, length));
}
}
return null;
}
private static long sublong(final String value, final int beginIndex, final int endIndex) {
final String substring = value.substring(beginIndex, endIndex);
return substring.isEmpty() ? -1 : Long.parseLong(substring);
}
private static void checkForShortcut(final HttpServletRequest request, final String etag, final long lastModified,
final ByteRange full, final List<ByteRange> ranges) {
final String ifRange = request.getHeader(HttpHeaders.IF_RANGE);
if (ifRange != null && !ifRange.equals(etag)) {
try {
final long ifRangeTime = request.getDateHeader(HttpHeaders.IF_RANGE);
if (ifRangeTime != -1 && ifRangeTime + 1000 < lastModified) {
ranges.add(full);
}
} catch (final IllegalArgumentException ignored) {
log.info("Invalid if-range header field", ignored);
ranges.add(full);
}
}
}
private static ResponseEntity<InputStream> handleMultipartRangeRequest(
final InputStream inputStream, final String filename, final HttpServletResponse response,
final FileStreamingProgressListener progressListener, final List<ByteRange> ranges) {
ranges.sort((r1, r2) -> Long.compare(r1.getStart(), r2.getStart()));
response.setContentType("multipart/byteranges; boundary=" + ByteRange.MULTIPART_BOUNDARY);
response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
try {
final ServletOutputStream to = response.getOutputStream();
long streamPos = 0;
for (final ByteRange range : ranges) {
if (streamPos > range.getStart()) {
throw new FileStreamingFailedException("Ranges are overlapping or not in order");
}
// Add multipart boundary and header fields for every range.
to.println();
to.println("--" + ByteRange.MULTIPART_BOUNDARY);
to.println(HttpHeaders.CONTENT_RANGE + ": bytes " + range.getStart() + "-" + range.getEnd() + "/" + range.getTotal());
// Copy single part range of multipart range.
copyStreams(inputStream, to, progressListener, range.getStart() - streamPos, range.getLength(), filename);
streamPos = range.getStart() + range.getLength();
}
// End with final multipart boundary.
to.println();
to.print("--" + ByteRange.MULTIPART_BOUNDARY + "--");
} catch (final IOException e) {
throw new FileStreamingFailedException("multipartRangeRequest " + filename, e);
}
return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT).build();
}
private static ResponseEntity<InputStream> handleStandardRangeRequest(
final InputStream inputStream, final String filename, final HttpServletResponse response,
final FileStreamingProgressListener progressListener, final ByteRange range) {
response.setHeader(HttpHeaders.CONTENT_RANGE, "bytes " + range.getStart() + "-" + range.getEnd() + "/" + range.getTotal());
response.setContentLengthLong(range.getLength());
response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
try {
final ServletOutputStream to = response.getOutputStream();
copyStreams(inputStream, to, progressListener, range.getStart(), range.getLength(), filename);
} catch (final IOException e) {
log.error("standardRangeRequest of file ({}) failed!", filename, e);
throw new FileStreamingFailedException(filename);
}
return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT).build();
}
private static long copyStreams(
final InputStream from, final OutputStream to,
final FileStreamingProgressListener progressListener,
final long start, final long length,
final String filename) throws IOException {
final long startMillis = System.currentTimeMillis();
log.trace("Start of copy-streams of file {} from {} to {}", filename, start, length);
Objects.requireNonNull(from);
Objects.requireNonNull(to);
final byte[] buf = new byte[BUFFER_SIZE];
long total = 0;
int progressPercent = 1;
IOUtils.skipFully(from, start);
long toRead = length;
boolean toContinue = true;
long shippedSinceLastEvent = 0;
while (toContinue) {
final int r = from.read(buf, 0, Math.min(BUFFER_SIZE, toRead > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) toRead));
if (r == -1) {
break;
}
toRead -= r;
if (toRead > 0) {
to.write(buf, 0, r);
total += r;
shippedSinceLastEvent += r;
} else {
to.write(buf, 0, (int) toRead + r);
total += toRead + r;
shippedSinceLastEvent += toRead + r;
toContinue = false;
}
if (progressListener != null) {
final int newPercent = (int) Math.floor(total * 100.0 / length);
// every 10 percent an event
if (newPercent == 100 || newPercent > progressPercent + 10) {
progressPercent = newPercent;
progressListener.progress(length, shippedSinceLastEvent, total);
shippedSinceLastEvent = 0;
}
}
}
final long totalTime = System.currentTimeMillis() - startMillis;
if (total < length) {
throw new FileStreamingFailedException(
filename + ": " + (length - total) + " bytes could not be written to client, total time on write: !" + totalTime + " ms");
}
log.trace("Finished copy-stream of file {} with length {} in {} ms", filename, length, totalTime);
return total;
}
/**
* Listener for progress on artifact file streaming.
*/
@FunctionalInterface
public interface FileStreamingProgressListener {
/**
* Called multiple times during streaming.
*
* @param requestedBytes requested bytes of the request
* @param shippedBytesSinceLast since the last report
* @param shippedBytesOverall during the request
*/
void progress(long requestedBytes, long shippedBytesSinceLast, long shippedBytesOverall);
}
private static final class ByteRange {
private static final String MULTIPART_BOUNDARY = "THIS_STRING_SEPARATES_MULTIPART";
private final long start;
private final long end;
private final long length;
private final long total;
private ByteRange(final long start, final long end, final long total) {
this.start = start;
this.end = end;
length = end - start + 1;
this.total = total;
}
@Override
// Generated code
@SuppressWarnings("squid:S864")
public int hashCode() {
final int prime = 31;
int result = 1;
result = prime * result + (int) (end ^ (end >>> 32));
result = prime * result + (int) (length ^ (length >>> 32));
result = prime * result + (int) (start ^ (start >>> 32));
result = prime * result + (int) (total ^ (total >>> 32));
return result;
}
@Override
// Generated code
@SuppressWarnings("squid:S1126")
public boolean equals(final Object obj) {
if (this == obj) {
return true;
}
if (obj == null) {
return false;
}
if (getClass() != obj.getClass()) {
return false;
}
final ByteRange other = (ByteRange) obj;
if (end != other.end) {
return false;
}
if (length != other.length) {
return false;
}
if (start != other.start) {
return false;
}
if (total != other.total) {
return false;
}
return true;
}
private long getStart() {
return start;
}
private long getEnd() {
return end;
}
private long getLength() {
return length;
}
private long getTotal() {
return total;
}
}
}

View File

@@ -0,0 +1,33 @@
/**
* Copyright (c) 2015 Bosch Software Innovations GmbH and others
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.rest.util;
import java.util.stream.Stream;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
/**
* Utility class for the Rest Source API.
*/
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public final class HttpUtil {
/**
* Checks given CSV string for defined match value or wildcard.
*
* @param matchHeader to search through
* @param toMatch to search for
* @return <code>true</code> if string matches.
*/
public static boolean matchesHttpHeader(final String matchHeader, final String toMatch) {
return Stream.of(matchHeader.split(",")).map(String::trim).anyMatch(chunk -> chunk.equals(toMatch) || chunk.equals("*"));
}
}

View File

@@ -0,0 +1,42 @@
/**
* Copyright (c) 2015 Bosch Software Innovations GmbH and others
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.rest.util;
import java.util.Objects;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.NoArgsConstructor;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
/**
* Gives access to the request and response for the rest resources.
*/
@NoArgsConstructor(access = lombok.AccessLevel.PRIVATE)
public class RequestResponseContextHolder {
public static HttpServletRequest getHttpServletRequest() {
return Objects
.requireNonNull(
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes(),
"Request attribute is unavailable")
.getRequest();
}
public static HttpServletResponse getHttpServletResponse() {
return Objects
.requireNonNull(
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes(),
"Request attribute is unavailable")
.getResponse();
}
}

View File

@@ -0,0 +1,75 @@
/**
* Copyright (c) 2015 Bosch Software Innovations GmbH and others
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.rest;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.eclipse.hawkbit.repository.jpa.JpaRepositoryConfiguration;
import org.eclipse.hawkbit.repository.jpa.model.AbstractJpaBaseEntity_;
import org.eclipse.hawkbit.repository.jpa.model.JpaAction;
import org.eclipse.hawkbit.repository.jpa.model.JpaAction_;
import org.eclipse.hawkbit.repository.test.TestConfiguration;
import org.eclipse.hawkbit.repository.test.util.AbstractIntegrationTest;
import org.junit.jupiter.api.BeforeEach;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.web.WebAppConfiguration;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.filter.CharacterEncodingFilter;
/**
* Abstract Test for Rest tests.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
@ContextConfiguration(classes = { RestConfiguration.class, JpaRepositoryConfiguration.class, TestConfiguration.class })
@WebAppConfiguration
@AutoConfigureMockMvc
public abstract class AbstractRestIntegrationTest extends AbstractIntegrationTest {
protected static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
protected MockMvc mvc;
@Autowired
protected WebApplicationContext webApplicationContext;
@Autowired
private CharacterEncodingFilter characterEncodingFilter;
@BeforeEach
public void before() {
mvc = createMvcWebAppContext(webApplicationContext).build();
}
protected DefaultMockMvcBuilder createMvcWebAppContext(final WebApplicationContext context) {
final DefaultMockMvcBuilder createMvcWebAppContext = MockMvcBuilders.webAppContextSetup(context);
// CharacterEncodingFilter is needed for the encoding properties to be imported properly
createMvcWebAppContext.addFilter(characterEncodingFilter);
createMvcWebAppContext.addFilter(
new RestConfiguration.ExcludePathAwareShallowETagFilter("/rest/v1/softwaremodules/{smId}/artifacts/{artId}/download",
"/{tenant}/controller/v1/{controllerId}/softwaremodules/{softwareModuleId}/artifacts/**",
"/api/v1/downloadserver/**"));
return createMvcWebAppContext;
}
protected static String toJson(final Object obj) throws JsonProcessingException {
return OBJECT_MAPPER.writeValueAsString(obj);
}
protected static Specification<JpaAction> byDistributionSetId(final Long distributionSetId) {
return (root, query, cb) -> cb.equal(root.get(JpaAction_.distributionSet).get(AbstractJpaBaseEntity_.id), distributionSetId);
}
}

View File

@@ -0,0 +1,92 @@
/**
* Copyright (c) 2015 Bosch Software Innovations GmbH and others
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.rest;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mockingDetails;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.IOException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
/**
* Feature: Unit Tests - Security<br/>
* Story: Exclude path aware shallow ETag filter
*/
@ExtendWith(MockitoExtension.class)
class ExcludePathAwareShallowETagFilterTest {
@Mock
private HttpServletRequest servletRequestMock;
@Mock
private HttpServletResponse servletResponseMock;
@Mock
private FilterChain filterChainMock;
@Test
void excludePathDoesNotCalculateETag() throws ServletException, IOException {
final String knownContextPath = "/bumlux/test";
final String knownUri = knownContextPath + "/exclude/download";
final String antPathExclusion = "/exclude/**";
// mock
when(servletRequestMock.getContextPath()).thenReturn(knownContextPath);
when(servletRequestMock.getRequestURI()).thenReturn(knownUri);
final RestConfiguration.ExcludePathAwareShallowETagFilter filterUnderTest = new RestConfiguration.ExcludePathAwareShallowETagFilter(
antPathExclusion);
filterUnderTest.doFilterInternal(servletRequestMock, servletResponseMock, filterChainMock);
// verify no eTag header is set and response has not been changed
assertThat(servletResponseMock.getHeader("ETag"))
.as("ETag header should not be set during downloading, too expensive").isNull();
// the servlet response must be the same mock!
verify(filterChainMock, times(1)).doFilter(servletRequestMock, servletResponseMock);
}
@Test
void pathNotExcludedETagIsCalculated() throws ServletException, IOException {
final String knownContextPath = "/bumlux/test";
final String knownUri = knownContextPath + "/include/download";
final String antPathExclusion = "/exclude/**";
// mock
when(servletRequestMock.getContextPath()).thenReturn(knownContextPath);
when(servletRequestMock.getRequestURI()).thenReturn(knownUri);
final RestConfiguration.ExcludePathAwareShallowETagFilter filterUnderTest = new RestConfiguration.ExcludePathAwareShallowETagFilter(
antPathExclusion);
final ArgumentCaptor<HttpServletResponse> responseArgumentCaptor = ArgumentCaptor
.forClass(HttpServletResponse.class);
filterUnderTest.doFilterInternal(servletRequestMock, servletResponseMock, filterChainMock);
// the servlet response must be the same mock!
verify(filterChainMock, times(1)).doFilter(Mockito.eq(servletRequestMock), responseArgumentCaptor.capture());
assertThat(mockingDetails(responseArgumentCaptor.getValue()).isMock()).isFalse();
}
}

View File

@@ -0,0 +1,90 @@
/**
* Copyright (c) 2022 Bosch.IO GmbH and others
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.rest.util;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.function.Supplier;
import jakarta.servlet.ServletOutputStream;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.eclipse.hawkbit.artifact.model.ArtifactStream;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.Mockito;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
/**
* Feature: Component Tests - Management API<br/>
* Story: File streaming
*/
class FileStreamingUtilTest {
private static final String CONTENT = "This is some very long string which is intended to test";
private static final byte[] CONTENT_BYTES = CONTENT.getBytes(StandardCharsets.UTF_8);
private static final Supplier<ArtifactStream> TEST_ARTIFACT =
() -> new ArtifactStream(new ByteArrayInputStream(CONTENT_BYTES), CONTENT_BYTES.length, "sha1-111");
@Test
void shouldProcessRangeHeaderForMultipartRequests() throws IOException {
final HttpServletResponse servletResponse = Mockito.mock(HttpServletResponse.class);
final ServletOutputStream outputStream = Mockito.mock(ServletOutputStream.class);
Mockito.when(servletResponse.getOutputStream()).thenReturn(outputStream);
final HttpServletRequest servletRequest = Mockito.mock(HttpServletRequest.class);
Mockito.when(servletRequest.getHeader("Range")).thenReturn("bytes=0-10,11-15,16-");
long lastModified = System.currentTimeMillis();
final ResponseEntity<InputStream> responseEntity = FileStreamingUtil.writeFileResponse(
TEST_ARTIFACT.get(), "test.file", lastModified, servletResponse, servletRequest, null);
assertThat(responseEntity).isNotNull();
verify(servletResponse).setDateHeader(HttpHeaders.LAST_MODIFIED, lastModified);
final ArgumentCaptor<String> stringCaptor = ArgumentCaptor.forClass(String.class);
final ArgumentCaptor<Integer> lenCaptor = ArgumentCaptor.forClass(Integer.class);
verify(outputStream).print(stringCaptor.capture());
assertThat(stringCaptor.getValue()).contains("--THIS_STRING_SEPARATES_MULTIPART--");
verify(outputStream, times(3)).write(any(), anyInt(), lenCaptor.capture());
assertThat(lenCaptor.getAllValues()).containsExactly(11, 5, 39); // Range lengths
}
@Test
void shouldValidateRangeHeaderForMultipartRequests() throws IOException {
long lastModified = System.currentTimeMillis();
final HttpServletResponse servletResponse = Mockito.mock(HttpServletResponse.class);
final ServletOutputStream outputStream = Mockito.mock(ServletOutputStream.class);
Mockito.when(servletResponse.getOutputStream()).thenReturn(outputStream);
final HttpServletRequest servletRequest = Mockito.mock(HttpServletRequest.class);
Mockito.when(servletRequest.getHeader("Range")).thenReturn("bytes=0-10***,9-15,16-");
final ResponseEntity<InputStream> responseEntity = FileStreamingUtil.writeFileResponse(
TEST_ARTIFACT.get(), "test.file", lastModified, servletResponse, servletRequest, null);
assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE);
verify(outputStream, times(0)).print(anyString());
verify(outputStream, times(0)).write(any(), anyInt(), anyInt());
}
}

View File

@@ -0,0 +1,56 @@
/**
* Copyright (c) 2015 Bosch Software Innovations GmbH and others
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.rest.util;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.ResultHandler;
import org.springframework.test.web.servlet.result.PrintingResultHandler;
import org.springframework.util.CollectionUtils;
@NoArgsConstructor(access = AccessLevel.PRIVATE)
@Slf4j
public abstract class MockMvcResultPrinter {
/**
* Print {@link MvcResult} details to logger.
*/
public static ResultHandler print() {
return new ConsolePrintingResultHandler();
}
/**
* An {@link PrintingResultHandler} that writes to logger
*/
private static class ConsolePrintingResultHandler extends PrintingResultHandler {
public ConsolePrintingResultHandler() {
super(new ResultValuePrinter() {
@Override
public void printHeading(final String heading) {
log.debug(String.format("%20s:", heading));
}
@Override
public void printValue(final String label, final Object v) {
Object value = v;
if (value != null && value.getClass().isArray()) {
value = CollectionUtils.arrayToList(value);
}
log.debug(String.format("%20s = %s", label, value));
}
});
}
}
}

View File

@@ -0,0 +1,23 @@
/**
* Copyright (c) 2015 Bosch Software Innovations GmbH and others
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.rest.util;
/**
* @param <T>
*/
public interface SuccessCondition<T> {
/**
* @param result
* @return
*/
boolean success(final T result);
}