Skip to content

[TEST] Fix use-after-free when an HTTP handler closes the connection - #4289

Merged
dbarker merged 12 commits into
open-telemetry:mainfrom
thc1006:bugfix/http-server-connection-uaf-4288
Jul 30, 2026
Merged

dbarker merged 12 commits into
open-telemetry:mainfrom
thc1006:bugfix/http-server-connection-uaf-4288

Conversation

@thc1006

@thc1006 thc1006 commented Jul 24, 2026

Copy link
Copy Markdown
Member

Fixes #4288
Refs #4287

The bug

m_connections is a std::map<SocketTools::Socket, Connection>, so the m_connections.erase(connIt) at the end of handleConnectionClosed() destroys the mapped Connection. processRequest() called it and did not return, so everything after that point wrote to a destroyed object: conn.response.message, then the Host, Connection, Date and Content-Length headers. Control then went back to handleConnection(), which read conn.request.protocol and conn.response.* to build the status line and called sendMore(conn) on an already closed socket.

-1 is a documented part of the handler contract rather than an internal value; file_http_server.h states that a handler returning -1 terminates the connection.

Fail before, pass after

A standalone harness drives HttpServer with a handler that returns -1, then connects a raw client and sends one request. On origin/main:

==11==ERROR: AddressSanitizer: heap-use-after-free
READ of size 4 at 0x5150000001d8 thread T1
    #0 HttpServer::processRequest(Connection&)   http_server.h:775
    #1 HttpServer::handleConnection(Connection&) http_server.h:525
    #2 HttpServer::onSocketReadable(Socket)      http_server.h:293
freed by thread T1 here:
    #8 HttpServer::handleConnectionClosed(Connection&) http_server.h:370
    #9 HttpServer::processRequest(Connection&)         http_server.h:769

Freed by the erase at :370, read at :775. With this change the same harness runs clean under both ASan and UBSan, the handler is invoked once, and the client observes the connection closed with no response.

The fix

processRequest() no longer destroys the connection it was handed. It reports an outcome and handleConnection() performs the close:

if (processRequest(conn) == RequestOutcome::CloseConnection)
{
  conn.state = Connection::Closing;
  handleConnectionClosed(conn);
  return;
}

handleConnectionClosed() keeps the end() guard and now also asserts, so a broken invariant is caught in a debug build rather than silently swallowed. The guard does not make the function idempotent, since the reactor removal and socket close above it have already run, and there is a comment saying so.

The other two callers were already correct: onSocketReadable() returns immediately afterwards, and it is the last statement in onSocketClosed().

Test

/close/ is now registered in SetUp() and returns -1. HandlerRequestedCloseKeepsServerUsable asserts that the request fails, that a handler invocation counter is exactly 1, and that the next request is still served. The counter is the part that matters: without it, an unregistered or misspelled route passes the first assertion for the wrong reason, which is exactly what happened.

bazel test --config=asan //... covers //ext/test/http:curl_http_test.

Verification

  • clang-format 18 clean on both files.
  • clang-tidy 18 with the repo .clang-tidy on http_server.h: 3 warnings before and 3 after. The first version of the enum added a performance-enum-size warning, so it now has an explicit std::uint8_t base type. warning_limit is untouched.
  • Syntax-only build of file_http_server.h, which pulls in the subclass and an HttpServer instantiation, compiles clean.

Scope

Only the handler-requested close. The rest of #4287, including fatal send() errors and the Windows partial-write stall, is left for its own PRs.

@codecov

codecov Bot commented Jul 24, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.66667% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 81.16%. Comparing base (98445d3) to head (c00f730).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
...clude/opentelemetry/ext/http/server/socket_tools.h 50.00% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #4289      +/-   ##
==========================================
+ Coverage   81.15%   81.16%   +0.01%     
==========================================
  Files         446      446              
  Lines       18922    18929       +7     
==========================================
+ Hits        15355    15361       +6     
- Misses       3567     3568       +1     
Files with missing lines Coverage Δ
...nclude/opentelemetry/ext/http/server/http_server.h 66.67% <100.00%> (+0.76%) ⬆️
...clude/opentelemetry/ext/http/server/socket_tools.h 93.38% <50.00%> (-0.62%) ⬇️

... and 2 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@thc1006
thc1006 force-pushed the bugfix/http-server-connection-uaf-4288 branch from 4dfb26f to 5d4cc53 Compare July 24, 2026 17:52
@thc1006
thc1006 marked this pull request as ready for review July 25, 2026 07:49
@thc1006
thc1006 requested a review from a team as a code owner July 25, 2026 07:49
Copilot AI review requested due to automatic review settings July 25, 2026 07:49

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

