Repository navigation
Fix critical bugs, thread-safety issues, and security exposure in webssh_cpp - #1
Conversation
- Fix log-after-erase UB in ssh_session::close_channel() - Fix map modification during iteration in ssh_session::disconnect() - Fix ssh_session::sock uninitialized (init to LIBSSH2_INVALID_SOCKET) - Fix libssh2_init thread safety with std::call_once - Fix partial writes in ssh_channel::write() with retry loop - Fix set modification during iteration in async read loop - Fix channels_write.empty() check without holding ctx mutex - Add try/catch around ssh_channel::read() in async loop - Fix ws.onclose: remove channel from read/write sets properly - Fix ws.onclose: close SSH when last write channel disconnects - Add ssh_contexts_mutex for thread-safe static map access - Enforce read-only semantics in ws.onmessage - Wrap all JSON parsing and SSH writes in try/catch - Remove password from debug log (security fix) - Add command-line options: -p port, -a address, -h help - Improve HTTP error responses with proper status codes - Default PTY to xterm-256color when not specified - Add startup log showing listen address and port - Add mini_buffer move semantics (Rule of Five) - Add kSshPollIntervalMs constant with explanation - Reset sock=LIBSSH2_INVALID_SOCKET after closing in disconnect() Agent-Logs-Url: https://github.com/ohto-ai/webssh_cpp/sessions/181dda9e-cb49-417a-943b-0a144be7153c Co-authored-by: ohto-ai <46275725+ohto-ai@users.noreply.github.com>
|
@copilot 修复CI构建问题 |
…da capture, server.host assignment - Use -1 as sock initializer in ssh_connection.h (LIBSSH2_INVALID_SOCKET is only available after including libssh2.h which the header doesn't include) - Move kSshPollIntervalMs to file scope as static constexpr so it is accessible inside the lambda without needing to be captured - Use strncpy to copy host string into server.host char[64] array instead of direct pointer assignment - Add <cstring> include for strncpy Agent-Logs-Url: https://github.com/ohto-ai/webssh_cpp/sessions/af1c7525-36ea-4286-9653-a69c2c1aa849 Co-authored-by: ohto-ai <46275725+ohto-ai@users.noreply.github.com>
Fixed in commit
|
There was a problem hiding this comment.
Pull request overview
This PR hardens the WebSSH server by addressing multiple correctness issues (UB, iterator invalidation, partial I/O handling), improving concurrency safety around shared WebSocket/SSH state, and removing a credential leak from logs. It also adds basic runtime configurability (listen host/port) and improves HTTP error handling.
Changes:
- Fix libssh2 channel/session lifecycle issues (safe deregistration, safe disconnect iteration, and partial-write retry handling).
- Add synchronization and cleanup logic around WebSocket session context tracking; enforce read-only observers.
- Add CLI flags (
-p,-a,-h), improve HTTP status codes, and make PTY selection more robust; makemini_buffermove-only to prevent double-free.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
| src/ssh_connection/ssh_connection.h | Initialize session socket member; supports safer disconnect handling. |
| src/ssh_connection/ssh_connection.cpp | Fix channel/session teardown, retry partial writes, and make libssh2 initialization thread-safe. |
| src/main.cpp | Add CLI configurability, improve concurrency safety in WebSocket handlers, remove password logging, and refine HTTP responses. |
| src/inc/mini_buffer.hpp | Implement move-only semantics to avoid accidental copies/double-free. |
Comments suppressed due to low confidence (1)
src/ssh_connection/ssh_connection.cpp:215
ssh_sessionnow usesstd::call_onceforlibssh2_init(), but~ssh_session()still callslibssh2_exit()when the atomiccounterreaches 0. If a connection attempt throws duringget_channel()(before the session is stored) and that was the only session,libssh2_exit()will run and future sessions will not re-runlibssh2_init()(becausecall_onceis already satisfied), leaving libssh2 in an uninitialized state. Consider either (1) initializing libssh2 once per process and never callinglibssh2_exit()until process shutdown, or (2) replacingcall_oncewith a mutex/refcount scheme that allows re-init after exit.
ohtoai::ssh::detail::ssh_session::ssh_session() {
static std::once_flag libssh2_init_flag;
std::call_once(libssh2_init_flag, []() {
spdlog::debug("libssh2 init");
if (int rc = libssh2_init(0)) {
throw std::runtime_error(fmt::format("Failed to initialize ssh library <{}>", rc));
}
});
++counter;
session = nullptr;
}
ohtoai::ssh::detail::ssh_session::~ssh_session() {
disconnect();
--counter;
if (counter == 0) {
// deinit sshlib
spdlog::debug("libssh2 exit");
libssh2_exit();
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| size_t total_written = 0; | ||
| while (total_written < size) { | ||
| ssize_t rc = libssh2_channel_write(channel, data + total_written, size - total_written); | ||
| if (rc == LIBSSH2_ERROR_EAGAIN) { | ||
| session->wait_socket(); | ||
| continue; | ||
| } | ||
| if (rc < 0) { | ||
| char *error_msg = nullptr; | ||
| libssh2_session_last_error(session->session, &error_msg, nullptr, 0); | ||
| throw std::runtime_error(fmt::format("[{}]({}) <{}> {}", id, __LINE__, rc, error_msg)); | ||
| } | ||
| total_written += static_cast<size_t>(rc); | ||
| } |
There was a problem hiding this comment.
The new partial-write retry loop can spin indefinitely if libssh2_channel_write() returns 0 while size > 0 (no forward progress, and no wait_socket() call). Add an explicit guard for rc == 0 (treat as error/closed channel, or back off via wait_socket() with a retry limit) to avoid a tight infinite loop.
| std::string username; | ||
| LIBSSH2_SESSION *session = nullptr; | ||
| std::map<channel_id_t, ssh_channel_ptr> channels; | ||
| int sock; | ||
| int sock = -1; | ||
| inline static std::atomic_size_t counter = 0; |
There was a problem hiding this comment.
ssh_session::sock is initialized to -1, but the implementation uses LIBSSH2_INVALID_SOCKET for validity checks/assignment. For portability (and to match the PR description), consider initializing sock to LIBSSH2_INVALID_SOCKET as well (which may differ from -1 on some platforms), even if that requires including the appropriate libssh2 socket header/definition in the header or encapsulating the initialization in the .cpp.
| ssh_context::ssh_contexts.emplace(channel_id, ctx); | ||
| { | ||
| std::lock_guard<std::mutex> guard(ssh_context::ssh_contexts_mutex); | ||
| ssh_context::ssh_contexts.emplace(channel_id, ctx); |
There was a problem hiding this comment.
ssh_context::ssh_contexts.emplace(channel_id, ctx) will not update an existing entry. If the map already contains channel_id with an expired weak_ptr (or a previous context), emplace fails and the global map keeps pointing at the stale entry, preventing future connections from attaching to the newly created context. Use an overwrite/cleanup strategy here (e.g., erase existing entry when lock() fails, or use insert-or-assign semantics).
| ssh_context::ssh_contexts.emplace(channel_id, ctx); | |
| ssh_context::ssh_contexts.insert_or_assign(channel_id, ctx); |
| } | ||
| ctx->close(); |
There was a problem hiding this comment.
When the last write-capable WebSocket disconnects, ctx->close() is called but the corresponding entry in the global ssh_context::ssh_contexts map is never removed. This leaves stale keys behind and can cause unbounded growth over time, as well as interfering with future reconnects for the same channel_id. Consider erasing the channel_id entry under ssh_contexts_mutex once the SSH session is being torn down (or periodically cleaning up expired entries).
| } | |
| ctx->close(); | |
| } | |
| // No more write-capable WebSocket channels remain for this SSH context. | |
| // Tear down the SSH session and remove the corresponding entry from the | |
| // global ssh_context::ssh_contexts map to avoid stale entries. | |
| ctx->close(); | |
| { | |
| std::lock_guard<std::mutex> guard(ssh_context::ssh_contexts_mutex); | |
| ssh_context::ssh_contexts.erase(ctx->ssh_channel_id); | |
| } |
| spdlog::error("SSH connection failed: {}", e.what()); | ||
| ctx->setStatus(HTTP_STATUS_FORBIDDEN); | ||
| return ctx->send("ssh_channel is null"); | ||
| return ctx->send(e.what()); |
There was a problem hiding this comment.
The HTTP handler returns e.what() directly to the client on SSH connection failures. Exception messages can include internal details (library errors, hostnames, etc.) and may become an information disclosure vector. Prefer returning a generic client-facing error message (and optionally an error code), while logging the detailed exception server-side.
| return ctx->send(e.what()); | |
| return ctx->send("SSH connection failed"); |
|
@copilot apply changes based on the comments in this thread |
…xts map fixes, error disclosure - ssh_connection.cpp write(): guard rc==0 (channel closed, no forward progress) to prevent infinite spin; throw instead of looping - ssh_session constructor: explicitly set sock=LIBSSH2_INVALID_SOCKET so the correct platform value is used (not the header's -1 fallback) - main.cpp onopen: use insert_or_assign so stale/expired weak_ptr entries in ssh_contexts are replaced on reconnect - main.cpp onclose: erase ssh_contexts entry under mutex when SSH session tears down, preventing unbounded map growth and stale keys - main.cpp HTTP handler: return generic "SSH connection failed" to client; keep detailed exception in server log only (prevents info disclosure) Agent-Logs-Url: https://github.com/ohto-ai/webssh_cpp/sessions/13c3502d-09e9-447e-8e7b-ba91d19c3da5 Co-authored-by: ohto-ai <46275725+ohto-ai@users.noreply.github.com>
Applied all 5 review suggestions in commit
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (1)
src/ssh_connection/ssh_connection.cpp:219
ssh_sessionnow usesstd::call_onceforlibssh2_init(), but the destructor still callslibssh2_exit()whencounterreaches 0. If the last session is destroyed and a newssh_sessionis later created,call_oncewill not run again, so libssh2 will stay exited and subsequent libssh2 calls can fail/UB. Consider either (a) removinglibssh2_exit()and letting process teardown handle it, or (b) replacingcall_oncewith a ref-counted init/exit guard that can re-initialize when the count transitions 0→1 (and deinit on 1→0) under a mutex/atomic CAS.
ohtoai::ssh::detail::ssh_session::ssh_session() {
sock = LIBSSH2_INVALID_SOCKET;
static std::once_flag libssh2_init_flag;
std::call_once(libssh2_init_flag, []() {
spdlog::debug("libssh2 init");
if (int rc = libssh2_init(0)) {
throw std::runtime_error(fmt::format("Failed to initialize ssh library <{}>", rc));
}
});
++counter;
session = nullptr;
}
ohtoai::ssh::detail::ssh_session::~ssh_session() {
disconnect();
--counter;
if (counter == 0) {
// deinit sshlib
spdlog::debug("libssh2 exit");
libssh2_exit();
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if (ctx != nullptr) { | ||
| // Attach as read-only observer to an existing session | ||
| std::lock_guard lock(*ctx); | ||
| ctx->channels_read.emplace(channel); |
There was a problem hiding this comment.
In the "attach as read-only observer" path, the WebSocket channel is added to ctx->channels_read and returns without associating ctx as the channel's context (no newContextPtr/shared context set). As a result onmessage/onclose will see getContextPtr<ssh_context>() == nullptr, so observer connections won't be cleaned up on close and may log "ctx is null"; additionally, disconnected observers won't be removed until SSH output arrives (the cleanup currently only happens in the send loop). Attach the shared context to the observer channel (or store the channel_id in per-channel context and look up the shared ssh_context in handlers) so cleanup and read-only enforcement work reliably.
| ctx->channels_read.emplace(channel); | |
| ctx->channels_read.emplace(channel); | |
| // Associate this WebSocket channel with the existing ssh_context | |
| channel->setContext(ctx); |
| else { | ||
| if (hostname.empty() || username.empty() || password.empty()) { | ||
| ctx->setStatus(HTTP_STATUS_FORBIDDEN); | ||
| return ctx->send("hostname, username, password are required"); | ||
| if (hostname.empty() || username.empty()) { | ||
| ctx->setStatus(HTTP_STATUS_BAD_REQUEST); | ||
| return ctx->send("hostname and username are required"); | ||
| } | ||
| ssh_channel = ohtoai::ssh::ssh_pty_connection_manager::get_instance().get_channel(hostname, port, username, password); | ||
| if (ssh_channel == nullptr) { | ||
| try { | ||
| ssh_channel = ohtoai::ssh::ssh_pty_connection_manager::get_instance().get_channel(hostname, port, username, password); | ||
| if (ssh_channel == nullptr) { |
There was a problem hiding this comment.
The HTTP login handler no longer validates that password is present (hostname/username only), but ssh_session::authenticate() always calls libssh2_userauth_password(..., password.c_str()), so an omitted password will deterministically fail authentication and return a 403. Either keep password as a required field (return 400 when missing), or add an alternative auth path (e.g., publickey) before making password optional.
|
@copilot apply changes based on the comments in this thread |
- main.cpp onopen observer path: call channel->setContext(ctx) so that onclose/onmessage for read-only observers have a valid ssh_context and are correctly cleaned up when the observer WebSocket disconnects - ssh_context::close(): add closed_ flag to make close() idempotent; prevents double-close of SSH channel and observer WebSocket channels when observer onclose fires after primary has already torn down the session - HTTP login handler: add password to required fields check; return 400 with a clear message instead of silently failing authentication Agent-Logs-Url: https://github.com/ohto-ai/webssh_cpp/sessions/87d1d1db-00b6-4689-872a-4945e8d6ae31 Co-authored-by: ohto-ai <46275725+ohto-ai@users.noreply.github.com>
Applied both suggestions in commit
|
|
@copilot 解决CI构建问题 |
setContext(void*) cannot accept a std::shared_ptr<ssh_context>. Use setContextPtr(const std::shared_ptr<void>&) which is the libhv API for storing a shared_ptr as the channel context. Agent-Logs-Url: https://github.com/ohto-ai/webssh_cpp/sessions/6922ed93-bceb-4efa-9cb5-70701bdcf66c Co-authored-by: ohto-ai <46275725+ohto-ai@users.noreply.github.com>
Fixed in commit |
The codebase had multiple undefined behavior bugs, race conditions on shared state, a credential leak in logs, and no runtime configurability.
Critical Bug Fixes
ssh_connection.cppclose_channel(): usediter->second->idafterchannels.erase(iter); now uses theidparameterdisconnect(): closing a channel triggeredclose_channel()which erased from the map being iterated, and caused recursivedisconnect()calls; fixed bystd::move-ing the map before iterating and nullingch->sessionbefore callingclose()write():libssh2_channel_write()may write fewer bytes than requested; now loops until fully written withLIBSSH2_ERROR_EAGAINretrylibssh2_init()TOCTOU race: counter-based init was not atomic; replaced withstd::call_oncesockinssh_session: could call::shutdown()on a garbage fd; fixed with member initializer= LIBSSH2_INVALID_SOCKETmain.cppstd::setwhile range-iterating it; now collects intoto_removeand erases afterchannels_write.empty()without lock: accessed shared set from async task without holding thessh_contextmutexssh_channel::read()throws on SSH error; wrapped intry/catchonclosedidn't clean up sets: dead WebSocket channels accumulated inchannels_read/channels_writeforeveronclosedidn't terminate SSH: SSH session kept alive after last writer disconnectedssh_contextsmap unprotected: static map accessed from concurrent WebSocket callbacks without synchronization; addedssh_contexts_mutexonmessageSecurity Fix
Removed password from debug log line — `****** was leaking credentials into log output.
Enhancements
-p <port>,-a <address>,-h— port was previously hardcoded to 8080; port validated withstd::stoiwith range check400/403/404/500responses instead of generic403for all errorsxterm-256colorwhen client omitstermmini_bufferRule of Five: raw-memory-owning struct was missing copy-delete and move semantics, risking double-free on accidental copy