Make exposed CORS headers configurable (#1322)
* Prevent losing headers (like CORS related ones) when resetting the response headers due to artifact stream. * add comment * Extend CORS tests to verify provided header.
This commit is contained in:
@@ -591,6 +591,7 @@ public class SecurityManagedConfiguration {
|
||||
corsConfiguration.setAllowCredentials(true);
|
||||
corsConfiguration.setAllowedHeaders(securityProperties.getCors().getAllowedHeaders());
|
||||
corsConfiguration.setAllowedMethods(securityProperties.getCors().getAllowedMethods());
|
||||
corsConfiguration.setExposedHeaders(securityProperties.getCors().getExposedHeaders());
|
||||
|
||||
return corsConfiguration;
|
||||
}
|
||||
|
||||
@@ -40,14 +40,12 @@ import org.eclipse.hawkbit.repository.builder.TargetUpdate;
|
||||
import org.eclipse.hawkbit.repository.event.remote.TargetAttributesRequestedEvent;
|
||||
import org.eclipse.hawkbit.repository.event.remote.TargetDeletedEvent;
|
||||
import org.eclipse.hawkbit.repository.event.remote.entity.TargetUpdatedEvent;
|
||||
import org.eclipse.hawkbit.repository.exception.AutoConfirmationAlreadyActiveException;
|
||||
import org.eclipse.hawkbit.repository.exception.EntityAlreadyExistsException;
|
||||
import org.eclipse.hawkbit.repository.exception.EntityNotFoundException;
|
||||
import org.eclipse.hawkbit.repository.jpa.builder.JpaTargetCreate;
|
||||
import org.eclipse.hawkbit.repository.jpa.builder.JpaTargetUpdate;
|
||||
import org.eclipse.hawkbit.repository.jpa.configuration.Constants;
|
||||
import org.eclipse.hawkbit.repository.jpa.executor.AfterTransactionCommitExecutor;
|
||||
import org.eclipse.hawkbit.repository.jpa.model.JpaAutoConfirmationStatus;
|
||||
import org.eclipse.hawkbit.repository.jpa.model.JpaTarget;
|
||||
import org.eclipse.hawkbit.repository.jpa.model.JpaTargetMetadata;
|
||||
import org.eclipse.hawkbit.repository.jpa.model.JpaTargetMetadata_;
|
||||
@@ -59,7 +57,6 @@ import org.eclipse.hawkbit.repository.jpa.rsql.RSQLUtility;
|
||||
import org.eclipse.hawkbit.repository.jpa.specifications.SpecificationsBuilder;
|
||||
import org.eclipse.hawkbit.repository.jpa.specifications.TargetSpecifications;
|
||||
import org.eclipse.hawkbit.repository.jpa.utils.QuotaHelper;
|
||||
import org.eclipse.hawkbit.repository.model.AutoConfirmationStatus;
|
||||
import org.eclipse.hawkbit.repository.model.DistributionSet;
|
||||
import org.eclipse.hawkbit.repository.model.DistributionSetType;
|
||||
import org.eclipse.hawkbit.repository.model.MetaData;
|
||||
|
||||
@@ -14,7 +14,9 @@ import java.io.OutputStream;
|
||||
import java.math.RoundingMode;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import javax.servlet.ServletOutputStream;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
@@ -127,7 +129,8 @@ public final class FileStreamingUtil {
|
||||
final String etag = artifact.getHashes().getSha1();
|
||||
final long length = artifact.getSize();
|
||||
|
||||
response.reset();
|
||||
resetResponseExceptHeaders(response);
|
||||
|
||||
response.setHeader(HttpHeaders.CONTENT_DISPOSITION, "attachment;filename=" + filename);
|
||||
response.setHeader(HttpHeaders.ETAG, etag);
|
||||
response.setHeader(HttpHeaders.ACCEPT_RANGES, "bytes");
|
||||
@@ -189,6 +192,19 @@ public final class FileStreamingUtil {
|
||||
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 DbArtifact artifact, final String filename,
|
||||
final HttpServletResponse response, final FileStreamingProgressListener progressListener,
|
||||
final ByteRange full) {
|
||||
@@ -196,7 +212,7 @@ public final class FileStreamingUtil {
|
||||
response.setHeader(HttpHeaders.CONTENT_RANGE, "bytes " + r.getStart() + "-" + r.getEnd() + "/" + r.getTotal());
|
||||
response.setContentLengthLong(r.getLength());
|
||||
|
||||
try (InputStream from = artifact.getFileInputStream()) {
|
||||
try (final InputStream from = artifact.getFileInputStream()) {
|
||||
final ServletOutputStream to = response.getOutputStream();
|
||||
copyStreams(from, to, progressListener, r.getStart(), r.getLength(), filename);
|
||||
} catch (final IOException e) {
|
||||
@@ -268,7 +284,7 @@ public final class FileStreamingUtil {
|
||||
final ServletOutputStream to = response.getOutputStream();
|
||||
|
||||
for (final ByteRange r : ranges) {
|
||||
try (InputStream from = artifact.getFileInputStream()) {
|
||||
try (final InputStream from = artifact.getFileInputStream()) {
|
||||
|
||||
// Add multipart boundary and header fields for every range.
|
||||
to.println();
|
||||
@@ -299,7 +315,7 @@ public final class FileStreamingUtil {
|
||||
response.setContentLengthLong(r.getLength());
|
||||
response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
|
||||
|
||||
try (InputStream from = artifact.getFileInputStream()) {
|
||||
try (final InputStream from = artifact.getFileInputStream()) {
|
||||
final ServletOutputStream to = response.getOutputStream();
|
||||
copyStreams(from, to, progressListener, r.getStart(), r.getLength(), filename);
|
||||
} catch (final IOException e) {
|
||||
|
||||
@@ -10,29 +10,30 @@ package org.eclipse.hawkbit.app;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.options;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import org.eclipse.hawkbit.mgmt.rest.api.MgmtRestConstants;
|
||||
|
||||
import org.eclipse.hawkbit.repository.test.util.MsSqlTestDatabase;
|
||||
import org.eclipse.hawkbit.repository.test.util.MySqlTestDatabase;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.eclipse.hawkbit.repository.test.util.PostgreSqlTestDatabase;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.security.test.context.support.WithUserDetails;
|
||||
import org.springframework.test.context.TestExecutionListeners;
|
||||
import org.springframework.test.context.TestExecutionListeners.MergeMode;
|
||||
import org.springframework.test.context.TestPropertySource;
|
||||
import org.springframework.test.web.servlet.ResultActions;
|
||||
|
||||
import io.qameta.allure.Description;
|
||||
import io.qameta.allure.Feature;
|
||||
import io.qameta.allure.Story;
|
||||
|
||||
@SpringBootTest(properties = {"hawkbit.dmf.rabbitmq.enabled=false", "hawkbit.server.security.cors.enabled=true",
|
||||
"hawkbit.server.security.cors.allowedOrigins=" + CorsTest.ALLOWED_ORIGIN_FIRST + "," + CorsTest.ALLOWED_ORIGIN_SECOND})
|
||||
@TestExecutionListeners(listeners = { MySqlTestDatabase.class, MsSqlTestDatabase.class },
|
||||
mergeMode = MergeMode.MERGE_WITH_DEFAULTS)
|
||||
@SpringBootTest(properties = { "hawkbit.dmf.rabbitmq.enabled=false", "hawkbit.server.security.cors.enabled=true",
|
||||
"hawkbit.server.security.cors.allowedOrigins=" + CorsTest.ALLOWED_ORIGIN_FIRST + ","
|
||||
+ CorsTest.ALLOWED_ORIGIN_SECOND,
|
||||
"hawkbit.server.security.cors.exposedHeaders=Access-Control-Allow-Origin" })
|
||||
@TestExecutionListeners(listeners = { MySqlTestDatabase.class,
|
||||
MsSqlTestDatabase.class }, mergeMode = MergeMode.MERGE_WITH_DEFAULTS)
|
||||
@Feature("Integration Test - Security")
|
||||
@Story("CORS")
|
||||
public class CorsTest extends AbstractSecurityTest {
|
||||
@@ -47,16 +48,21 @@ public class CorsTest extends AbstractSecurityTest {
|
||||
@Test
|
||||
@Description("Ensures that Cors is working.")
|
||||
public void validateCorsRequest() throws Exception {
|
||||
performOptionsRequestToRestWithOrigin(ALLOWED_ORIGIN_FIRST).andExpect(status().isOk());
|
||||
performOptionsRequestToRestWithOrigin(ALLOWED_ORIGIN_SECOND).andExpect(status().isOk());
|
||||
performOptionsRequestToRestWithOrigin(ALLOWED_ORIGIN_FIRST).andExpect(status().isOk())
|
||||
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, ALLOWED_ORIGIN_FIRST));
|
||||
performOptionsRequestToRestWithOrigin(ALLOWED_ORIGIN_SECOND).andExpect(status().isOk())
|
||||
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, ALLOWED_ORIGIN_SECOND));
|
||||
|
||||
final String invalidOriginResponseBody = performOptionsRequestToRestWithOrigin(INVALID_ORIGIN)
|
||||
.andExpect(status().isForbidden()).andReturn().getResponse().getContentAsString();
|
||||
.andExpect(status().isForbidden())
|
||||
.andExpect(header().doesNotExist(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN)).andReturn().getResponse()
|
||||
.getContentAsString();
|
||||
assertThat(invalidOriginResponseBody).isEqualTo(INVALID_CORS_REQUEST);
|
||||
|
||||
final String invalidCorsUrlResponseBody = performOptionsRequestToUrlWithOrigin(
|
||||
MgmtRestConstants.BASE_SYSTEM_MAPPING, ALLOWED_ORIGIN_FIRST).andExpect(status().isForbidden())
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
.andExpect(header().doesNotExist(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN)).andReturn()
|
||||
.getResponse().getContentAsString();
|
||||
assertThat(invalidCorsUrlResponseBody).isEqualTo(INVALID_CORS_REQUEST);
|
||||
}
|
||||
|
||||
|
||||
@@ -130,6 +130,11 @@ public class HawkbitSecurityProperties {
|
||||
*/
|
||||
private List<String> allowedMethods = Arrays.asList("DELETE", "GET", "POST", "PATCH", "PUT");
|
||||
|
||||
/**
|
||||
* Exposed headers for CORS.
|
||||
*/
|
||||
private List<String> exposedHeaders = Collections.emptyList();
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
@@ -161,6 +166,14 @@ public class HawkbitSecurityProperties {
|
||||
public void setAllowedMethods(final List<String> allowedMethods) {
|
||||
this.allowedMethods = allowedMethods;
|
||||
}
|
||||
|
||||
public List<String> getExposedHeaders() {
|
||||
return exposedHeaders;
|
||||
}
|
||||
|
||||
public void setExposedHeaders(final List<String> exposedHeaders) {
|
||||
this.exposedHeaders = exposedHeaders;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -448,6 +461,10 @@ public class HawkbitSecurityProperties {
|
||||
return maxDistributionSetTypesPerTargetType;
|
||||
}
|
||||
|
||||
public void setMaxDistributionSetTypesPerTargetType(final int maxDistributionSetTypesPerTargetType) {
|
||||
this.maxDistributionSetTypesPerTargetType = maxDistributionSetTypesPerTargetType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for hawkBits DOS prevention filter. This is usually an
|
||||
* infrastructure topic (e.g. Web Application Firewall (WAF)) but might
|
||||
|
||||
Reference in New Issue
Block a user