Skip to content

Commit

Permalink
Issue helidon-io#6774 - 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

Signed-off-by: Tomáš Kraus <tomas.kraus@oracle.com>
  • Loading branch information
Tomas-Kraus committed Jun 14, 2023
1 parent a4aef4f commit 3c0b356
Show file tree
Hide file tree
Showing 26 changed files with 444 additions and 37 deletions.
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2022 Oracle and/or its affiliates.
* Copyright (c) 2023 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 All @@ -17,15 +17,20 @@
package io.helidon.common.http;

import java.nio.charset.StandardCharsets;
import java.util.Optional;

import io.helidon.common.buffers.Bytes;
import io.helidon.common.buffers.DataReader;
import io.helidon.common.buffers.LazyString;
import io.helidon.common.media.type.ParserMode;

/**
* Used by both HTTP server and client to parse headers from {@link io.helidon.common.buffers.DataReader}.
*/
public final class Http1HeadersParser {

private static final System.Logger LOGGER = System.getLogger(Http1HeadersParser.class.getName());

// TODO expand set of fastpath headers
private static final byte[] HD_HOST = (HeaderEnum.HOST.defaultCase() + ":").getBytes(StandardCharsets.UTF_8);
private static final byte[] HD_ACCEPT = (HeaderEnum.ACCEPT.defaultCase() + ":").getBytes(StandardCharsets.UTF_8);
Expand All @@ -43,10 +48,14 @@ private Http1HeadersParser() {
*
* @param reader reader to pull data from
* @param maxHeadersSize maximal size of all headers, in bytes
* @param parserMode media type parsing mode
* @param validate whether to validate headers
* @return a new mutable headers instance containing all headers parsed from reader
*/
public static WritableHeaders<?> readHeaders(DataReader reader, int maxHeadersSize, boolean validate) {
public static WritableHeaders<?> readHeaders(DataReader reader,
int maxHeadersSize,
ParserMode parserMode,
boolean validate) {
WritableHeaders<?> headers = WritableHeaders.create();
int maxLength = maxHeadersSize;

Expand All @@ -58,6 +67,10 @@ public static WritableHeaders<?> readHeaders(DataReader reader, int maxHeadersSi

Http.HeaderName header = readHeaderName(reader, maxLength, validate);
maxLength -= header.defaultCase().length() + 2;
// Skip spaces after header name
while (' ' == reader.lookup()) {
reader.skip(1);
}
int eol = reader.findNewLine(maxLength);
if (eol == maxLength) {
throw new IllegalStateException("Header size exceeded");
Expand All @@ -67,7 +80,22 @@ public static WritableHeaders<?> readHeaders(DataReader reader, int maxHeadersSi
reader.skip(2);
maxLength -= eol + 1;

headers.add(Http.Header.create(header, value));
if (parserMode == ParserMode.RELAXED && header == HeaderEnum.CONTENT_TYPE) {
String valueString = value.toString();
Optional<String> maybeRelaxedMediaType = ParserMode.findRelaxedMediaType(valueString);
if (maybeRelaxedMediaType.isPresent()) {
headers.add(Http.Header.create(header, maybeRelaxedMediaType.get()));
LOGGER.log(System.Logger.Level.WARNING,
() -> String.format("Invalid %s header value \"%s\" replaced with \"%s\"",
HeaderEnum.CONTENT_TYPE.defaultCase(),
valueString,
maybeRelaxedMediaType.get()));
} else {
headers.add(Http.Header.create(header, valueString));
}
} else {
headers.add(Http.Header.create(header, value));
}
if (maxLength < 0) {
throw new IllegalStateException("Header size exceeded");
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2022, 2023 Oracle and/or its affiliates.
* Copyright (c) 2023, 2023 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 All @@ -24,6 +24,7 @@

import io.helidon.common.media.type.MediaType;
import io.helidon.common.media.type.MediaTypes;
import io.helidon.common.media.type.ParserMode;

/**
* Media type used in HTTP headers, in addition to the media type definition, these may contain additional
Expand Down Expand Up @@ -106,12 +107,24 @@ static HttpMediaType create(MediaType mediaType) {

/**
* Parse media type from the provided string.
* Strict media type parsing mode is used.
*
* @param mediaTypeString media type string
* @return HTTP media type parsed from the string
*/
static HttpMediaType create(String mediaTypeString) {
return Builder.parse(mediaTypeString);
return Builder.parse(mediaTypeString, ParserMode.STRICT);
}

/**
* Parse media type from the provided string.
*
* @param mediaTypeString media type string
* @param parserMode media type parsing mode
* @return HTTP media type parsed from the string
*/
static HttpMediaType create(String mediaTypeString, ParserMode parserMode) {
return Builder.parse(mediaTypeString, parserMode);
}

/**
Expand Down Expand Up @@ -310,7 +323,7 @@ MediaType mediaType() {
return mediaType;
}

private static HttpMediaType parse(String mediaTypeString) {
private static HttpMediaType parse(String mediaTypeString, ParserMode parserMode) {
// text/plain; charset=UTF-8

Builder b = builder();
Expand All @@ -337,7 +350,7 @@ private static HttpMediaType parse(String mediaTypeString) {
}
}
} else {
b.mediaType(MediaTypes.create(mediaTypeString));
b.mediaType(MediaTypes.create(mediaTypeString, parserMode));
}
return b.build();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2022 Oracle and/or its affiliates.
* Copyright (c) 2023 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 All @@ -19,6 +19,7 @@
import java.nio.charset.StandardCharsets;

import io.helidon.common.buffers.DataReader;
import io.helidon.common.media.type.ParserMode;

import org.junit.jupiter.api.Test;

Expand All @@ -33,7 +34,7 @@ void testHeadersAreCaseInsensitive() {
"Set-Cookie: c1=v1\r\nSet-Cookie: c2=v2\r\n"
+ "Header: hv1\r\nheader: hv2\r\nheaDer: hv3\r\n"
+ "\r\n").getBytes(StandardCharsets.US_ASCII));
WritableHeaders<?> headers = Http1HeadersParser.readHeaders(reader, 1024, true);
WritableHeaders<?> headers = Http1HeadersParser.readHeaders(reader, 1024, ParserMode.STRICT, true);

testHeader(headers, "Set-Cookie", "c1=v1", "c2=v2");
testHeader(headers, "set-cookie", "c1=v1", "c2=v2");
Expand Down
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, 2023 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 All @@ -21,6 +21,7 @@

import io.helidon.common.media.type.MediaType;
import io.helidon.common.media.type.MediaTypes;
import io.helidon.common.media.type.ParserMode;

import org.junit.jupiter.api.Test;

Expand All @@ -30,6 +31,7 @@
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.collection.IsIterableWithSize.iterableWithSize;
import static org.hamcrest.number.IsCloseTo.closeTo;
import static org.junit.jupiter.api.Assertions.assertThrows;

/**
* Unit test for {@link MediaType}.
Expand Down Expand Up @@ -134,4 +136,20 @@ void testBuilt() {
assertThat(mediaType.parameters(), is(Map.of("q", "0.1", "charset", "ISO-8859-2")));
assertThat(mediaType.qualityFactor(), closeTo(0.1, 0.000001));
}

// Calling create method with "text" argument shall throw IllegalArgumentException in strict mode.
@Test
void parseInvalidTextInStrictMode() {
assertThrows(IllegalArgumentException.class, () -> {
HttpMediaType.create("text");
});
}

// Calling create method with "text" argument shall return "text/plain" in relaxed mode.
@Test
void parseInvalidTextInRelaxedMode() {
HttpMediaType type = HttpMediaType.create("text", ParserMode.RELAXED);
assertThat(type.text(), is("text/plain"));
}

}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2022 Oracle and/or its affiliates.
* Copyright (c) 2023 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 All @@ -16,10 +16,30 @@

package io.helidon.common.media.type;

import java.util.Optional;

record MediaTypeImpl(String type, String subtype, String text) implements MediaType {
static MediaType parse(String fullType) {

private static final System.Logger LOGGER = System.getLogger(MediaTypeImpl.class.getName());

static MediaType parse(String fullType, ParserMode parserMode) {
int slashIndex = fullType.indexOf('/');
if (slashIndex < 1) {
if (parserMode == ParserMode.RELAXED) {
Optional<String> maybeRelaxedType = ParserMode.findRelaxedMediaType(fullType);
if (maybeRelaxedType.isPresent()) {
String relaxedType = maybeRelaxedType.get();
slashIndex = relaxedType.indexOf('/');
LOGGER.log(System.Logger.Level.WARNING,
() -> String.format("Invalid media type value \"%s\" replaced with \"%s\"",
fullType,
relaxedType));

return new MediaTypeImpl(relaxedType.substring(0, slashIndex),
relaxedType.substring(slashIndex + 1),
relaxedType);
}
}
throw new IllegalArgumentException("Cannot parse media type: " + fullType);
}
return new MediaTypeImpl(fullType.substring(0, slashIndex),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2019, 2022 Oracle and/or its affiliates.
* Copyright (c) 2019, 2023 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 @@ -151,13 +151,26 @@ public static MediaType create(String type, String subtype) {

/**
* Create a new media type from the full media type string.
* Strict media type parsing mode is used.
*
* @param fullType media type string, such as {@code application/json}
* @return media type for the string
*/
public static MediaType create(String fullType) {
MediaTypeEnum types = MediaTypeEnum.find(fullType);
return types == null ? MediaTypeImpl.parse(fullType) : types;
return types == null ? MediaTypeImpl.parse(fullType, ParserMode.STRICT) : types;
}

/**
* Create a new media type from the full media type string.
*
* @param fullType media type string, such as {@code application/json}
* @param parserMode media type parsing mode
* @return media type for the string
*/
public static MediaType create(String fullType, ParserMode parserMode) {
MediaTypeEnum types = MediaTypeEnum.find(fullType);
return types == null ? MediaTypeImpl.parse(fullType, parserMode) : types;
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/*
* Copyright (c) 2023 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.common.media.type;

import java.util.Map;
import java.util.Objects;
import java.util.Optional;

/**
* Media type parsing mode.
*/
public enum ParserMode {

/**
* Strict mode (default).
* Media type must match known name.
*/
STRICT,
/**
* Relaxed mode.
* Apply additional rules to identify unknown media types.
*/
RELAXED;

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

// Lower-case value names mapping
private static final Map<String, ParserMode> VALUE_OF_MAPPING = Map.of(
STRICT.name().toLowerCase(), STRICT,
RELAXED.name().toLowerCase(), RELAXED
);

/**
* Find relaxed media type mapping for provided value.
*
* @param value source media type value
* @return mapped media type value or {@code Optional.empty()}
* when no mapping for given value exists
*/
public static Optional<String> findRelaxedMediaType(String value) {
Objects.requireNonNull(value);
String relaxedValue = RELAXED_TYPES.get(value);
return (relaxedValue != null) ? Optional.of(relaxedValue) : Optional.empty();
}

// Config value resolving helper
/**
* Resolve {@link String} values to {@link ParserMode} instances.
* Matching is case-insensitive, source names are converted to lower-case.
*
* @param name ParserMode instance name
* @param defaultMode ParserMode instance to use when provided name
* does not match any known name.
* @return matching {@link ParserMode} instance or {@code defaultMode}
* when provided name does not match any known mode name.
*/
public static ParserMode valueOfIgnoreCase(String name, ParserMode defaultMode) {
Objects.requireNonNull(name);
return VALUE_OF_MAPPING.getOrDefault(name, defaultMode);
}

}
Loading

0 comments on commit 3c0b356

Please sign in to comment.