001/* 002 * Licensed to the Apache Software Foundation (ASF) under one 003 * or more contributor license agreements. See the NOTICE file 004 * distributed with this work for additional information 005 * regarding copyright ownership. The ASF licenses this file 006 * to you under the Apache License, Version 2.0 (the 007 * "License"); you may not use this file except in compliance 008 * with the License. You may obtain a copy of the License at 009 * 010 * http://www.apache.org/licenses/LICENSE-2.0 011 * 012 * Unless required by applicable law or agreed to in writing, 013 * software distributed under the License is distributed on an 014 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 015 * KIND, either express or implied. See the License for the 016 * specific language governing permissions and limitations 017 * under the License. 018 */ 019package org.eclipse.aether.spi.connector.transport.http.RFC9457; 020 021import java.io.ByteArrayOutputStream; 022import java.io.IOException; 023import java.io.InputStream; 024import java.nio.charset.StandardCharsets; 025 026/** 027 * A reporter for RFC 9457 messages. 028 * RFC 9457 is a standard for reporting problems in HTTP responses as a JSON object. 029 * There are members specified in the RFC but none of those appear to be required, 030 * see <a href=https://www.rfc-editor.org/rfc/rfc9457#section-3-7>rfc9457 section 3.7</a> 031 * Given the JSON fields are not mandatory, this reporter simply extracts the body of the 032 * response without validation. 033 * A RFC 9457 message is detected by the content type {@value #CONTENT_TYPE_PROBLEM_DETAILS_JSON} in the response header. 034 * 035 * @param <T> The type of the response. 036 * @param <E> The base exception type to throw if the response is not a RFC9457 message. 037 * @param <R> The type of the request or request builder (which allows to modify headers) 038 * @see <a href=https://www.rfc-editor.org/rfc/rfc9457#section-3-7>RFC 9457</a> 039 */ 040public abstract class RFC9457Reporter<T, E extends Exception, R> { 041 public static final String CONTENT_TYPE_PROBLEM_DETAILS_JSON = "application/problem+json"; 042 public static final String CONTENT_TYPE_PROBLEM_DETAILS_JSON_AND_ANY = "application/problem+json,*/*"; 043 044 /** 045 * Maximum number of bytes read from an error response body. Legitimate RFC 9457 payloads are 046 * tiny, while the body of an error response is attacker influenced data (the content type header 047 * alone triggers consumption) and may be transparently decompressed by the transport, so it must 048 * never be buffered unbounded. Bodies larger than this limit are truncated, not rejected. 049 */ 050 public static final int MAX_BODY_BYTES = 64 * 1024; 051 052 protected abstract boolean isRFC9457Message(T response); 053 054 protected abstract int getStatusCode(T response); 055 056 protected abstract String getReasonPhrase(T response); 057 058 protected abstract String getBody(T response) throws IOException; 059 060 /** 061 * Prepares the request to accept RFC 9457 responses. 062 * This involves setting/updating the "Accept" header to include "application/problem+json". 063 * @param request The request or request builder to prepare 064 * @see <a href=https://www.rfc-editor.org/rfc/rfc9457#section-3-2>RFC 9457 section 3.2</a> 065 */ 066 public abstract void prepareRequest(R request); 067 068 /** 069 * Reads the given stream into a UTF-8 string, consuming at most {@link #MAX_BODY_BYTES} bytes. 070 * Any remaining bytes are left unread, so an oversized (or decompression amplified) body is 071 * truncated instead of exhausting memory. The caller remains responsible for closing the stream. 072 * 073 * @param is The stream to read the body from, must not be {@code null}. 074 * @return The body as UTF-8 string, truncated to {@link #MAX_BODY_BYTES} bytes, never {@code null}. 075 * @throws IOException If reading the stream fails. 076 */ 077 protected static String readBody(InputStream is) throws IOException { 078 ByteArrayOutputStream body = new ByteArrayOutputStream(); 079 byte[] chunk = new byte[8 * 1024]; 080 int remaining = MAX_BODY_BYTES; 081 while (remaining > 0) { 082 int read = is.read(chunk, 0, Math.min(chunk.length, remaining)); 083 if (read < 0) { 084 break; 085 } 086 body.write(chunk, 0, read); 087 remaining -= read; 088 } 089 return body.toString(StandardCharsets.UTF_8.name()); 090 } 091 092 protected boolean hasRFC9457ContentType(String contentType) { 093 if (contentType == null) { 094 return false; 095 } 096 // strip off parameters 097 int idx = contentType.indexOf(';'); 098 if (idx > -1) { 099 contentType = contentType.substring(0, idx); 100 } 101 return CONTENT_TYPE_PROBLEM_DETAILS_JSON.equals(contentType); 102 } 103 104 /** 105 * Generates a {@link HttpRFC9457Exception} if the response type is a RFC 9457 message. 106 * Otherwise, it throws the base exception 107 * 108 * @param response The response to check for RFC 9457 messages. 109 * @param baseException The base exception to throw if the response is not a RFC 9457 message. 110 */ 111 public void generateException(T response, BiConsumerChecked<Integer, String, E> baseException) 112 throws E, HttpRFC9457Exception { 113 int statusCode = getStatusCode(response); 114 String reasonPhrase = getReasonPhrase(response); 115 116 if (isRFC9457Message(response)) { 117 String body; 118 try { 119 body = getBody(response); 120 } catch (IOException ignore) { 121 // No body found but it is representing a RFC 9457 message due to the content type. 122 throw new HttpRFC9457Exception(statusCode, reasonPhrase, RFC9457Payload.INSTANCE); 123 } 124 125 if (body != null && !body.isEmpty()) { 126 RFC9457Payload rfc9457Payload; 127 try { 128 rfc9457Payload = RFC9457Parser.parse(body); 129 } catch (RuntimeException ignore) { 130 // Malformed (possibly truncated, see MAX_BODY_BYTES) problem details 131 // must not change the error classification. 132 rfc9457Payload = null; 133 } 134 if (rfc9457Payload != null) { 135 throw new HttpRFC9457Exception(statusCode, reasonPhrase, rfc9457Payload); 136 } 137 baseException.accept(statusCode, reasonPhrase); 138 return; 139 } 140 throw new HttpRFC9457Exception(statusCode, reasonPhrase, RFC9457Payload.INSTANCE); 141 } 142 baseException.accept(statusCode, reasonPhrase); 143 } 144}