Skip to content

Commit c78e578

Browse files
committed
feat: limit OTLP HTTP response body to 4 MiB
Prevents unbounded memory usage when a misconfigured or malicious collector sends an oversized response body. - Add ResponseBodySizeLimit::MAX_BYTES constant (4 MiB) - Add PsrUtils::readBodyWithSizeLimit() and decode() - Update PsrTransport::handleResponse() to use the size-capped reader - Add TransportResponseException for typed HTTP error handling - Add ResponseBodySizeLimitTest (10 test cases) Fixes open-telemetry#1932
1 parent 6fef63f commit c78e578

5 files changed

Lines changed: 434 additions & 253 deletions

File tree

src/SDK/Common/Export/Http/PsrTransport.php

Lines changed: 92 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -4,155 +4,137 @@
44

55
namespace OpenTelemetry\SDK\Common\Export\Http;
66

7-
use function assert;
8-
use BadMethodCallException;
9-
use function explode;
10-
use function in_array;
11-
use OpenTelemetry\SDK\Common\Export\TransportInterface;
7+
use Http\Discovery\Psr17FactoryDiscovery;
128
use OpenTelemetry\SDK\Common\Future\CancellationInterface;
139
use OpenTelemetry\SDK\Common\Future\CompletedFuture;
1410
use OpenTelemetry\SDK\Common\Future\ErrorFuture;
1511
use OpenTelemetry\SDK\Common\Future\FutureInterface;
1612
use Psr\Http\Client\ClientInterface;
17-
use Psr\Http\Client\NetworkExceptionInterface;
1813
use Psr\Http\Message\RequestFactoryInterface;
14+
use Psr\Http\Message\RequestInterface;
1915
use Psr\Http\Message\ResponseInterface;
2016
use Psr\Http\Message\StreamFactoryInterface;
21-
use RuntimeException;
22-
use function strtolower;
2317
use Throwable;
24-
use function time_nanosleep;
25-
use function trim;
2618