thc1006 added 3 commits July 25, 2026 16:53
processRequest() called handleConnectionClosed() and then kept going.
That erases the Connection from m_connections, so every later access in
processRequest() and in its caller handleConnection() touched a
destroyed object, and sendMore() then ran on a closed socket.

processRequest() now reports whether the connection survived, and
handleConnection() returns immediately when it did not. The erase in
handleConnectionClosed() is also guarded against a missed find().

Adds a regression test: a handler on /close/ returns -1 and the test
checks that the server still serves afterwards. The unfixed code trips
AddressSanitizer on that request.

Fixes open-telemetry#4288

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
The test route was never reachable: the /close/ branch was added to
onHttpRequest() but SetUp() never called addHandler("/close/", *this),
so the request fell through to the default 404, the client saw a normal
response, and the assertion measured nothing. That is what broke the
test jobs. The route is now registered and the test also asserts a
handler invocation counter, so an unregistered or misspelled route fails
loudly instead of silently passing.

processRequest() no longer destroys the connection it was handed. It
returns a RequestOutcome and handleConnection() performs the close, so
lifetime stays with the one function that owns it and a future caller
cannot reintroduce the same use-after-free. The state is set to Closing
first, so a handler-requested close is no longer logged as "connection
closed unexpectedly".

handleConnectionClosed() keeps the end() guard and now also asserts, so
a broken invariant is caught in a debug build instead of being swallowed.

Verified with a standalone harness driving HttpServer with a handler
returning -1. On origin/main, AddressSanitizer reports
heap-use-after-free with the read at http_server.h:775 and the free at
:370. With this change the same harness is clean under both ASan and
UBSan.

Fixes open-telemetry#4288

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
@thc1006
thc1006 force-pushed the bugfix/http-server-connection-uaf-4288 branch from 5d4cc53 to 8c1a6f5 Compare July 25, 2026 08:54

@owent owent left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, thanks.

…nnection-uaf-4288

# Conflicts:
#	CHANGELOG.md

@marcalff marcalff left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, thanks for the analysis and fix.

@marcalff

Copy link
Copy Markdown
Member

@thc1006

Please check CI, there is a test failure in bazel (MacOS)

