Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
12 changes: 12 additions & 0 deletions core/src/main/java/google/registry/request/HttpException.java
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,18 @@ public String getResponseCodeString() {
}
}

/** Exception that causes a 413 response. */
public static final class PayloadTooLargeException extends HttpException {
public PayloadTooLargeException(String message) {
super(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE, message, null);
}

@Override
public String getResponseCodeString() {
return "Payload Too Large";
}
}

/** Exception that causes a 404 response. */
public static final class NotFoundException extends HttpException {
public NotFoundException() {
Expand Down
51 changes: 33 additions & 18 deletions core/src/main/java/google/registry/request/RequestModule.java
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@
import com.google.common.collect.ImmutableListMultimap;
import com.google.common.collect.ImmutableSet;
import com.google.common.io.ByteStreams;
import com.google.common.io.CharStreams;
import com.google.common.net.MediaType;
import com.google.gson.Gson;
import com.google.gson.JsonElement;
Expand All @@ -42,6 +41,7 @@
import dagger.Module;
import dagger.Provides;
import google.registry.request.HttpException.BadRequestException;
import google.registry.request.HttpException.PayloadTooLargeException;
import google.registry.request.HttpException.UnsupportedMediaTypeException;
import google.registry.request.auth.AuthResult;
import google.registry.request.lock.LockHandler;
Expand All @@ -50,13 +50,16 @@
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import java.io.IOException;
import java.nio.charset.Charset;
import java.util.Map;
import java.util.Optional;

/** Dagger module for servlets. */
@Module
public final class RequestModule {

public static final int MAX_PAYLOAD_BYTES = 50 * 1024 * 1024;

private final HttpServletRequest req;
private final HttpServletResponse rsp;
private final AuthResult authResult;
Expand Down Expand Up @@ -167,32 +170,46 @@ static MediaType provideContentType(HttpServletRequest req) {

@Provides
@Payload
static String providePayloadAsString(HttpServletRequest req) {
public static String providePayloadAsString(
@Payload byte[] payloadBytes, HttpServletRequest req) {
String charsetName = req.getCharacterEncoding();
Charset charset;
try {
return CharStreams.toString(req.getReader());
} catch (IOException e) {
throw new RuntimeException(e);
charset = (charsetName != null) ? Charset.forName(charsetName) : UTF_8;
} catch (IllegalArgumentException e) {
throw new UnsupportedMediaTypeException("Unsupported charset: " + charsetName, e);
}
return new String(payloadBytes, charset);
}

@Provides
@Payload
static byte[] providePayloadAsBytes(HttpServletRequest req) {
public static byte[] providePayloadAsBytes(HttpServletRequest req) {
try {
return ByteStreams.toByteArray(req.getInputStream());
if (req.getContentLengthLong() > MAX_PAYLOAD_BYTES) {
throw new PayloadTooLargeException(
String.format(
"Payload size %d exceeds limit of %d bytes",
req.getContentLengthLong(), MAX_PAYLOAD_BYTES));
}
if (req.getInputStream() == null) {
return new byte[0];
}
byte[] bytes =
ByteStreams.toByteArray(ByteStreams.limit(req.getInputStream(), MAX_PAYLOAD_BYTES + 1));
if (bytes.length > MAX_PAYLOAD_BYTES) {
throw new PayloadTooLargeException("Payload exceeds maximum allowed size");
}
return bytes;
} catch (IOException e) {
throw new RuntimeException(e);
}
}

@Provides
@Payload
static ByteString providePayloadAsByteString(HttpServletRequest req) {
try {
return ByteString.copyFrom(ByteStreams.toByteArray(req.getInputStream()));
} catch (IOException e) {
throw new RuntimeException(e);
}
public static ByteString providePayloadAsByteString(@Payload byte[] payloadBytes) {
return ByteString.copyFrom(payloadBytes);
}

@Provides
Expand Down Expand Up @@ -251,12 +268,10 @@ static int provideCloudTasksRetryCount(HttpServletRequest req) {

@Provides
@OptionalJsonPayload
public static Optional<JsonElement> provideJsonBody(HttpServletRequest req, Gson gson) {
try {
// GET requests return a null reader and thus a null JsonObject, which is fine
return Optional.ofNullable(gson.fromJson(req.getReader(), JsonElement.class));
} catch (IOException e) {
public static Optional<JsonElement> provideJsonBody(@Payload String payloadString, Gson gson) {
if (payloadString.isEmpty()) {
return Optional.empty();
}
return Optional.ofNullable(gson.fromJson(payloadString, JsonElement.class));
}
}
22 changes: 18 additions & 4 deletions core/src/main/java/google/registry/request/UrlConnectionUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@
/** Utilities for common functionality relating to {@link URLConnection}s. */
public final class UrlConnectionUtils {

public static final int MAX_PAYLOAD_BYTES = 50 * 1024 * 1024;

private UrlConnectionUtils() {}

/**
Expand All @@ -48,10 +50,22 @@ private UrlConnectionUtils() {}
* @see HttpURLConnection#getErrorStream()
*/
public static byte[] getResponseBytes(HttpURLConnection connection) throws IOException {
int responseCode = connection.getResponseCode();
try (InputStream is =
responseCode < 400 ? connection.getInputStream() : connection.getErrorStream()) {
return ByteStreams.toByteArray(is);
try {
if (connection.getContentLengthLong() > MAX_PAYLOAD_BYTES) {
throw new IOException(
String.format(
"Response size %d exceeds limit of %d bytes",
connection.getContentLengthLong(), MAX_PAYLOAD_BYTES));
}
int responseCode = connection.getResponseCode();
try (InputStream is =
responseCode < 400 ? connection.getInputStream() : connection.getErrorStream()) {
byte[] bytes = ByteStreams.toByteArray(ByteStreams.limit(is, MAX_PAYLOAD_BYTES + 1));
if (bytes.length > MAX_PAYLOAD_BYTES) {
throw new IOException("Response exceeds maximum allowed size");
}
return bytes;
}
} catch (NullPointerException e) {
return new byte[] {};
}
Expand Down
51 changes: 51 additions & 0 deletions core/src/test/java/google/registry/request/RequestModuleTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,23 @@
package google.registry.request;

import static com.google.common.truth.Truth.assertThat;
import static google.registry.request.RequestModule.provideJsonBody;
import static google.registry.request.RequestModule.provideJsonPayload;
import static google.registry.request.RequestModule.providePayloadAsBytes;
import static google.registry.request.RequestModule.providePayloadAsString;
import static google.registry.security.JsonHttpTestUtils.createServletInputStream;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

import com.google.common.net.MediaType;
import com.google.gson.Gson;
import google.registry.request.HttpException.BadRequestException;
import google.registry.request.HttpException.PayloadTooLargeException;
import google.registry.request.HttpException.UnsupportedMediaTypeException;
import google.registry.tools.GsonUtils;
import jakarta.servlet.http.HttpServletRequest;
import org.junit.jupiter.api.Test;

/** Unit tests for {@link RequestModule}. */
Expand Down Expand Up @@ -72,4 +81,46 @@ void testProvideJsonPayload_contentTypeWithWeirdParam_throws415() {
UnsupportedMediaTypeException.class,
() -> provideJsonPayload(MediaType.JSON_UTF_8.withParameter("omg", "handel"), "{}", GSON));
}

@Test
void testProvidePayloadAsBytes_contentLengthExceedsLimit_throws413() {
HttpServletRequest req = mock(HttpServletRequest.class);
when(req.getContentLengthLong()).thenReturn((long) RequestModule.MAX_PAYLOAD_BYTES + 1);
PayloadTooLargeException thrown =
assertThrows(PayloadTooLargeException.class, () -> providePayloadAsBytes(req));
assertThat(thrown).hasMessageThat().contains("exceeds limit");
}

@Test
void testProvidePayloadAsBytes_streamExceedsLimit_throws413() throws Exception {
HttpServletRequest req = mock(HttpServletRequest.class);
when(req.getContentLengthLong()).thenReturn(-1L);
when(req.getInputStream())
.thenReturn(createServletInputStream(new byte[RequestModule.MAX_PAYLOAD_BYTES + 1]));
PayloadTooLargeException thrown =
assertThrows(PayloadTooLargeException.class, () -> providePayloadAsBytes(req));
assertThat(thrown).hasMessageThat().contains("exceeds maximum allowed size");
}

@Test
void testProvidePayloadAsString_invalidCharset_throws415() {
HttpServletRequest req = mock(HttpServletRequest.class);
when(req.getCharacterEncoding()).thenReturn("invalid-charset-name");
UnsupportedMediaTypeException thrown =
assertThrows(
UnsupportedMediaTypeException.class,
() -> providePayloadAsString("hello".getBytes(UTF_8), req));
assertThat(thrown).hasMessageThat().contains("Unsupported charset: invalid-charset-name");
}

@Test
void testProvideJsonBody_contentLengthExceedsLimit_throws413() {
HttpServletRequest req = mock(HttpServletRequest.class);
when(req.getContentLengthLong()).thenReturn((long) RequestModule.MAX_PAYLOAD_BYTES + 1);
PayloadTooLargeException thrown =
assertThrows(
PayloadTooLargeException.class,
() -> provideJsonBody(providePayloadAsString(providePayloadAsBytes(req), req), GSON));
assertThat(thrown).hasMessageThat().contains("exceeds limit");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;
import java.util.Random;
Expand Down Expand Up @@ -80,13 +81,13 @@ void testSetPayloadMultipart() throws Exception {
"294");
String payload =
"""
--------------------------------AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\r
Content-Disposition: form-data; name="lol"; filename="cat"\r
Content-Type: text/csv; charset=utf-8\r
\r
The nice people at the store say hello. ヘ(◕。◕ヘ)\r
--------------------------------AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA--\r
""";
--------------------------------AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\r
Content-Disposition: form-data; name="lol"; filename="cat"\r
Content-Type: text/csv; charset=utf-8\r
\r
The nice people at the store say hello. ヘ(◕。◕ヘ)\r
--------------------------------AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA--\r
""";
verify(connection).setDoOutput(true);
verify(connection).getOutputStream();
assertThat(connectionOutputStream.toByteArray()).isEqualTo(payload.getBytes(UTF_8));
Expand Down Expand Up @@ -124,4 +125,26 @@ void testErrorStream_null() throws Exception {
when(connection.getResponseCode()).thenReturn(400);
assertThat(UrlConnectionUtils.getResponseBytes(connection)).isEmpty();
}

@Test
void testGetResponseBytes_contentLengthExceedsLimit_throwsException() {
HttpsURLConnection connection = mock(HttpsURLConnection.class);
when(connection.getContentLengthLong())
.thenReturn((long) UrlConnectionUtils.MAX_PAYLOAD_BYTES + 1);
IOException thrown =
assertThrows(IOException.class, () -> UrlConnectionUtils.getResponseBytes(connection));
assertThat(thrown).hasMessageThat().contains("exceeds limit");
}

@Test
void testGetResponseBytes_streamExceedsLimit_throwsException() throws Exception {
HttpsURLConnection connection = mock(HttpsURLConnection.class);
when(connection.getContentLengthLong()).thenReturn(-1L);
when(connection.getResponseCode()).thenReturn(200);
when(connection.getInputStream())
.thenReturn(new ByteArrayInputStream(new byte[UrlConnectionUtils.MAX_PAYLOAD_BYTES + 1]));
IOException thrown =
assertThrows(IOException.class, () -> UrlConnectionUtils.getResponseBytes(connection));
assertThat(thrown).hasMessageThat().contains("exceeds maximum allowed size");
}
}
31 changes: 31 additions & 0 deletions core/src/test/java/google/registry/security/JsonHttpTestUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@
import com.google.gson.JsonSyntaxException;
import com.google.gson.reflect.TypeToken;
import google.registry.tools.GsonUtils;
import jakarta.servlet.ReadListener;
import jakarta.servlet.ServletInputStream;
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.StringReader;
import java.io.StringWriter;
import java.util.Map;
Expand Down Expand Up @@ -84,4 +87,32 @@ public static Supplier<Map<String, Object>> createJsonResponseSupplier(
final StringWriter writer) {
return memoize(() -> getJsonResponse(writer));
}

public static ServletInputStream createServletInputStream(byte[] data) {
ByteArrayInputStream bais = new ByteArrayInputStream(data);
return new ServletInputStream() {
@Override
public boolean isFinished() {
return bais.available() == 0;
}

@Override
public boolean isReady() {
return true;
}

@Override
public void setReadListener(ReadListener listener) {}

@Override
public int read() {
return bais.read();
}

@Override
public int read(byte[] b, int off, int len) {
return bais.read(b, off, len);
}
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,13 @@
import google.registry.model.console.User;
import google.registry.persistence.transaction.JpaTestExtensions;
import google.registry.request.auth.AuthResult;
import google.registry.security.JsonHttpTestUtils;
import google.registry.testing.ConsoleApiParamsUtils;
import google.registry.testing.FakeClock;
import google.registry.testing.FakeResponse;
import google.registry.tools.GsonUtils;
import jakarta.servlet.ServletInputStream;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.extension.RegisterExtension;
Expand All @@ -51,4 +54,8 @@ void beforeEachBaseTestCase() {
consoleApiParams = ConsoleApiParamsUtils.createFake(authResult);
response = (FakeResponse) consoleApiParams.response();
}

protected static ServletInputStream createServletInputStream(String data) {
return JsonHttpTestUtils.createServletInputStream(data.getBytes(StandardCharsets.UTF_8));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@
import static jakarta.servlet.http.HttpServletResponse.SC_FORBIDDEN;
import static jakarta.servlet.http.HttpServletResponse.SC_OK;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
Expand All @@ -47,9 +46,7 @@
import google.registry.util.EmailMessage;
import jakarta.mail.internet.AddressException;
import jakarta.mail.internet.InternetAddress;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.StringReader;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
Expand Down Expand Up @@ -167,16 +164,13 @@ private ConsoleEppPasswordAction createAction(
AuthenticatedRegistrarAccessor.createForTesting(
ImmutableSetMultimap.of("TheRegistrar", OWNER));
when(consoleApiParams.request().getMethod()).thenReturn(Action.Method.POST.toString());
doReturn(
new BufferedReader(
new StringReader(
String.format(
eppPostData, registrarId, oldPassword, newPassword, newPasswordRepeat))))
.when(consoleApiParams.request())
.getReader();
Optional<EppPasswordData> maybePasswordChangeRequest =
ConsoleModule.provideEppPasswordChangeRequest(
GSON, RequestModule.provideJsonBody(consoleApiParams.request(), GSON));
GSON,
RequestModule.provideJsonBody(
String.format(
eppPostData, registrarId, oldPassword, newPassword, newPasswordRepeat),
GSON));

return new ConsoleEppPasswordAction(
consoleApiParams, authenticatedRegistrarAccessor, maybePasswordChangeRequest);
Expand Down
Loading
Loading