001/* 002 * Copyright 2022-2026 Revetware LLC. 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016 017package com.soklet; 018 019import org.jspecify.annotations.NonNull; 020import org.jspecify.annotations.Nullable; 021 022import javax.annotation.concurrent.NotThreadSafe; 023import javax.annotation.concurrent.ThreadSafe; 024import java.util.Optional; 025 026import static java.lang.String.format; 027 028/** 029 * Policy used by the standard HTTP server to decide whether and how gzip-compressed request bodies 030 * ({@code Content-Encoding: gzip} or {@code x-gzip}, per 031 * <a href="https://www.rfc-editor.org/rfc/rfc9110.html#section-8.4">RFC 9110, Section 8.4</a>) are 032 * transparently decompressed before request handling. Only a single {@code gzip}/{@code x-gzip} coding is 033 * supported; coding chains (e.g. {@code identity, gzip}) are rejected with {@code 415} as sanctioned by 034 * <a href="https://www.rfc-editor.org/rfc/rfc9110.html#section-15.5.16">RFC 9110, Section 15.5.16</a>. 035 * <p> 036 * Decompression is <strong>opt-in</strong> and disabled by default; see 037 * {@code HttpServer.Builder#requestDecompressionPolicy(RequestDecompressionPolicy)}. When disabled, request 038 * bodies are passed to handlers exactly as received, compressed or not. 039 * <p> 040 * When enabled: 041 * <ul> 042 * <li>A request with {@code Content-Encoding: gzip} or {@code x-gzip} has its body decompressed; the 043 * {@code Content-Encoding} header is removed and {@code Content-Length} is updated to the decompressed size, 044 * so handlers observe a self-consistent uncompressed request. The original encoded payload size remains 045 * available through {@link Request#getEncodedBodySizeInBytes()} for telemetry and diagnostics.</li> 046 * <li>A request with an unsupported {@code Content-Encoding} (or multiple codings) is rejected with 047 * {@code 415 Unsupported Media Type}.</li> 048 * <li>A request whose body cannot be decompressed is rejected with {@code 400 Bad Request}.</li> 049 * <li>A request whose decompressed body exceeds {@link #getMaximumDecompressedBodySizeInBytes()} (or, when 050 * unset, the server's {@code maximumRequestSizeInBytes}) or expands beyond 051 * {@link #getMaximumCompressionRatio()} is rejected through the normal {@code 413 Content Too Large} 052 * marshaling path. These limits guard against decompression bombs; decompression aborts as soon as a 053 * limit is exceeded.</li> 054 * <li>{@code Content-Encoding: identity}, requests without {@code Content-Encoding}, and bodyless 055 * {@code gzip}/{@code x-gzip} requests are passed through unchanged.</li> 056 * </ul> 057 * <p> 058 * This applies to the standard HTTP server only. The SSE and MCP servers do not decompress request bodies, 059 * and the {@code Simulator} exercises handlers directly without transport-level decompression. 060 * 061 * @author <a href="https://www.revetkn.com">Mark Allen</a> 062 */ 063@ThreadSafe 064public final class RequestDecompressionPolicy { 065 @NonNull 066 private static final Integer DEFAULT_MAXIMUM_COMPRESSION_RATIO; 067 @NonNull 068 private static final RequestDecompressionPolicy DISABLED_INSTANCE; 069 @NonNull 070 private static final RequestDecompressionPolicy DEFAULTS_INSTANCE; 071 072 static { 073 DEFAULT_MAXIMUM_COMPRESSION_RATIO = 100; 074 DISABLED_INSTANCE = new RequestDecompressionPolicy(false, null, DEFAULT_MAXIMUM_COMPRESSION_RATIO); 075 DEFAULTS_INSTANCE = new RequestDecompressionPolicy(true, null, DEFAULT_MAXIMUM_COMPRESSION_RATIO); 076 } 077 078 @NonNull 079 private final Boolean enabled; 080 @Nullable 081 private final Integer maximumDecompressedBodySizeInBytes; 082 @NonNull 083 private final Integer maximumCompressionRatio; 084 085 private RequestDecompressionPolicy(@NonNull Boolean enabled, 086 @Nullable Integer maximumDecompressedBodySizeInBytes, 087 @NonNull Integer maximumCompressionRatio) { 088 this.enabled = enabled; 089 this.maximumDecompressedBodySizeInBytes = maximumDecompressedBodySizeInBytes; 090 this.maximumCompressionRatio = maximumCompressionRatio; 091 } 092 093 /** 094 * Acquires a policy that disables request decompression (the default): request bodies are passed to 095 * handlers exactly as received. 096 * 097 * @return a disabled request decompression policy 098 */ 099 @NonNull 100 public static RequestDecompressionPolicy disabledInstance() { 101 return DISABLED_INSTANCE; 102 } 103 104 /** 105 * Acquires a policy that enables gzip request decompression with default limits: the decompressed body is 106 * capped by the server's {@code maximumRequestSizeInBytes} and a {@code 100:1} compression ratio. 107 * 108 * @return a default request decompression policy 109 */ 110 @NonNull 111 public static RequestDecompressionPolicy fromDefaults() { 112 return DEFAULTS_INSTANCE; 113 } 114 115 /** 116 * Acquires a builder for an enabled request decompression policy with custom limits. 117 * 118 * @return a request decompression policy builder 119 */ 120 @NonNull 121 public static Builder builder() { 122 return new Builder(); 123 } 124 125 /** 126 * Is request decompression enabled? 127 * 128 * @return {@code true} if gzip request bodies should be decompressed, {@code false} otherwise 129 */ 130 @NonNull 131 public Boolean isEnabled() { 132 return this.enabled; 133 } 134 135 /** 136 * The maximum permitted decompressed body size in bytes, if customized. 137 * <p> 138 * When empty, the server's {@code maximumRequestSizeInBytes} applies to the decompressed body. 139 * 140 * @return the maximum decompressed body size, or {@link Optional#empty()} to use the server's request size limit 141 */ 142 @NonNull 143 public Optional<Integer> getMaximumDecompressedBodySizeInBytes() { 144 return Optional.ofNullable(this.maximumDecompressedBodySizeInBytes); 145 } 146 147 /** 148 * The maximum permitted expansion ratio between decompressed and compressed body sizes. 149 * <p> 150 * A request is rejected with {@code 413 Content Too Large} once its decompressed size exceeds 151 * {@code (compressed size × ratio) + 8 KB}; the additive allowance keeps legitimately small compressed 152 * bodies from tripping the ratio check. 153 * 154 * @return the maximum compression ratio (default {@code 100}) 155 */ 156 @NonNull 157 public Integer getMaximumCompressionRatio() { 158 return this.maximumCompressionRatio; 159 } 160 161 @Override 162 @NonNull 163 public String toString() { 164 return format("%s{enabled=%s, maximumDecompressedBodySizeInBytes=%s, maximumCompressionRatio=%s}", 165 getClass().getSimpleName(), isEnabled(), getMaximumDecompressedBodySizeInBytes().orElse(null), 166 getMaximumCompressionRatio()); 167 } 168 169 /** 170 * Builder for enabled {@link RequestDecompressionPolicy} instances. 171 */ 172 @NotThreadSafe 173 public static final class Builder { 174 @Nullable 175 private Integer maximumDecompressedBodySizeInBytes; 176 @Nullable 177 private Integer maximumCompressionRatio; 178 179 private Builder() {} 180 181 /** 182 * Sets the maximum permitted decompressed body size in bytes, or {@code null} to use the server's 183 * {@code maximumRequestSizeInBytes}. 184 * 185 * @param maximumDecompressedBodySizeInBytes the maximum decompressed body size, or {@code null} for the server default 186 * @return this builder 187 */ 188 @NonNull 189 public Builder maximumDecompressedBodySizeInBytes(@Nullable Integer maximumDecompressedBodySizeInBytes) { 190 this.maximumDecompressedBodySizeInBytes = maximumDecompressedBodySizeInBytes; 191 return this; 192 } 193 194 /** 195 * Sets the maximum permitted expansion ratio between decompressed and compressed body sizes, or 196 * {@code null} for the default of {@code 100}. Must be between {@code 1} and {@code 10_000}; 197 * the upper bound keeps ratio arithmetic safely within {@code long} range for any legal body size 198 * (gzip's theoretical maximum expansion is roughly {@code 1032:1}). 199 * 200 * @param maximumCompressionRatio the maximum compression ratio, or {@code null} for the default 201 * @return this builder 202 */ 203 @NonNull 204 public Builder maximumCompressionRatio(@Nullable Integer maximumCompressionRatio) { 205 this.maximumCompressionRatio = maximumCompressionRatio; 206 return this; 207 } 208 209 /** 210 * Builds an enabled {@link RequestDecompressionPolicy}. 211 * 212 * @return the request decompression policy 213 * @throws IllegalArgumentException if a limit is zero or negative 214 */ 215 @NonNull 216 public RequestDecompressionPolicy build() { 217 if (this.maximumDecompressedBodySizeInBytes != null && this.maximumDecompressedBodySizeInBytes <= 0) 218 throw new IllegalArgumentException(format("maximumDecompressedBodySizeInBytes must be positive, was %d", 219 this.maximumDecompressedBodySizeInBytes)); 220 221 if (this.maximumCompressionRatio != null 222 && (this.maximumCompressionRatio <= 0 || this.maximumCompressionRatio > 10_000)) 223 throw new IllegalArgumentException(format("maximumCompressionRatio must be between 1 and 10000, was %d", 224 this.maximumCompressionRatio)); 225 226 return new RequestDecompressionPolicy(true, this.maximumDecompressedBodySizeInBytes, 227 this.maximumCompressionRatio != null ? this.maximumCompressionRatio : DEFAULT_MAXIMUM_COMPRESSION_RATIO); 228 } 229 } 230}