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,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);
}