Repository navigation
Fix critical bugs, thread-safety issues, and security exposure in webssh_cpp #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
5538292
8c987e5
4af82f1
7dc1d55
5395a08
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,9 +1,16 @@ | ||||||||||||||||||||||||
| #include "ssh_connection/ssh_connection.h" | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| #include <spdlog/spdlog.h> | ||||||||||||||||||||||||
| #include <spdlog/fmt/fmt.h> | ||||||||||||||||||||||||
| #include <chrono> | ||||||||||||||||||||||||
| #include <cstring> | ||||||||||||||||||||||||
| #include <map> | ||||||||||||||||||||||||
| #include <stdexcept> | ||||||||||||||||||||||||
| #include <string> | ||||||||||||||||||||||||
| #include <thread> | ||||||||||||||||||||||||
| #include <set> | ||||||||||||||||||||||||
| #include <mutex> | ||||||||||||||||||||||||
| #include <vector> | ||||||||||||||||||||||||
| #include <hv/WebSocketServer.h> | ||||||||||||||||||||||||
| #include <hv/HttpServer.h> | ||||||||||||||||||||||||
| #include <hv/EventLoop.h> | ||||||||||||||||||||||||
|
|
@@ -16,6 +23,8 @@ class ssh_context : public std::mutex { | |||||||||||||||||||||||
| std::set<WebSocketChannelPtr> channels_write; | ||||||||||||||||||||||||
| void close() { | ||||||||||||||||||||||||
| std::lock_guard lock(*this); | ||||||||||||||||||||||||
| if (closed_) return; | ||||||||||||||||||||||||
| closed_ = true; | ||||||||||||||||||||||||
| for (auto& channel : channels_read) { | ||||||||||||||||||||||||
| channel->close(); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
@@ -32,11 +41,40 @@ class ssh_context : public std::mutex { | |||||||||||||||||||||||
| close(); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| inline static std::map<ohtoai::ssh::channel_id_t, std::weak_ptr<ssh_context>> ssh_contexts; | ||||||||||||||||||||||||
| inline static std::mutex ssh_contexts_mutex; | ||||||||||||||||||||||||
| private: | ||||||||||||||||||||||||
| bool closed_ = false; | ||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| // Poll interval when no SSH data is available. | ||||||||||||||||||||||||
| // 10ms gives good responsiveness without busy-looping. | ||||||||||||||||||||||||
| static constexpr int kSshPollIntervalMs = 10; | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| int main(int argc, char *argv[]) { | ||||||||||||||||||||||||
| if (argc > 1 && strcmp(argv[1], "-d") == 0 ) { | ||||||||||||||||||||||||
| spdlog::set_level(spdlog::level::debug); | ||||||||||||||||||||||||
| int port = 8080; | ||||||||||||||||||||||||
| std::string host = "0.0.0.0"; | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| for (int i = 1; i < argc; ++i) { | ||||||||||||||||||||||||
| if (strcmp(argv[i], "-d") == 0) { | ||||||||||||||||||||||||
| spdlog::set_level(spdlog::level::debug); | ||||||||||||||||||||||||
| } else if (strcmp(argv[i], "-p") == 0 && i + 1 < argc) { | ||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||
| port = std::stoi(argv[++i]); | ||||||||||||||||||||||||
| if (port <= 0 || port > 65535) throw std::out_of_range("port out of range"); | ||||||||||||||||||||||||
| } catch (const std::exception& e) { | ||||||||||||||||||||||||
| fmt::print(stderr, "Invalid port: {}\n", argv[i]); | ||||||||||||||||||||||||
| return 1; | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| } else if (strcmp(argv[i], "-a") == 0 && i + 1 < argc) { | ||||||||||||||||||||||||
| host = argv[++i]; | ||||||||||||||||||||||||
| } else if (strcmp(argv[i], "-h") == 0 || strcmp(argv[i], "--help") == 0) { | ||||||||||||||||||||||||
| fmt::print("Usage: {} [-d] [-p port] [-a address]\n", argv[0]); | ||||||||||||||||||||||||
| fmt::print(" -d Enable debug logging\n"); | ||||||||||||||||||||||||
| fmt::print(" -p port Listen port (default: 8080)\n"); | ||||||||||||||||||||||||
| fmt::print(" -a address Listen address (default: 0.0.0.0)\n"); | ||||||||||||||||||||||||
| fmt::print(" -h Show this help\n"); | ||||||||||||||||||||||||
| return 0; | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| hv::WebSocketService ws; | ||||||||||||||||||||||||
|
|
@@ -45,28 +83,40 @@ int main(int argc, char *argv[]) { | |||||||||||||||||||||||
| auto channel_id = req->GetParam("id"); | ||||||||||||||||||||||||
| if (channel_id.empty()) { | ||||||||||||||||||||||||
| spdlog::error("channel_id is empty"); | ||||||||||||||||||||||||
| channel->close(); | ||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| // find ssh_context by channel_id | ||||||||||||||||||||||||
| auto it = ssh_context::ssh_contexts.find(channel_id); | ||||||||||||||||||||||||
| if (it != ssh_context::ssh_contexts.end()) { | ||||||||||||||||||||||||
| auto ctx = it->second.lock(); | ||||||||||||||||||||||||
| if (ctx != nullptr) { | ||||||||||||||||||||||||
| ctx->channels_read.emplace(channel); | ||||||||||||||||||||||||
| spdlog::info("[{}] Add channel to existing ssh_context", channel_id); | ||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||
| // Find or create ssh_context for this channel_id | ||||||||||||||||||||||||
| { | ||||||||||||||||||||||||
| std::lock_guard<std::mutex> guard(ssh_context::ssh_contexts_mutex); | ||||||||||||||||||||||||
| auto it = ssh_context::ssh_contexts.find(channel_id); | ||||||||||||||||||||||||
| if (it != ssh_context::ssh_contexts.end()) { | ||||||||||||||||||||||||
| auto ctx = it->second.lock(); | ||||||||||||||||||||||||
| if (ctx != nullptr) { | ||||||||||||||||||||||||
| // Attach as read-only observer to an existing session | ||||||||||||||||||||||||
| std::lock_guard lock(*ctx); | ||||||||||||||||||||||||
| ctx->channels_read.emplace(channel); | ||||||||||||||||||||||||
| channel->setContextPtr(ctx); | ||||||||||||||||||||||||
| spdlog::info("[{}] Attached read-only WebSocket to existing ssh_context", channel_id); | ||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| auto ctx = channel->newContextPtr<ssh_context>(); | ||||||||||||||||||||||||
| ssh_context::ssh_contexts.emplace(channel_id, ctx); | ||||||||||||||||||||||||
| { | ||||||||||||||||||||||||
| std::lock_guard<std::mutex> guard(ssh_context::ssh_contexts_mutex); | ||||||||||||||||||||||||
| ssh_context::ssh_contexts.insert_or_assign(channel_id, ctx); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| ctx->ssh_channel_id = channel_id; | ||||||||||||||||||||||||
| ctx->channels_read.emplace(channel); | ||||||||||||||||||||||||
| ctx->channels_write.emplace(channel); | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| auto ssh_channel = ohtoai::ssh::ssh_pty_connection_manager::get_instance().get_channel(ctx->ssh_channel_id); | ||||||||||||||||||||||||
| if (ssh_channel == nullptr) { | ||||||||||||||||||||||||
| spdlog::error("[{}] ssh_channel is null", channel_id); | ||||||||||||||||||||||||
| channel->close(); | ||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
|
|
@@ -77,23 +127,41 @@ int main(int argc, char *argv[]) { | |||||||||||||||||||||||
| spdlog::error("[{}] ssh_channel is null", ctx->ssh_channel_id); | ||||||||||||||||||||||||
| break; | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| if (ctx->channels_write.empty()) { | ||||||||||||||||||||||||
| spdlog::error("[{}] channels_write is empty", ctx->ssh_channel_id); | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| { | ||||||||||||||||||||||||
| std::lock_guard lock(*ctx); | ||||||||||||||||||||||||
| if (ctx->channels_write.empty()) { | ||||||||||||||||||||||||
| spdlog::info("[{}] All write channels gone, stopping read loop", ctx->ssh_channel_id); | ||||||||||||||||||||||||
| break; | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| long rc = 0; | ||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||
| rc = ssh_channel->read(); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| catch (const std::exception& e) { | ||||||||||||||||||||||||
| spdlog::error("[{}] SSH read failed: {}", ctx->ssh_channel_id, e.what()); | ||||||||||||||||||||||||
| break; | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| if (ssh_channel->read() <= 0) { | ||||||||||||||||||||||||
| std::this_thread::sleep_for(std::chrono::milliseconds(100)); | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| if (rc == 0) { | ||||||||||||||||||||||||
| std::this_thread::sleep_for(std::chrono::milliseconds(kSshPollIntervalMs)); | ||||||||||||||||||||||||
| continue; | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| std::lock_guard lock(*ctx); | ||||||||||||||||||||||||
| for (auto& channel : ctx->channels_read) { | ||||||||||||||||||||||||
| if (!channel->isConnected()) { | ||||||||||||||||||||||||
| ctx->channels_read.erase(channel); | ||||||||||||||||||||||||
| ctx->channels_write.erase(channel); | ||||||||||||||||||||||||
| std::vector<WebSocketChannelPtr> to_remove; | ||||||||||||||||||||||||
| for (auto& ws_channel : ctx->channels_read) { | ||||||||||||||||||||||||
| if (!ws_channel->isConnected()) { | ||||||||||||||||||||||||
| to_remove.push_back(ws_channel); | ||||||||||||||||||||||||
| continue; | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| channel->send(ssh_channel->get_buffer().data, ssh_channel->get_buffer().size); | ||||||||||||||||||||||||
| ws_channel->send(ssh_channel->get_buffer().data, ssh_channel->get_buffer().size); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| for (auto& ws_channel : to_remove) { | ||||||||||||||||||||||||
| ctx->channels_read.erase(ws_channel); | ||||||||||||||||||||||||
| ctx->channels_write.erase(ws_channel); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| ctx->close(); | ||||||||||||||||||||||||
|
|
@@ -106,41 +174,58 @@ int main(int argc, char *argv[]) { | |||||||||||||||||||||||
| spdlog::error("ctx is null"); | ||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| // Read-only channels must not send data | ||||||||||||||||||||||||
| { | ||||||||||||||||||||||||
| std::lock_guard lock(*ctx); | ||||||||||||||||||||||||
| if (ctx->channels_write.find(channel) == ctx->channels_write.end()) { | ||||||||||||||||||||||||
| spdlog::debug("[{}] Ignoring message from read-only channel", ctx->ssh_channel_id); | ||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| auto ssh_channel = ohtoai::ssh::ssh_pty_connection_manager::get_instance().get_channel(ctx->ssh_channel_id); | ||||||||||||||||||||||||
| if (ssh_channel == nullptr) { | ||||||||||||||||||||||||
| spdlog::error("ssh_channel is null"); | ||||||||||||||||||||||||
| ctx->close(); | ||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| hv::Json j = hv::Json::parse(msg); | ||||||||||||||||||||||||
| if (j.contains("resize")) { | ||||||||||||||||||||||||
| int width = j["resize"][0]; | ||||||||||||||||||||||||
| int height = j["resize"][1]; | ||||||||||||||||||||||||
| ssh_channel->resize_pty(width, height); | ||||||||||||||||||||||||
| } else if (j.contains("data")) { | ||||||||||||||||||||||||
| auto data = j["data"].get<std::string>(); | ||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||
| hv::Json j = hv::Json::parse(msg); | ||||||||||||||||||||||||
| if (j.contains("resize")) { | ||||||||||||||||||||||||
| int width = j["resize"][0]; | ||||||||||||||||||||||||
| int height = j["resize"][1]; | ||||||||||||||||||||||||
| ssh_channel->resize_pty(width, height); | ||||||||||||||||||||||||
| } else if (j.contains("data")) { | ||||||||||||||||||||||||
| auto data = j["data"].get<std::string>(); | ||||||||||||||||||||||||
| ssh_channel->write(data); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| catch (const std::exception& e) { | ||||||||||||||||||||||||
| spdlog::error("[{}] Try to write {} bytes, but failed.", ssh_channel->id, data.size()); | ||||||||||||||||||||||||
| spdlog::error("{}", e.what()); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| catch (const std::exception& e) { | ||||||||||||||||||||||||
| spdlog::error("[{}] Failed to handle message: {}", ctx->ssh_channel_id, e.what()); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||
| ws.onclose = [](const WebSocketChannelPtr& channel) { | ||||||||||||||||||||||||
| spdlog::debug("{}", channel->peeraddr()); | ||||||||||||||||||||||||
| auto ctx = channel->getContextPtr<ssh_context>(); | ||||||||||||||||||||||||
| if (ctx == nullptr) { | ||||||||||||||||||||||||
| spdlog::error("ctx is null"); | ||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| auto ssh_channel = ohtoai::ssh::ssh_pty_connection_manager::get_instance().get_channel(ctx->ssh_channel_id); | ||||||||||||||||||||||||
| if (ssh_channel == nullptr) { | ||||||||||||||||||||||||
| spdlog::error("ssh_channel is null"); | ||||||||||||||||||||||||
| ctx->close(); | ||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||
| { | ||||||||||||||||||||||||
| std::lock_guard lock(*ctx); | ||||||||||||||||||||||||
| ctx->channels_read.erase(channel); | ||||||||||||||||||||||||
| ctx->channels_write.erase(channel); | ||||||||||||||||||||||||
| if (!ctx->channels_write.empty()) { | ||||||||||||||||||||||||
| // Other write-capable connections remain; keep SSH alive | ||||||||||||||||||||||||
| channel->deleteContextPtr(); | ||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| ctx->close(); | ||||||||||||||||||||||||
|
Comment on lines
224
to
+225
|
||||||||||||||||||||||||
| } | |
| 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
AI
Apr 1, 2026
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
channelis added toctx->channels_readand returns without associatingctxas the channel's context (nonewContextPtr/shared context set). As a resultonmessage/onclosewill seegetContextPtr<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 sharedssh_contextin handlers) so cleanup and read-only enforcement work reliably.