diff --git a/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/ws/ThrowableExceptionMapper.java b/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/ws/ThrowableExceptionMapper.java index f1fec4e..47d77d0 100644 --- a/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/ws/ThrowableExceptionMapper.java +++ b/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/ws/ThrowableExceptionMapper.java @@ -18,6 +18,7 @@ import jakarta.ws.rs.core.Response.StatusType; import jakarta.ws.rs.core.UriInfo; import jakarta.ws.rs.ext.ExceptionMapper; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -86,7 +87,18 @@ private Response buildContentNegotiatedErrorResponse(Message message, StatusType if (MediaType.TEXT_HTML_TYPE.equals(mediaType)) { var body = pages.error(message, locale); - return Response.status(status).entity(body).type(MediaType.TEXT_HTML_TYPE).build(); + + // FIXES oviva-ag/ehealthid-relying-party #58 / EPA-102 + // resteasy has a built-in `MessageSanitizerContainerResponseFilter` escaping all non status + // 200 + // 'text/html' responses + // if the entity is a string. + // The corresponding "resteasy.disable.html.sanitizer" config does not work with SeBootstrap + // currently (resteasy 6.2). + return Response.status(status) + .entity(body.getBytes(StandardCharsets.UTF_8)) + .type(MediaType.TEXT_HTML_TYPE) + .build(); } if (MediaType.APPLICATION_JSON_TYPE.equals(mediaType)) { diff --git a/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/MainTest.java b/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/MainTest.java index db198a2..43a467d 100644 --- a/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/MainTest.java +++ b/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/MainTest.java @@ -6,14 +6,9 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import com.github.tomakehurst.wiremock.junit5.WireMockExtension; -import com.oviva.ehealthid.relyingparty.cfg.ConfigProvider; +import com.oviva.ehealthid.relyingparty.test.EmbeddedRelyingParty; import io.restassured.http.ContentType; -import java.io.IOException; -import java.io.StringReader; import java.net.URI; -import java.util.Map; -import java.util.Optional; -import java.util.Properties; import java.util.UUID; import java.util.concurrent.ExecutionException; import java.util.stream.Stream; @@ -36,35 +31,15 @@ class MainTest { private static final String IDP_PATH = "auth/select-idp"; private static final String CALLBACK_PATH = "auth/callback"; + private static EmbeddedRelyingParty application; + @RegisterExtension static WireMockExtension wm = WireMockExtension.newInstance().options(wireMockConfig().dynamicPort()).build(); - private static Main application; - @BeforeAll static void beforeAll() throws ExecutionException, InterruptedException { - - var discoveryUri = URI.create(wm.baseUrl()).resolve(DISCOVERY_PATH); - - var redirectUri = URI.create("https://myapp.example.com"); - - var config = - configFromProperties( - """ - federation_enc_jwks_path=src/test/resources/fixtures/example_enc_jwks.json - federation_sig_jwks_path=src/test/resources/fixtures/example_sig_jwks.json - base_uri=%s - idp_discovery_uri=%s - redirect_uris=%s - app_name=Awesome DiGA - port=0 - """ - .formatted(wm.baseUrl(), discoveryUri, redirectUri)); - - application = new Main(config); - - // when + application = new EmbeddedRelyingParty(); application.start(); } @@ -73,16 +48,6 @@ static void afterAll() throws Exception { application.close(); } - private static ConfigProvider configFromProperties(String s) { - var props = new Properties(); - try { - props.load(new StringReader(s)); - } catch (IOException e) { - throw new RuntimeException(e); - } - return new StaticConfig(props); - } - @Test void run_smokeTest() { @@ -333,12 +298,4 @@ void run_selectIdp() { private void assertGetOk(URI uri) { get(uri).then().statusCode(200); } - - record StaticConfig(Map values) implements ConfigProvider { - - @Override - public Optional get(String name) { - return Optional.ofNullable(values.get(name)).map(Object::toString); - } - } } diff --git a/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/test/EmbeddedRelyingParty.java b/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/test/EmbeddedRelyingParty.java new file mode 100644 index 0000000..1d56d39 --- /dev/null +++ b/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/test/EmbeddedRelyingParty.java @@ -0,0 +1,94 @@ +package com.oviva.ehealthid.relyingparty.test; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.core.WireMockConfiguration; +import com.github.tomakehurst.wiremock.junit.Stubbing; +import com.oviva.ehealthid.relyingparty.Main; +import com.oviva.ehealthid.relyingparty.cfg.ConfigProvider; +import java.io.IOException; +import java.io.StringReader; +import java.net.URI; +import java.util.Map; +import java.util.Optional; +import java.util.Properties; +import java.util.concurrent.ExecutionException; + +public class EmbeddedRelyingParty implements AutoCloseable { + + private static final String DISCOVERY_PATH = "/.well-known/openid-configuration"; + private Main application; + private WireMockServer wireMockServer; + + public URI start() throws ExecutionException, InterruptedException { + + var options = WireMockConfiguration.options().dynamicPort(); + this.wireMockServer = new WireMockServer(options); + wireMockServer.start(); + + var discoveryUri = URI.create(wireMockServer.baseUrl()).resolve(DISCOVERY_PATH); + + var redirectUri = URI.create("https://myapp.example.com"); + + var config = + StaticConfig.fromRawProperties( + """ + federation_enc_jwks_path=src/test/resources/fixtures/example_enc_jwks.json + federation_sig_jwks_path=src/test/resources/fixtures/example_sig_jwks.json + base_uri=%s + idp_discovery_uri=%s + redirect_uris=%s + app_name=Awesome DiGA + port=0 + """ + .formatted(wireMockServer.baseUrl(), discoveryUri, redirectUri)); + + this.application = new Main(config); + + application.start(); + + return application.baseUri(); + } + + public URI baseUri() { + return application.baseUri(); + } + + public Stubbing wireMockStubbing() { + return wireMockServer; + } + + @Override + public void close() throws Exception { + + Exception cause = null; + + try { + this.application.close(); + } catch (Exception e) { + cause = e; + } + + this.wireMockServer.stop(); + if (cause != null) { + throw cause; + } + } + + record StaticConfig(Map values) implements ConfigProvider { + + @Override + public Optional get(String name) { + return Optional.ofNullable(values.get(name)).map(Object::toString); + } + + public static ConfigProvider fromRawProperties(String s) { + var props = new Properties(); + try { + props.load(new StringReader(s)); + } catch (IOException e) { + throw new RuntimeException(e); + } + return new StaticConfig(props); + } + } +} diff --git a/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/ws/ThrowableExceptionMapperComponentTest.java b/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/ws/ThrowableExceptionMapperComponentTest.java new file mode 100644 index 0000000..836947f --- /dev/null +++ b/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/ws/ThrowableExceptionMapperComponentTest.java @@ -0,0 +1,41 @@ +package com.oviva.ehealthid.relyingparty.ws; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.*; +import static org.hamcrest.Matchers.startsWith; + +import com.oviva.ehealthid.relyingparty.test.EmbeddedRelyingParty; +import io.restassured.RestAssured; +import java.util.concurrent.ExecutionException; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +class ThrowableExceptionMapperComponentTest { + + private static EmbeddedRelyingParty app; + + @BeforeAll + static void beforeAll() throws ExecutionException, InterruptedException { + app = new EmbeddedRelyingParty(); + app.start(); + RestAssured.baseURI = app.baseUri().toString(); + } + + @AfterAll + static void afterAll() throws Exception { + app.close(); + } + + /** Regression test for: oviva-ag/ehealthid-relying-party #58 / EPA-102 */ + @Test + void toResponse_htmlUnescaped() { + + given() + .accept("text/html,*/*") + .get("/auth") + .then() + .header("content-type", startsWith("text/html")) + .body(startsWith("")); + } +} diff --git a/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/ws/ThrowableExceptionMapperTest.java b/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/ws/ThrowableExceptionMapperTest.java index cb2a1dd..377ec7c 100644 --- a/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/ws/ThrowableExceptionMapperTest.java +++ b/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/ws/ThrowableExceptionMapperTest.java @@ -1,20 +1,21 @@ package com.oviva.ehealthid.relyingparty.ws; import static com.oviva.ehealthid.relyingparty.svc.ValidationException.*; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; -import com.github.jknack.handlebars.internal.text.StringEscapeUtils; +import com.github.mustachejava.util.HtmlEscaper; import com.oviva.ehealthid.relyingparty.svc.AuthenticationException; import com.oviva.ehealthid.relyingparty.svc.ValidationException; import com.oviva.ehealthid.relyingparty.ws.ThrowableExceptionMapper.Problem; import jakarta.ws.rs.NotFoundException; import jakarta.ws.rs.ServerErrorException; -import jakarta.ws.rs.core.HttpHeaders; -import jakarta.ws.rs.core.MediaType; -import jakarta.ws.rs.core.Request; -import jakarta.ws.rs.core.UriInfo; +import jakarta.ws.rs.core.*; +import java.io.StringWriter; import java.net.URI; +import java.nio.charset.StandardCharsets; import java.util.List; import java.util.stream.Stream; import org.junit.jupiter.api.Test; @@ -230,11 +231,31 @@ void toResponse_withBody_withValidationExceptionAndDynamicContent( // then assertEquals(400, res.getStatus()); - assertTrue(StringEscapeUtils.unescapeHtml4(res.getEntity().toString()).contains(message)); + assertBodyContains(res, message); assertEquals(MediaType.TEXT_HTML_TYPE, res.getMediaType()); assertNotNull(res.getEntity()); } + private static void assertBodyContains(Response res, String message) { + if (!(res.getEntity() instanceof byte[] bytes)) { + fail("unsupported entity type for tests: %s".formatted(res.getEntity().getClass())); + return; + } + + var htmlBody = new String(bytes, StandardCharsets.UTF_8); + + // the rendered response is already HTML escaped, let's do the same to the raw message + var escapedMessage = htmlEscape(message); + + assertThat(htmlBody, containsString(escapedMessage)); + } + + private static String htmlEscape(String s) { + var w = new StringWriter(); + HtmlEscaper.escape(s, w); + return w.toString(); + } + private void mockHeaders(String locales) { doReturn(locales).when(headers).getHeaderString("Accept-Language"); doReturn("00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01")