Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added CustomHttpResponseHeadersFilter #1235

Merged
merged 28 commits into from
Oct 19, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
9dd12b1
Added CustomHttpResponseHeadersFilter
AdiWehrli Oct 18, 2022
351649b
Added test classes for CustomHttpResponseHeadersFilter.
AdiWehrli Oct 18, 2022
70d6ec2
Added applicaton configuration for CustomHttpResponseHeaderFilterTest.
AdiWehrli Oct 18, 2022
a9493dc
Added configuration hint for custom response headers.
AdiWehrli Oct 18, 2022
7855142
Update application.example.yml
AdiWehrli Oct 18, 2022
988e36e
Update application.example.yml
AdiWehrli Oct 18, 2022
160b821
Update akhq.md
AdiWehrli Oct 18, 2022
1e25c78
Adjusted property name.
AdiWehrli Oct 18, 2022
95f99e3
Adjusted property name.
AdiWehrli Oct 18, 2022
35ac8b6
Added Server config.
AdiWehrli Oct 19, 2022
9c9eaa4
Replaced CustomHttpResponseHeadersFilter.
AdiWehrli Oct 19, 2022
b87476d
Replaced CustomHttpResponseHeadersFilter test casses.
AdiWehrli Oct 19, 2022
87f445e
Delete application-custom-response-headers.yml
AdiWehrli Oct 19, 2022
5bf28e1
Added new test application configuration.
AdiWehrli Oct 19, 2022
cf52f34
Adjusted configuration description.
AdiWehrli Oct 19, 2022
a24e8fe
Removed unnecessary comment.
AdiWehrli Oct 19, 2022
91c456e
Moved custom HTTP response headers configuration up.
AdiWehrli Oct 19, 2022
5741ff5
Added Kafka connection.
AdiWehrli Oct 19, 2022
ec04356
Added configuraton with no headers.
AdiWehrli Oct 19, 2022
4fcb230
Added test with no headers.
AdiWehrli Oct 19, 2022
3ca1629
Removed not used import.
AdiWehrli Oct 19, 2022
6040d8c
Created new folder middlewares.
AdiWehrli Oct 19, 2022
7246e77
Moved test classes.
AdiWehrli Oct 19, 2022
ac6f055
Delete dummy
AdiWehrli Oct 19, 2022
4024f4a
Delete CustomHttpResponseHeadersFilterTest.java
AdiWehrli Oct 19, 2022
37b6c8c
Delete CustomHttpResponseHeadersFilterTestPage.java
AdiWehrli Oct 19, 2022
5d2fa97
Delete NoCustomHttpResponseHeadersFilterTest.java
AdiWehrli Oct 19, 2022
3d12c09
final
tchiotludo Oct 19, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions application.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ akhq:
enabled: true # true by default
name: org.akhq.log.access # Logger name
format: "[Date: {}] [Duration: {} ms] [Url: {} {}] [Status: {}] [Ip: {}] [User: {}]" # Logger format
# Custom HTTP response headers configuration
customHttpResponseHeaders:
- name: "Content-Security-Policy"
value: "default-src 'none'; frame-src 'self'; script-src 'self'; connect-src 'self'; img-src 'self'; style-src 'self'; frame-ancestors 'self'; form-action 'self'; upgrade-insecure-requests"
- name: "X-Permitted-Cross-Domain-Policies"
value: "none"

# default kafka properties for each clients, available for admin / producer / consumer (optional)
clients-defaults:
Expand Down Expand Up @@ -289,3 +295,4 @@ akhq:
- username: header-admin
groups:
- admin

