Skip to content

Commit

Permalink
Issue helidon-io#7102 - WebClient should have a mode that is resilien…
Browse files Browse the repository at this point in the history
…t to bad media/content types (helidon-io#9040)
  • Loading branch information
Tomas-Kraus authored Jul 30, 2024
1 parent fb7f95c commit 52152f4
Show file tree
Hide file tree
Showing 7 changed files with 260 additions and 12 deletions.
40 changes: 38 additions & 2 deletions common/http/src/main/java/io/helidon/common/http/MediaType.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2018, 2022 Oracle and/or its affiliates.
* Copyright (c) 2018, 2024 Oracle and/or its affiliates.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -268,6 +268,12 @@ public final class MediaType implements AcceptPredicate<MediaType> {
*/
private static final CharMatcher LINEAR_WHITE_SPACE = CharMatcher.anyOf(" \t\r\n");
private static final String CHARSET_ATTRIBUTE = "charset";

// Relaxed media types mapping
private static final Map<String, MediaType> RELAXED_TYPES = Map.of(
"text", MediaType.TEXT_PLAIN // text -> text/plain
);

private final String type;
private final String subtype;
private final Map<String, String> parameters;
Expand Down Expand Up @@ -319,13 +325,43 @@ public static MediaType create(String type, String subtype) {
* @throws NullPointerException if the input is {@code null}
*/
public static MediaType parse(String input) {
return parse(input, true);
}

/**
* Parses a media type from its string representation in relaxed mode.
* Predefined incomplete media types are replaced with corresponding valid media types.
*
* @param input the input string representing a media type
* @return parsed {@link MediaType} instance
* @throws IllegalArgumentException if the input is not parsable
* @throws NullPointerException if the input is {@code null}
*/
public static MediaType parseRelaxed(String input) {
return parse(input, false);
}

/**
* Parses a media type from its string representation.
*
* @param input the input string representing a media type
* @param strictMode whether strict mode (default) shall be used
* @return parsed {@link MediaType} instance
* @throws IllegalArgumentException if the input is not parsable
* @throws NullPointerException if the input is {@code null}
*/
private static MediaType parse(String input, boolean strictMode) {
Objects.requireNonNull(input, "Parameter 'input' is null!");
Tokenizer tokenizer = new Tokenizer(input);
try {
String type = tokenizer.consumeToken(TOKEN_MATCHER);
Map<String, String> parameters = new HashMap<>();
// Handle relaxed media type parsing when '/' character is not present
if (!strictMode && !tokenizer.hasMore() && RELAXED_TYPES.containsKey(type)) {
return RELAXED_TYPES.get(type);
}
tokenizer.consumeCharacter('/');
String subtype = tokenizer.consumeToken(TOKEN_MATCHER);
Map<String, String> parameters = new HashMap<>();
while (tokenizer.hasMore()) {
tokenizer.consumeTokenIfPresent(LINEAR_WHITE_SPACE);
tokenizer.consumeCharacter(';');
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
/*
* Copyright (c) 2024 Oracle and/or its affiliates.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.helidon.tests.integration.webclient;

import java.util.Optional;
import java.util.concurrent.CompletionException;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;

import io.helidon.common.http.Http;
import io.helidon.common.http.MediaType;
import io.helidon.webclient.WebClient;
import io.helidon.webclient.WebClientResponse;
import io.helidon.webserver.Routing;
import io.helidon.webserver.ServerRequest;
import io.helidon.webserver.ServerResponse;
import io.helidon.webserver.WebServer;

import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;

import static org.hamcrest.CoreMatchers.containsString;
import static org.hamcrest.CoreMatchers.instanceOf;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.CoreMatchers.notNullValue;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.junit.jupiter.api.Assertions.fail;

public class HeadersTest {

private static final String INVALID_CONTENT_TYPE_VALUE = "invalid header value";
private static final String INVALID_CONTENT_TYPE_TEXT = "text";
private static final String RELAXED_CONTENT_TYPE_TEXT = "text/plain";

private static WebServer server;
private static WebClient client;

@BeforeAll
static void beforeAll() throws ExecutionException, InterruptedException, TimeoutException {
server = WebServer.builder(
Routing.builder()
.get("/invalidContentType", HeadersTest::invalidContentType)
.get("/invalidTextContentType", HeadersTest::invalidTextContentType)
.build()
)
.build();
server.start()
.toCompletableFuture()
.get(10, TimeUnit.SECONDS);

client = WebClient.builder()
.baseUri("http://localhost:" + server.port())
.validateHeaders(false)
.keepAlive(true)
.build();
}

// HTTP service with invalid Content-Type
private static void invalidContentType(ServerRequest request, ServerResponse response) {
response.addHeader(Http.Header.CONTENT_TYPE, INVALID_CONTENT_TYPE_VALUE)
.send();
}

// HTTP service with Content-Type: text instead of text/plain
private static void invalidTextContentType(ServerRequest request, ServerResponse response) {
response.addHeader(Http.Header.CONTENT_TYPE, INVALID_CONTENT_TYPE_TEXT)
.send();
}

@AfterAll
static void afterAll() throws ExecutionException, InterruptedException, TimeoutException {
if (server != null) {
server.shutdown()
.toCompletableFuture()
.get(10, TimeUnit.SECONDS);
}
}

// Verify that invalid content type causes an exception when being parsed
@Test
void testInvalidContentType() {
try {
client.get()
.path("/invalidContentType")
.request()
.await(10, TimeUnit.SECONDS);
fail("WebClient shall throw an exception");
} catch (Exception ex) {
assertThat(ex, is(instanceOf(CompletionException.class)));
Throwable cause = ex.getCause();
assertThat(cause, is(notNullValue()));
assertThat(cause, is(instanceOf(IllegalArgumentException.class)));
assertThat(cause.getMessage(), containsString(INVALID_CONTENT_TYPE_VALUE));
}
}

// Verify that "text" content type causes an exception when being parsed in strict mode
@Test
void testInvalidTextContentTypeStrict() {
try {
client.get()
.path("/invalidTextContentType")
.request()
.await(10, TimeUnit.SECONDS);
} catch (Exception ex) {
assertThat(ex, is(instanceOf(CompletionException.class)));
Throwable cause = ex.getCause();
assertThat(cause, is(notNullValue()));
assertThat(cause, is(instanceOf(IllegalArgumentException.class)));
assertThat(cause.getMessage(), containsString(INVALID_CONTENT_TYPE_TEXT));
}
}

// Verify that "text" content type is transformed to "text/plain" in relaxed mode
@Test
void testInvalidTextContentTypeRelaxed() {
WebClient client = WebClient.builder()
.baseUri("http://localhost:" + server.port())
.validateHeaders(false)
.keepAlive(true)
.mediaTypeParserRelaxed(true)
.build();
WebClientResponse response = client.get()
.path("/invalidTextContentType")
.request()
.await(10, TimeUnit.SECONDS);
Optional<MediaType> maybeContentType = response.headers().contentType();
assertThat(maybeContentType.isPresent(), is(true));
assertThat(maybeContentType.get().toString(), is(RELAXED_CONTENT_TYPE_TEXT));
}

}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2020, 2023 Oracle and/or its affiliates.
* Copyright (c) 2020, 2024 Oracle and/or its affiliates.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -123,7 +123,8 @@ protected void channelRead0(ChannelHandlerContext ctx, HttpObject msg) throws IO
.status(helidonStatus(response.status()))
.httpVersion(Http.Version.create(response.protocolVersion().toString()))
.responseCloser(responseCloser)
.lastEndpointURI(requestConfiguration.requestURI());
.lastEndpointURI(requestConfiguration.requestURI())
.mediaTypeParserRelaxed(requestConfiguration.mediaTypeParserRelaxed());

HttpHeaders nettyHeaders = response.headers();
for (String name : nettyHeaders.names()) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2020, 2022 Oracle and/or its affiliates.
* Copyright (c) 2020, 2024 Oracle and/or its affiliates.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -440,6 +440,21 @@ public Builder enableAutomaticCookieStore(boolean enableAutomaticCookieStore) {
return this;
}

/**
* Configure media type parsing mode for HTTP {@code Content-Type} header.
* Media type parsing in relaxed mode accepts incomplete Content-Type values like
* {@code "text"}, which are accepted as corresponding complete value, e.g. {@code "text/plain"}.
* Parsing in strict mode (default) expect exact {@code Content-Type} matching.
*
* @param relaxedMode value of {@code true} sets relaxed media type parsing mode,
* value of {@code false} sets strict media type parsing mode
* @return updated builder instance
*/
public Builder mediaTypeParserRelaxed(boolean relaxedMode) {
configuration.mediaTypeParserRelaxed(relaxedMode);
return this;
}

WebClientConfiguration configuration() {
configuration.clientServices(services());
return configuration.build();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2019, 2023 Oracle and/or its affiliates.
* Copyright (c) 2019, 2024 Oracle and/or its affiliates.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -84,6 +84,7 @@ class WebClientConfiguration {
private final boolean validateHeaders;
private final boolean relativeUris;
private final DnsResolverType dnsResolverType;
private final boolean mediaTypeParserRelaxed;

/**
* Creates a new instance of client configuration.
Expand Down Expand Up @@ -115,6 +116,7 @@ class WebClientConfiguration {
this.validateHeaders = builder.validateHeaders;
this.relativeUris = builder.relativeUris;
this.dnsResolverType = builder.dnsResolverType;
this.mediaTypeParserRelaxed = builder.mediaTypeParserRelaxed;
}

/**
Expand Down Expand Up @@ -291,6 +293,10 @@ DnsResolverType dnsResolverType() {
return dnsResolverType;
}

boolean mediaTypeParserRelaxed() {
return mediaTypeParserRelaxed;
}

/**
* A fluent API builder for {@link WebClientConfiguration}.
*/
Expand Down Expand Up @@ -323,6 +329,7 @@ static class Builder<B extends Builder<B, T>, T extends WebClientConfiguration>
private boolean validateHeaders;
private boolean relativeUris;
private DnsResolverType dnsResolverType;
private boolean mediaTypeParserRelaxed;
@SuppressWarnings("unchecked")
private B me = (B) this;

Expand Down Expand Up @@ -635,6 +642,11 @@ B clientServices(List<WebClientService> clientServices) {
return me;
}

B mediaTypeParserRelaxed(boolean relaxedMode) {
this.mediaTypeParserRelaxed = relaxedMode;
return me;
}

/**
* Enable keep alive option on the connection.
*
Expand Down Expand Up @@ -699,6 +711,10 @@ public B keepAlive(boolean keepAlive) {
* <td>proxy</td>
* <td>Proxy configuration. See {@link Proxy.Builder#config(Config)}</td>
* </tr>
* <tr>
* <td>media-type-parser-relaxed</td>
* <td>Whether relaxed media type parsing mode should be used.</td>
* </tr>
* </table>
*
* @param config config
Expand Down Expand Up @@ -729,6 +745,7 @@ public B config(Config config) {
config.get("dns-resolver-type").asString()
.map(s -> DnsResolverType.valueOf(s.toUpperCase()))
.ifPresent(this::dnsResolverType);
config.get("media-type-parser-relaxed").asBoolean().ifPresent(this::mediaTypeParserRelaxed);
return me;
}

Expand Down Expand Up @@ -757,6 +774,7 @@ public B update(WebClientConfiguration configuration) {
keepAlive(configuration.keepAlive);
validateHeaders(configuration.validateHeaders);
dnsResolverType(configuration.dnsResolverType);
mediaTypeParserRelaxed(configuration.mediaTypeParserRelaxed);
configuration.cookieManager.defaultCookies().forEach(this::defaultCookie);
config = configuration.config;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2020, 2022 Oracle and/or its affiliates.
* Copyright (c) 2020, 2024 Oracle and/or its affiliates.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -32,8 +32,22 @@
*/
class WebClientResponseHeadersImpl extends ReadOnlyHeaders implements WebClientResponseHeaders {

private WebClientResponseHeadersImpl(Map<String, List<String>> headers) {
private final boolean mediaTypeParserRelaxed;

private WebClientResponseHeadersImpl(Map<String, List<String>> headers, boolean mediaTypeParserRelaxed) {
super(headers);
this.mediaTypeParserRelaxed = mediaTypeParserRelaxed;
}

/**
* Creates {@link WebClientResponseHeaders} instance which contains data from {@link Map}.
*
* @param headers response headers in map
* @param mediaTypeParserRelaxed whether relaxed media type parsing mode should be used
* @return response headers instance
*/
protected static WebClientResponseHeadersImpl create(Map<String, List<String>> headers, boolean mediaTypeParserRelaxed) {
return new WebClientResponseHeadersImpl(headers, mediaTypeParserRelaxed);
}

/**
Expand All @@ -43,7 +57,7 @@ private WebClientResponseHeadersImpl(Map<String, List<String>> headers) {
* @return response headers instance
*/
protected static WebClientResponseHeadersImpl create(Map<String, List<String>> headers) {
return new WebClientResponseHeadersImpl(headers);
return new WebClientResponseHeadersImpl(headers, false);
}

@Override
Expand Down Expand Up @@ -76,7 +90,10 @@ public Optional<ZonedDateTime> date() {

@Override
public Optional<MediaType> contentType() {
return first(Http.Header.CONTENT_TYPE).map(MediaType::parse);
return first(Http.Header.CONTENT_TYPE)
.map(mediaTypeParserRelaxed
? MediaType::parseRelaxed
: MediaType::parse);
}

@Override
Expand Down
Loading

0 comments on commit 52152f4

Please sign in to comment.