2026-07-28T14:56:26.7866140Z FAIL: //ext/test/http:curl_http_test (Aborted) (see /Users/runner/.cache/bazel/cb80cd7005204d9de6ac6bba60573b2f/execroot/_main/bazel-out/darwin_arm64-fastbuild/testlogs/ext/test/http/curl_http_test/test.log)
2026-07-28T14:56:26.7989740Z [7,694 / 9,846] 34 / 166 tests, 1 failed; Testing //ext/test/http:curl_http_test; 6s darwin-sandbox ... (3 actions running)
2026-07-28T14:56:26.8091240Z INFO: From Testing //ext/test/http:curl_http_test:
2026-07-28T14:56:26.8093480Z ==================== Test output for //ext/test/http:curl_http_test:
2026-07-28T14:56:26.8180790Z Running main() from gmock_main.cc
2026-07-28T14:56:26.8230540Z [==========] Running 20 tests from 1 test suite.
2026-07-28T14:56:26.8233080Z [----------] Global test environment set-up.
2026-07-28T14:56:26.8248250Z [----------] 20 tests from BasicCurlHttpTests
2026-07-28T14:56:26.8249670Z [ RUN      ] BasicCurlHttpTests.DoNothing
2026-07-28T14:56:26.8250500Z [       OK ] BasicCurlHttpTests.DoNothing (0 ms)
2026-07-28T14:56:26.8251410Z [ RUN      ] BasicCurlHttpTests.HttpRequest
2026-07-28T14:56:26.8252140Z [       OK ] BasicCurlHttpTests.HttpRequest (504 ms)
2026-07-28T14:56:26.8252940Z [ RUN      ] BasicCurlHttpTests.HttpResponse
2026-07-28T14:56:26.8253710Z [       OK ] BasicCurlHttpTests.HttpResponse (0 ms)
2026-07-28T14:56:26.8254440Z [ RUN      ] BasicCurlHttpTests.SendGetRequest
2026-07-28T14:56:26.8255240Z [       OK ] BasicCurlHttpTests.SendGetRequest (27 ms)
2026-07-28T14:56:26.8255940Z [ RUN      ] BasicCurlHttpTests.SendPostRequest
2026-07-28T14:56:26.8256730Z [       OK ] BasicCurlHttpTests.SendPostRequest (3 ms)
2026-07-28T14:56:26.8257480Z [ RUN      ] BasicCurlHttpTests.RequestTimeout
2026-07-28T14:56:26.8258190Z [       OK ] BasicCurlHttpTests.RequestTimeout (5023 ms)
2026-07-28T14:56:26.8259000Z [ RUN      ] BasicCurlHttpTests.CurlHttpOperations
2026-07-28T14:56:26.8259790Z [Error] File: ext/src/http/client/curl/http_operation_curl.cc:1289 Unexpected HTTP method
2026-07-28T14:56:26.8260750Z [       OK ] BasicCurlHttpTests.CurlHttpOperations (502 ms)
2026-07-28T14:56:26.8261530Z [ RUN      ] BasicCurlHttpTests.RetryPolicyEnabled
2026-07-28T14:56:26.8262250Z [       OK ] BasicCurlHttpTests.RetryPolicyEnabled (4 ms)
2026-07-28T14:56:26.8288310Z [ RUN      ] BasicCurlHttpTests.RetryPolicyDisabled
2026-07-28T14:56:26.8291560Z [       OK ] BasicCurlHttpTests.RetryPolicyDisabled (4 ms)
2026-07-28T14:56:26.8314940Z [ RUN      ] BasicCurlHttpTests.ExponentialBackoffRetry
2026-07-28T14:56:26.8318290Z [       OK ] BasicCurlHttpTests.ExponentialBackoffRetry (20 ms)
2026-07-28T14:56:26.8333550Z [ RUN      ] BasicCurlHttpTests.SendGetRequestSync
2026-07-28T14:56:26.8336190Z [       OK ] BasicCurlHttpTests.SendGetRequestSync (4 ms)
2026-07-28T14:56:26.8352790Z [ RUN      ] BasicCurlHttpTests.HandlerRequestedCloseKeepsServerUsable
2026-07-28T14:56:26.8355920Z Assertion failed: (it != m_sockets.end()), function onThread, file socket_tools.h, line 817.
2026-07-28T14:56:26.8374620Z ================================================================================

HandlerRequestedCloseKeepsServerUsable aborted on macOS with
'Assertion failed: (it != m_sockets.end())' in Reactor::onThread.

kevent() and epoll_wait() both report a batch of events. Handling one of them
can close a connection, which calls removeSocket() and drops that socket from
m_sockets straight away, so a later entry in the same batch can still name it.
The lookup then reaches m_sockets.end(), and reading it->socket dereferences
that iterator. A debug build stops at the assert; without NDEBUG there is
nothing to stop it.

Handler-requested close makes this reachable from a read callback, which is
what the new test does. Both loops now skip an event whose socket is no longer
registered. The Windows path handles one event per wait rather than a batch,
so it does not have the same window.
@thc1006

thc1006 commented Jul 29, 2026

Copy link
Copy Markdown
Member Author

Thanks for catching that. Root cause and fix are pushed.

kevent() and epoll_wait() both hand back a batch of events. Handling one of them can close a connection, and removeSocket() drops that socket from m_sockets straight away, so a later entry in the same batch can still name it. The lookup then reaches m_sockets.end(), and reading it->socket goes through that iterator. The assert stops a debug build there. Without NDEBUG nothing does.

A handler asking to close now happens inside a read callback, which is what makes the new test reach it. Both loops skip an event whose socket is no longer registered. The Windows path takes one event per wait rather than a batch, so I left it alone.

macOS Bazel and macOS Conan were the same test and the same assertion, so this covers both.

@marcalff marcalff changed the title [BUG] Fix use-after-free when an HTTP handler closes the connection [TEST] Fix use-after-free when an HTTP handler closes the connection Jul 29, 2026
@marcalff

Copy link
Copy Markdown
Member

This patch changes test code only, no code change in SDK or exporters.

@dbarker
dbarker merged commit d438d01 into open-telemetry:main Jul 30, 2026
72 checks passed
@thc1006
thc1006 deleted the bugfix/http-server-connection-uaf-4288 branch July 30, 2026 19:19
@thc1006
thc1006 restored the bugfix/http-server-connection-uaf-4288 branch July 30, 2026 19:19
@thc1006
thc1006 deleted the bugfix/http-server-connection-uaf-4288 branch July 30, 2026 19:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] HttpServer accesses a Connection after handleConnectionClosed() erases it

5 participants