13 changes: 13 additions & 0 deletions docs/docs/configuration/akhq.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,16 @@ akhq:
}
</style>
```

## Custom HTTP response headers
To add headers to every response please add the headers like in following example:
```yaml
akhq:
server:
customHttpResponseHeaders:
- name: "Content-Security-Policy"
value: "default-src 'none'; frame-src 'self'; script-src 'self'; connect-src 'self'; img-src 'self'; style-src 'self'; frame-ancestors 'self'; form-action 'self'; upgrade-insecure-requests"
- name: "X-Permitted-Cross-Domain-Policies"
value: "none"
```

38 changes: 38 additions & 0 deletions src/main/java/org/akhq/configs/Server.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package org.akhq.configs;

import io.micronaut.context.annotation.ConfigurationProperties;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
* Configuration for AKHQ server configuration (mainly for {@code akhq.server.custom-http-response-headers}).
*
* This does NOT include settings for {@link org.akhq.middlewares.HttpServerAccessLogFilter} like
* {@code akhq.server.access-log.enabled} or {@code akhq.server.access-log.format} which are directly referenced in that class.
*/
@ConfigurationProperties("akhq.server")
public class Server {

private static final String HEADER_KEY = "name";
private static final String HEADER_VALUE = "value";
private final Map<String,String> customHttpResponseHeaders = new HashMap<>();

public Map<String, String> getCustomHttpResponseHeaders() {
return customHttpResponseHeaders;
}

/**
* Convert the property entries to a {@code Map} as they are read from configuration as an {@link ArrayList}.
*
* @param customHttpResponseHeaders the list of maps from application configuration definied by property
* {@code akhq.server.custom-http-response-headers}.
*/
public void setCustomHttpResponseHeaders(List<Map<String, String>> customHttpResponseHeaders) {
customHttpResponseHeaders.forEach(header ->
this.customHttpResponseHeaders.put(header.get(HEADER_KEY), header.get(HEADER_VALUE))
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package org.akhq.middlewares;


import io.micronaut.context.ApplicationContext;
import io.micronaut.core.async.publisher.Publishers;
import io.micronaut.http.HttpRequest;
import io.micronaut.http.MutableHttpResponse;
import io.micronaut.http.annotation.Filter;
import io.micronaut.http.filter.HttpServerFilter;
import io.micronaut.http.filter.ServerFilterChain;
import org.akhq.configs.Server;
import org.reactivestreams.Publisher;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
* Add or replace custom HTTP response headers and remove some headers that are not allowed according to the
* configuration property {@code akhq.server.custom-http-response-headers}.
*/
@Filter("/**")
public class CustomHttpResponseHeadersFilter implements HttpServerFilter {

private static final Logger LOG = LoggerFactory.getLogger(CustomHttpResponseHeadersFilter.class);
private static final String REMOVE_HEADER_VALUE = "REMOVE_HEADER";

private final Server server;

public CustomHttpResponseHeadersFilter(ApplicationContext context, Server server) {
this.server = server;
LOG.trace("Created instance of " + CustomHttpResponseHeadersFilter.class);
}

@Override
public Publisher<MutableHttpResponse<?>> doFilter(HttpRequest<?> request, ServerFilterChain chain) {
Publisher<MutableHttpResponse<?>> responsePublisher = chain.proceed(request);
LOG.trace("Adding custom headers to response.");
return Publishers.map(responsePublisher, mutableHttpResponse -> {
this.server.getCustomHttpResponseHeaders().entrySet().forEach(responseHeader -> {
if (responseHeader.getValue().equals(REMOVE_HEADER_VALUE)) {
if (mutableHttpResponse.getHeaders().contains(responseHeader.getKey())) {
String existingHeaderValue = mutableHttpResponse.getHeaders().get(responseHeader.getKey());
mutableHttpResponse.getHeaders().remove(responseHeader.getKey());
LOG.trace("Removed header '{}' (value was '{}')", responseHeader.getKey(), existingHeaderValue);
} else {
LOG.trace("Header '{}' to be removed did not exist", responseHeader.getKey());
}
} else {
if (mutableHttpResponse.getHeaders().contains(responseHeader.getKey())) {
String existingHeaderValue = mutableHttpResponse.getHeaders().get(responseHeader.getKey());
mutableHttpResponse.getHeaders().set(responseHeader.getKey(), responseHeader.getValue());
LOG.trace("Replaced existing header '{}' by value {} (value was '{}')", responseHeader.getKey(), responseHeader.getValue(), existingHeaderValue);
} else {
mutableHttpResponse.getHeaders().add(responseHeader.getKey(), responseHeader.getValue());
LOG.trace("Added custom header '{}' with value '{}'", responseHeader.getKey(), responseHeader.getValue());
}
}
});
return mutableHttpResponse;
});

}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package org.akhq.middlewares;

import io.micronaut.context.ApplicationContext;
import io.micronaut.http.HttpResponse;
import io.micronaut.http.HttpStatus;
import io.micronaut.http.client.HttpClient;
import io.micronaut.http.client.exceptions.HttpClientResponseException;
import io.micronaut.runtime.server.EmbeddedServer;
import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
import org.junit.jupiter.api.*;

import static org.junit.jupiter.api.Assertions.*;

@MicronautTest
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class CustomHttpResponseHeadersFilterTest {

private static EmbeddedServer server;
private static HttpClient client;

protected static final String REQUIRED_HEADER_CONTENT_SECURITY_POLICY = "Content-Security-Policy";
protected static final String REQUIRED_HEADER_X_PERMITTED_CROSS_DOMAIN_POLICIES = "X-Permitted-Cross-Domain-Policies";
protected static final String REQUIRED_HEADER_CROSS_ORIGIN_EMBEDDER_POLICY = "Cross-Origin-Embedder-Policy";
protected static final String REQUIRED_HEADER_CROSS_ORIGIN_OPENER_POLICY = "Cross-Origin-Opener-Policy";
protected static final String REQUIRED_HEADER_CROSS_ORIGIN_RESOURCE_POLICY = "Cross-Origin-Resource-Policy";
protected static final String REQUIRED_HEADER_FEATURE_POLICY = "Feature-Policy";
protected static final String REQUIRED_HEADER_PERMISSIONS_POLICY = "Permissions-Policy";
protected static final String REQUIRED_HEADER_CONTENT_SECURITY_POLICY_VALUE = "default-src 'none'; frame-src 'self'; script-src 'self'; connect-src 'self'; img-src 'self'; style-src 'self'; frame-ancestors 'self'; form-action 'self'; upgrade-insecure-requests";
protected static final String REQUIRED_HEADER_X_PERMITTED_CROSS_DOMAIN_POLICIES_VALUE = "none";
protected static final String REQUIRED_HEADER_CROSS_ORIGIN_EMBEDDER_POLICY_VALUE = "require-corp";
protected static final String REQUIRED_HEADER_CROSS_ORIGIN_OPENER_POLICY_VALUE = "same-origin";
protected static final String REQUIRED_HEADER_CROSS_ORIGIN_RESOURCE_POLICY_VALUE = "same-origin";
protected static final String REQUIRED_HEADER_FEATURE_POLICY_VALUE = "microphone 'none'; geolocation 'none'; usb 'none'; payment 'none'; document-domain 'none'; camera 'none'; display-capture 'none'; ambient-light-sensor 'none'";
protected static final String REQUIRED_HEADER_PERMISSIONS_POLICY_VALUE = "microphone=(), geolocation=(), usb=(), payment=(), document-domain=(), camera=(), display-capture=(), ambient-light-sensor=()";
protected static final String FORBIDDEN_HEADER_X_POWERED_BY = "X-Powered-By";
protected static final String FORBIDDEN_HEADER_ACCESS_CONTROL_ALLOW_ORIGIN = "Access-Control-Allow-Origin";
protected static final String FORBIDDEN_HEADER_VIA = "Via";
protected static final String FORBIDDEN_HEADER_SERVER = "Server";

@BeforeAll
public static void setupServer() {
server = ApplicationContext.run(EmbeddedServer.class, "custom-http-response-headers");
client = server.getApplicationContext().createBean(HttpClient.class, server.getURL());
}

@AfterAll
public static void stopServer() {
if (client != null) {
client.stop();
}
if (server != null) {
server.stop();
}
}

/**
* Test the {@link CustomHttpResponseHeadersFilter} for a valid URI. Check the response for existence of the custom headers.
*/
@Test
@Order(1)
void testFilterCheckHeaders() {
HttpResponse<?> response = client.toBlocking().exchange("/issues/66");
assertEquals(HttpStatus.OK.getCode(), response.getStatus().getCode());
assertHeaders(response);
}

/**
* Test the {@link CustomHttpResponseHeadersFilter} for an invalid URI. But the response must nevertheless be added.
*/
@Test
@Order(2)
void testFilterCheckHeadersForInvalidURI() {
HttpClientResponseException exception = assertThrows(HttpClientResponseException.class, () -> client.toBlocking().exchange("/issues/"));
assertEquals(HttpStatus.NOT_FOUND.getCode(), exception.getStatus().getCode());
assertHeaders(exception.getResponse());
}

private static void assertHeaders(HttpResponse<?> response) {

Assertions.assertEquals(REQUIRED_HEADER_CONTENT_SECURITY_POLICY_VALUE, response.getHeaders().get(REQUIRED_HEADER_CONTENT_SECURITY_POLICY),
"Wrong value " + response.getHeaders().get(REQUIRED_HEADER_CONTENT_SECURITY_POLICY) + " for " + REQUIRED_HEADER_CONTENT_SECURITY_POLICY);

Assertions.assertEquals(REQUIRED_HEADER_FEATURE_POLICY_VALUE, response.getHeaders().get(REQUIRED_HEADER_FEATURE_POLICY),
"Wrong value " + response.getHeaders().get(REQUIRED_HEADER_FEATURE_POLICY) + " for " + REQUIRED_HEADER_FEATURE_POLICY);

Assertions.assertEquals(REQUIRED_HEADER_CROSS_ORIGIN_OPENER_POLICY_VALUE, response.getHeaders().get(REQUIRED_HEADER_CROSS_ORIGIN_OPENER_POLICY),
"Wrong value " + response.getHeaders().get(REQUIRED_HEADER_CROSS_ORIGIN_OPENER_POLICY) + " for " + REQUIRED_HEADER_CROSS_ORIGIN_OPENER_POLICY);

Assertions.assertEquals(REQUIRED_HEADER_CROSS_ORIGIN_EMBEDDER_POLICY_VALUE, response.getHeaders().get(REQUIRED_HEADER_CROSS_ORIGIN_EMBEDDER_POLICY),
"Wrong value " + response.getHeaders().get(REQUIRED_HEADER_CROSS_ORIGIN_EMBEDDER_POLICY) + " for " + REQUIRED_HEADER_CROSS_ORIGIN_EMBEDDER_POLICY);

Assertions.assertEquals(REQUIRED_HEADER_CROSS_ORIGIN_RESOURCE_POLICY_VALUE, response.getHeaders().get(REQUIRED_HEADER_CROSS_ORIGIN_RESOURCE_POLICY),
"Wrong value " + response.getHeaders().get(REQUIRED_HEADER_CROSS_ORIGIN_RESOURCE_POLICY) + " for " + REQUIRED_HEADER_CROSS_ORIGIN_RESOURCE_POLICY);

Assertions.assertEquals(REQUIRED_HEADER_PERMISSIONS_POLICY_VALUE, response.getHeaders().get(REQUIRED_HEADER_PERMISSIONS_POLICY),
"Wrong value " + response.getHeaders().get(REQUIRED_HEADER_PERMISSIONS_POLICY) + " for " + REQUIRED_HEADER_PERMISSIONS_POLICY);

Assertions.assertEquals(REQUIRED_HEADER_X_PERMITTED_CROSS_DOMAIN_POLICIES_VALUE, response.getHeaders().get(REQUIRED_HEADER_X_PERMITTED_CROSS_DOMAIN_POLICIES),
"Wrong value " + response.getHeaders().get(REQUIRED_HEADER_X_PERMITTED_CROSS_DOMAIN_POLICIES) + " for " + REQUIRED_HEADER_X_PERMITTED_CROSS_DOMAIN_POLICIES);

assertFalse(response.getHeaders().contains(FORBIDDEN_HEADER_SERVER), FORBIDDEN_HEADER_SERVER + " erroneously exists as header");
assertFalse(response.getHeaders().contains(FORBIDDEN_HEADER_VIA), FORBIDDEN_HEADER_VIA + " erroneously exists as header");
assertFalse(response.getHeaders().contains(FORBIDDEN_HEADER_X_POWERED_BY), FORBIDDEN_HEADER_X_POWERED_BY + " erroneously exists as header");
assertFalse(response.getHeaders().contains(FORBIDDEN_HEADER_ACCESS_CONTROL_ALLOW_ORIGIN), FORBIDDEN_HEADER_ACCESS_CONTROL_ALLOW_ORIGIN + " erroneously exists as header");
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package org.akhq.middlewares;

import io.micronaut.http.HttpResponse;
import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.Get;
import io.micronaut.http.annotation.PathVariable;

@Controller("/issues")
public class CustomHttpResponseHeadersFilterTestPage {

@Get("/{number}")
public HttpResponse<String> issue(@PathVariable Integer number) {
return HttpResponse.ok("Issue # " + number + "!")
.header("Cross-Origin-Opener-Policy", "test if being replaced")
.header("Via", "test if being removed");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package org.akhq.middlewares;

import io.micronaut.context.ApplicationContext;
import io.micronaut.http.HttpResponse;
import io.micronaut.http.HttpStatus;
import io.micronaut.http.client.HttpClient;
import io.micronaut.runtime.server.EmbeddedServer;
import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
import org.junit.jupiter.api.*;

import static org.junit.jupiter.api.Assertions.*;

@MicronautTest
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class NoCustomHttpResponseHeadersFilterTest {

private static EmbeddedServer server;
private static HttpClient client;

@BeforeAll
public static void setupServer() {
server = ApplicationContext.run(EmbeddedServer.class, "no-custom-http-response-headers");
client = server.getApplicationContext().createBean(HttpClient.class, server.getURL());
}

@AfterAll
public static void stopServer() {
if (client != null) {
client.stop();
}
if (server != null) {
server.stop();
}
}

/**
* Test the {@link CustomHttpResponseHeadersFilter} for a valid URI. Check the response for existence of the custom headers.
*/
@Test
@Order(1)
void testFilterCheckNoHeaders() {
HttpResponse<?> response = client.toBlocking().exchange("/issues/66");
assertEquals(HttpStatus.OK.getCode(), response.getStatus().getCode());
}
}

36 changes: 36 additions & 0 deletions src/test/resources/application-custom-http-response-headers.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
micronaut:
application:
name: akhq
security:
enabled: false
server:
port: -1
akhq:
connections:
dummy-kafka-cluster:
properties:
bootstrap.servers: "localhost:9121"
server:
customHttpResponseHeaders:
- name: "Content-Security-Policy"
value: "default-src 'none'; frame-src 'self'; script-src 'self'; connect-src 'self'; img-src 'self'; style-src 'self'; frame-ancestors 'self'; form-action 'self'; upgrade-insecure-requests"
- name: "X-Permitted-Cross-Domain-Policies"
value: "none"
- name: "Cross-Origin-Embedder-Policy"
value: "require-corp"
- name: "Cross-Origin-Opener-Policy"
value: "same-origin"
- name: "Cross-Origin-Resource-Policy"
value: "same-origin"
- name: "Feature-Policy"
value: "microphone 'none'; geolocation 'none'; usb 'none'; payment 'none'; document-domain 'none'; camera 'none'; display-capture 'none'; ambient-light-sensor 'none'"
- name: "Permissions-Policy"
value: "microphone=(), geolocation=(), usb=(), payment=(), document-domain=(), camera=(), display-capture=(), ambient-light-sensor=()"
- name: "X-Powered-By"
value: "REMOVE_HEADER"
- name: "Access-Control-Allow-Origin"
value: "REMOVE_HEADER"
- name: "Via"
value: "REMOVE_HEADER"
- name: "Server"
value: "REMOVE_HEADER"
12 changes: 12 additions & 0 deletions src/test/resources/application-no-custom-http-response-headers.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
micronaut:
application:
name: akhq
security:
enabled: false
server:
port: -1
akhq:
connections:
dummy-kafka-cluster:
properties:
bootstrap.servers: "localhost:9121"