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}