Skip to content

Fix critical bugs, thread-safety issues, and security exposure in webssh_cpp - #1

Merged
ohto-ai merged 5 commits into
masterfrom
copilot/refactor-code-systematically
Apr 1, 2026
Merged

ohto-ai merged 5 commits into
masterfrom
copilot/refactor-code-systematically

Conversation

Copilot AI commented Mar 31, 2026

Copy link
Copy Markdown
Contributor

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.cpp

  • Log-after-erase UB in close_channel(): used iter->second->id after channels.erase(iter); now uses the id parameter
  • Modification-during-iteration in disconnect(): closing a channel triggered close_channel() which erased from the map being iterated, and caused recursive disconnect() calls; fixed by std::move-ing the map before iterating and nulling ch->session before calling close()
  • Partial write not retried in write(): libssh2_channel_write() may write fewer bytes than requested; now loops until fully written with LIBSSH2_ERROR_EAGAIN retry
  • libssh2_init() TOCTOU race: counter-based init was not atomic; replaced with std::call_once
  • Uninitialized sock in ssh_session: could call ::shutdown() on a garbage fd; fixed with member initializer = LIBSSH2_INVALID_SOCKET

main.cpp

  • Iterator invalidation in async read loop: erased from std::set while range-iterating it; now collects into to_remove and erases after
  • channels_write.empty() without lock: accessed shared set from async task without holding the ssh_context mutex
  • Unhandled exception in async loop: ssh_channel::read() throws on SSH error; wrapped in try/catch
  • onclose didn't clean up sets: dead WebSocket channels accumulated in channels_read/channels_write forever
  • onclose didn't terminate SSH: SSH session kept alive after last writer disconnected
  • ssh_contexts map unprotected: static map accessed from concurrent WebSocket callbacks without synchronization; added ssh_contexts_mutex
  • Read-only enforcement missing: observer channels could write to SSH via onmessage

Security Fix