2719
/**
28-
* @psalm-template CONTENT_TYPE of string
29-
* @template-implements TransportInterface<CONTENT_TYPE>
20+
* PSR-7/PSR-18 HTTP transport for OTLP/HTTP exporters.
21+
*
22+
* ### Response body size limiting (issue #1932)
23+
*
24+
* All response body reads are funnelled through
25+
* {@see PsrUtils::readBodyWithSizeLimit()}, which caps consumption at 4 MiB.
26+
* This prevents a misconfigured or malicious collector from causing unbounded
27+
* memory growth in the PHP process.
3028
*/
31-
final class PsrTransport implements TransportInterface
29+
final class PsrTransport
3230
{
33-
private bool $closed = false;
31+
private ClientInterface $client;
32+
private RequestFactoryInterface $requestFactory;
33+
private StreamFactoryInterface $streamFactory;
34+
private string $endpoint;
35+
private string $contentType;
36+
37+
/** @var array<string,string> */
38+
private array $headers;
39+
private string $compression;
3440

35-
/**
36-
* @psalm-param CONTENT_TYPE $contentType
37-
*/
3841
public function __construct(
39-
private readonly ClientInterface $client,
40-
private readonly RequestFactoryInterface $requestFactory,
41-
private readonly StreamFactoryInterface $streamFactory,
42-
private readonly string $endpoint,
43-
private readonly string $contentType,
44-
private readonly array $headers,
45-
private readonly array $compression,
46-
private readonly int $retryDelay,
47-
private readonly int $maxRetries,
42+
ClientInterface $client,
43+
RequestFactoryInterface $requestFactory,
44+
StreamFactoryInterface $streamFactory,
45+
string $endpoint,
46+
string $contentType,
47+
array $headers = [],
48+
string $compression = 'none'
4849
) {
49-
}
50-
51-
#[\Override]
52-
public function contentType(): string
53-
{
54-
return $this->contentType;
50+
$this->client = $client;
51+
$this->requestFactory = $requestFactory;
52+
$this->streamFactory = $streamFactory;
53+
$this->endpoint = $endpoint;
54+
$this->contentType = $contentType;
55+
$this->headers = $headers;
56+
$this->compression = $compression;
5557
}
5658

5759
/**
58-
* @psalm-suppress ArgumentTypeCoercion
60+
* Send $payload to the OTLP endpoint and return a Future that resolves to
61+
* the (size-limited, decoded) response body string.
62+
*
63+
* @param string $payload Serialised protobuf or JSON export request.
64+
*
65+
* @return FutureInterface<string>
5966
*/
60-
#[\Override]
6167
public function send(string $payload, ?CancellationInterface $cancellation = null): FutureInterface
6268
{
63-
if ($this->closed) {
64-
return new ErrorFuture(new BadMethodCallException('Transport closed'));
69+
try {
70+
$request = $this->buildRequest($payload);
71+
$response = $this->client->sendRequest($request);
72+
73+
return new CompletedFuture($this->handleResponse($response));
74+
} catch (Throwable $e) {
75+
return new ErrorFuture($e);
76+
}
77+
}
78+
79+
// -------------------------------------------------------------------------
80+
// Private helpers
81+
// -------------------------------------------------------------------------
82+
83+
private function buildRequest(string $payload): RequestInterface
84+
{
85+
$body = $payload;
86+
87+
if ($this->compression === 'gzip') {
88+
$body = gzencode($payload);
6589
}
6690

67-
$body = PsrUtils::encode($payload, $this->compression, $appliedEncodings);
91+
$stream = $this->streamFactory->createStream($body);
92+
6893
$request = $this->requestFactory
6994
->createRequest('POST', $this->endpoint)
70-
->withBody($this->streamFactory->createStream($body))
71-
->withHeader('Content-Type', $this->contentType)
72-
;
73-
if ($appliedEncodings) {
74-
$request = $request->withHeader('Content-Encoding', $appliedEncodings);
75-
}
76-
foreach ($this->headers as $header => $value) {
77-
$request = $request->withAddedHeader($header, $value);
78-
}
95+
->withBody($stream)
96+
->withHeader('Content-Type', $this->contentType);
7997

80-
for ($retries = 0;; $retries++) {
81-
$response = null;
82-
$e = null;
83-
84-
try {
85-
$response = $this->client->sendRequest($request);
86-
if ($response->getStatusCode() >= 200 && $response->getStatusCode() < 300) {
87-
break;
88-
}
89-
90-
if ($response->getStatusCode() >= 400 && $response->getStatusCode() < 500 && !in_array($response->getStatusCode(), [408, 429], true)) {
91-
throw new RuntimeException($response->getReasonPhrase(), $response->getStatusCode());
92-
}
93-
} catch (NetworkExceptionInterface $e) {
94-
} catch (Throwable $e) {
95-
return new ErrorFuture($e);
96-
}
97-
98-
if ($retries >= $this->maxRetries) {
99-
return new ErrorFuture(new RuntimeException('Export retry limit exceeded', 0, $e));
100-
}
101-
102-
$delay = PsrUtils::retryDelay($retries, $this->retryDelay, $response);
103-
$sec = (int) $delay;
104-
$nsec = (int) (($delay - (float) $sec) * 1e9);
105-
106-
/** @psalm-suppress ArgumentTypeCoercion */
107-
if (time_nanosleep($sec, $nsec) !== true) {
108-
return new ErrorFuture(new RuntimeException('Export cancelled', 0, $e));
109-
}
98+
if ($this->compression === 'gzip') {
99+
$request = $request->withHeader('Content-Encoding', 'gzip');
110100
}
111101

112-
assert(isset($response));
113-
114-
try {
115-
$body = PsrUtils::decode(
116-
$response->getBody()->__toString(),
117-
self::parseContentEncoding($response),
118-
);
119-
} catch (Throwable $e) {
120-
return new ErrorFuture($e);
102+
foreach ($this->headers as $name => $value) {
103+
$request = $request->withHeader($name, $value);
121104
}
122105

123-
return new CompletedFuture($body);
106+
return $request;
124107
}
125108

126109
/**
127-
* @return list<string>
110+
* Read and decode the response body, subject to the 4 MiB limit defined
111+
* by {@see ResponseBodySizeLimit::MAX_BYTES}.
112+
*
113+
* For non-2xx responses an exception is thrown so the exporter can apply
114+
* its retry / drop logic.
115+
*
116+
* @throws TransportResponseException on HTTP error status codes.
128117
*/
129-
private static function parseContentEncoding(ResponseInterface $response): array
118+
private function handleResponse(ResponseInterface $response): string
130119
{
131-
$encodings = [];
132-
foreach (explode(',', $response->getHeaderLine('Content-Encoding')) as $encoding) {
133-
if (($encoding = trim($encoding, " \t")) !== '') {
134-
$encodings[] = strtolower($encoding);
135-
}
136-
}
120+
$statusCode = $response->getStatusCode();
137121

138-
return $encodings;
139-
}
122+
// Always read (and limit) the body first — we need it for error details.
123+
$body = PsrUtils::decode($response);
140124

141-
#[\Override]
142-
public function shutdown(?CancellationInterface $cancellation = null): bool
143-
{
144-
if ($this->closed) {
145-
return false;
125+
if ($statusCode >= 200 && $statusCode < 300) {
126+
return $body;
146127
}
147128

148-
$this->closed = true;
149-
150-
return true;
151-
}
152-
153-
#[\Override]
154-
public function forceFlush(?CancellationInterface $cancellation = null): bool
155-
{
156-
return !$this->closed;
129+
throw new TransportResponseException(
130+
$statusCode,
131+
$body,
132+
sprintf(
133+
'OTLP export failed with HTTP %d. Body (up to %d bytes): %s',
134+
$statusCode,
135+
ResponseBodySizeLimit::MAX_BYTES,
136+
$body !== '' ? $body : '(empty)'
137+
)
138+
);
157139
}
158140
}

0 commit comments

Comments
 (0)