Removed password from debug log line — `****** was leaking credentials into log output.

Enhancements

  • CLI flags: -p <port>, -a <address>, -h — port was previously hardcoded to 8080; port validated with std::stoi with range check
  • HTTP status codes: proper 400/403/404/500 responses instead of generic 403 for all errors
  • Default PTY: falls back to xterm-256color when client omits term
  • mini_buffer Rule of Five: raw-memory-owning struct was missing copy-delete and move semantics, risking double-free on accidental copy

- 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>
@ohto-ai

ohto-ai commented Mar 31, 2026

Copy link
Copy Markdown
Owner

@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>

Copilot AI commented Mar 31, 2026

Copy link
Copy Markdown
Contributor Author

@copilot 修复CI构建问题

Fixed in commit 8c987e5. Three build errors were addressed:

  1. LIBSSH2_INVALID_SOCKET undeclared in header — ssh_connection.h only has forward declarations and doesn't include libssh2.h, so the macro was unavailable. Changed the sock initializer to -1 (which is what LIBSSH2_INVALID_SOCKET expands to on Linux; the .cpp file still uses the macro properly since it includes libssh2.h).

  2. kSshPollIntervalMs not captured in lambda — The constexpr was a local variable in main() not accessible inside [ctx]-captured lambda. Moved it to file scope as static constexpr.

  3. server.host = host.c_str() type mismatch — hv::WebSocketServer::host is char[64], not char*. Replaced with strncpy (+ added <cstring> include).

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.

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; make mini_buffer move-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_session now uses std::call_once for libssh2_init(), but ~ssh_session() still calls libssh2_exit() when the atomic counter reaches 0. If a connection attempt throws during get_channel() (before the session is stored) and that was the only session, libssh2_exit() will run and future sessions will not re-run libssh2_init() (because call_once is already satisfied), leaving libssh2 in an uninitialized state. Consider either (1) initializing libssh2 once per process and never calling libssh2_exit() until process shutdown, or (2) replacing call_once with 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.

Comment on lines +96 to 109
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);
}

Copilot AI Mar 31, 2026

Copy link

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment on lines 73 to 77
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;

Copilot AI Mar 31, 2026

Copy link

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment thread src/main.cpp Outdated
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);

Copilot AI Mar 31, 2026

Copy link

Choose a reason for hiding this comment

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

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).

Suggested change
ssh_context::ssh_contexts.emplace(channel_id, ctx);
ssh_context::ssh_contexts.insert_or_assign(channel_id, ctx);

Copilot uses AI. Check for mistakes.
Comment thread src/main.cpp
Comment on lines 219 to +220
}
ctx->close();

Copilot AI Mar 31, 2026

Copy link

Choose a reason for hiding this comment

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

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).

Suggested change
}
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);
}

Copilot uses AI. Check for mistakes.
Comment thread src/main.cpp Outdated
spdlog::error("SSH connection failed: {}", e.what());
ctx->setStatus(HTTP_STATUS_FORBIDDEN);
return ctx->send("ssh_channel is null");
return ctx->send(e.what());

Copilot AI Mar 31, 2026

Copy link

Choose a reason for hiding this comment

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

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.

Suggested change
return ctx->send(e.what());
return ctx->send("SSH connection failed");

Copilot uses AI. Check for mistakes.
@ohto-ai

ohto-ai commented Mar 31, 2026

Copy link
Copy Markdown
Owner

@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>

Copilot AI commented Mar 31, 2026

Copy link
Copy Markdown
Contributor Author

@copilot apply changes based on the comments in this thread

Applied all 5 review suggestions in commit 4af82f1:

  1. Write loop rc == 0 guard — added explicit check before rc < 0; throws "channel may be closed" to break the loop instead of spinning.
  2. sock init with LIBSSH2_INVALID_SOCKET — moved initialization into the constructor body in .cpp (where libssh2.h is included) so the platform-correct value is used; the header keeps = -1 as a compile-time fallback with a comment.
  3. insert_or_assign for ssh_contexts — replaces stale/expired weak_ptr entries on reconnect instead of silently failing.
  4. Erase ssh_contexts entry on teardown — erases the map entry under ssh_contexts_mutex when the SSH session closes, preventing unbounded growth and stale keys.
  5. Generic HTTP error response — returns "SSH connection failed" to the client; detailed e.what() is only logged server-side.

@ohto-ai
ohto-ai marked this pull request as ready for review April 1, 2026 02:30
@ohto-ai
ohto-ai requested a review from Copilot April 1, 2026 02:31

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.

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_session now uses std::call_once for libssh2_init(), but the destructor still calls libssh2_exit() when counter reaches 0. If the last session is destroyed and a new ssh_session is later created, call_once will not run again, so libssh2 will stay exited and subsequent libssh2 calls can fail/UB. Consider either (a) removing libssh2_exit() and letting process teardown handle it, or (b) replacing call_once with 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.

Comment thread src/main.cpp
if (ctx != nullptr) {
// Attach as read-only observer to an existing session
std::lock_guard lock(*ctx);
ctx->channels_read.emplace(channel);

Copilot AI Apr 1, 2026

Copy link

Choose a reason for hiding this comment

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

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.

Suggested change
ctx->channels_read.emplace(channel);
ctx->channels_read.emplace(channel);
// Associate this WebSocket channel with the existing ssh_context
channel->setContext(ctx);

Copilot uses AI. Check for mistakes.
Comment thread src/main.cpp
Comment on lines 250 to +257
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) {

Copilot AI Apr 1, 2026

Copy link

Choose a reason for hiding this comment

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

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 uses AI. Check for mistakes.
@ohto-ai

ohto-ai commented Apr 1, 2026

Copy link
Copy Markdown
Owner

@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>

Copilot AI commented Apr 1, 2026

Copy link
Copy Markdown
Contributor Author

@copilot apply changes based on the comments in this thread

Applied both suggestions in commit 7dc1d55:

  1. Observer channel context — added channel->setContext(ctx) after channels_read.emplace(channel) so observer WebSocket channels have a valid ssh_context pointer; onclose and onmessage now work correctly for observers (cleanup and read-only enforcement). Also added a closed_ flag to ssh_context::close() to make it idempotent, preventing double-close of the SSH channel and observer WebSocket channels when an observer's onclose fires after the primary connection has already torn down the session.

  2. Password required — added password.empty() to the required-field check; an omitted password now returns HTTP 400 ("hostname, username and password are required") instead of silently failing authentication.

@ohto-ai

ohto-ai commented Apr 1, 2026

Copy link
Copy Markdown
Owner

@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>

Copilot AI commented Apr 1, 2026

Copy link
Copy Markdown
Contributor Author

@copilot 解决CI构建问题

Fixed in commit 5395a08. The build error was cannot convert 'std::shared_ptr<ssh_context>' to 'void*' on the channel->setContext(ctx) call added in the previous commit. setContext takes a raw void*, but the observer path holds a shared_ptr<ssh_context>. Changed to channel->setContextPtr(ctx) which accepts a std::shared_ptr<void> (the shared_ptr is implicitly upcast), matching the libhv API for smart-pointer context storage.

@ohto-ai
ohto-ai merged commit e80c05a into master Apr 1, 2026
7 of 9 checks passed
@ohto-ai
ohto-ai deleted the copilot/refactor-code-systematically branch April 2, 2026 02:19
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.

3 participants