daemon: rpc/ws_server — loopback WebSocket transport + pairing (build step 1, second half)
The extension's fallback transport (docs/05 §4). veloxd now also listens on 127.0.0.1, first free port in 52000-52016, and writes it to <runtime>/ws.port (0600). - rpc/ws_frame — RFC 6455 frame codec. Incremental; reassembles continuation frames; enforces "client frames MUST be masked" (§5.1); caps a reassembled message at 8 MiB. This is the attacker-adjacent parser, so it has its own test table. - rpc/ws_handshake — HTTP upgrade parse, Sec-WebSocket-Accept (SHA-1 + base64 via libcrypto), and the two non-negotiable checks: an Origin header must be present and must be moz-extension:// (a page cannot pair). Version must be 13. - rpc/ws_server — per-connection Handshake -> Open state machine on the shared EventLoop. Token gate: session.pair mints a token behind the approver + rate limiter; session.hello must present a valid one; every other method is -32002 until authed. Privileged methods are refused -32003 by the generated dispatch(). Ping -> Pong; Close echoed. session.hello major-version mismatch -> -32001. - rpc/pairing — PairingApprover interface + EnvAutoApprover dev stub (approves iff VELOX_PAIR_AUTO=1); PairingRateLimiter (5 failures / 60 s per origin, then 60 s lockout -> -32014, survives reconnect); a four-digit code generator. - store/pairings — the pairings table: create() returns the plaintext token once and stores only its SHA-256; find_active_by_token, touch, revoke, list_active. - util/crypto — sha1 / sha256_hex / base64 / random_token over libcrypto. - store/sqlite — pin the DB file (and -wal/-shm) to 0600. - runtime_dir — resolve_data_dir() for $XDG_DATA_HOME/velox (velox.db). - main.cpp — opens + migrates velox.db, starts both transports; a WS bind failure is logged, not fatal (capture must fail open, the Unix socket still serves the GUI/CLI). Real gap, flagged not hidden: the pairing prompt is EnvAutoApprover for now — a GUI dialog / desktop notification is build step 7. Pairing needs VELOX_PAIR_AUTO=1 until then. Tests (ASan+UBSan and TSan clean): veloxd.ws_frame (codec + handshake vectors incl. the RFC 6455 §1.3 accept sample), veloxd.pairings (token create/find/revoke, hash-not-token, rate-limit window + lockout + per-origin isolation + success reset), veloxd.ws_server (full flow: 101 handshake, -32002 gate, deny-then-approve pairing, hello-with-token, -32003 privileged refusal, real download.list). 27 daemon/cli tests green; full tree green. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Upd9WhG9oppieig5nRDLig
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
// Integration: a real WsServer on a loopback port, a hand-rolled WebSocket client.
|
||||
// Covers the handshake, the pairing flow, the token gate (-32002), and the
|
||||
// privileged-over-WS refusal (-32003).
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <netinet/in.h>
|
||||
#include <sys/socket.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <cstdlib>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include "check.hpp"
|
||||
#include "rpc/dispatcher.hpp"
|
||||
#include "rpc/event_loop.hpp"
|
||||
#include "rpc/pairing.hpp"
|
||||
#include "rpc/runtime_dir.hpp"
|
||||
#include "rpc/ws_frame.hpp"
|
||||
#include "rpc/ws_server.hpp"
|
||||
#include "store/migrations.hpp"
|
||||
#include "store/sqlite.hpp"
|
||||
|
||||
using nlohmann::json;
|
||||
namespace rpc = velox::daemon::rpc;
|
||||
namespace store = velox::daemon::store;
|
||||
|
||||
namespace {
|
||||
|
||||
int dial(int port) {
|
||||
const int fd = ::socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0);
|
||||
sockaddr_in a{};
|
||||
a.sin_family = AF_INET;
|
||||
a.sin_addr.s_addr = ::htonl(INADDR_LOOPBACK);
|
||||
a.sin_port = ::htons(static_cast<std::uint16_t>(port));
|
||||
if (::connect(fd, reinterpret_cast<sockaddr*>(&a), sizeof(a)) != 0) {
|
||||
::close(fd);
|
||||
return -1;
|
||||
}
|
||||
return fd;
|
||||
}
|
||||
|
||||
void write_all(int fd, std::string_view s) {
|
||||
std::size_t off = 0;
|
||||
while (off < s.size()) {
|
||||
const ssize_t n = ::write(fd, s.data() + off, s.size() - off);
|
||||
if (n <= 0) return;
|
||||
off += static_cast<std::size_t>(n);
|
||||
}
|
||||
}
|
||||
|
||||
std::string read_some(int fd) {
|
||||
char buf[8192];
|
||||
const ssize_t n = ::read(fd, buf, sizeof(buf));
|
||||
return n > 0 ? std::string(buf, static_cast<std::size_t>(n)) : std::string{};
|
||||
}
|
||||
|
||||
// A masked client text frame.
|
||||
std::string client_text(std::string_view payload) {
|
||||
std::string f;
|
||||
f.push_back(static_cast<char>(0x81)); // FIN + text
|
||||
const std::size_t n = payload.size();
|
||||
if (n < 126) {
|
||||
f.push_back(static_cast<char>(0x80 | n));
|
||||
} else {
|
||||
f.push_back(static_cast<char>(0x80 | 126));
|
||||
f.push_back(static_cast<char>((n >> 8) & 0xFF));
|
||||
f.push_back(static_cast<char>(n & 0xFF));
|
||||
}
|
||||
const char k[4] = {0x0A, 0x0B, 0x0C, 0x0D};
|
||||
f.append(k, 4);
|
||||
for (std::size_t i = 0; i < n; ++i) f.push_back(static_cast<char>(payload[i] ^ k[i & 3]));
|
||||
return f;
|
||||
}
|
||||
|
||||
// Decode one unmasked server frame from `buf`, consuming it. Returns payload; sets `op`.
|
||||
std::string server_frame(std::string& buf, rpc::WsOpcode& op) {
|
||||
if (buf.size() < 2) return {};
|
||||
op = static_cast<rpc::WsOpcode>(buf[0] & 0x0F);
|
||||
std::size_t len = static_cast<std::uint8_t>(buf[1]) & 0x7F;
|
||||
std::size_t header = 2;
|
||||
if (len == 126) {
|
||||
len = (static_cast<std::size_t>(static_cast<std::uint8_t>(buf[2])) << 8) |
|
||||
static_cast<std::uint8_t>(buf[3]);
|
||||
header = 4;
|
||||
}
|
||||
if (buf.size() < header + len) return {};
|
||||
std::string payload = buf.substr(header, len);
|
||||
buf.erase(0, header + len);
|
||||
return payload;
|
||||
}
|
||||
|
||||
// Send a request frame, wait for one text reply, return its parsed JSON.
|
||||
json rpc_call(int fd, const json& req) {
|
||||
write_all(fd, client_text(req.dump()));
|
||||
std::string buf;
|
||||
for (;;) {
|
||||
buf += read_some(fd);
|
||||
rpc::WsOpcode op{};
|
||||
std::string save = buf;
|
||||
const std::string payload = server_frame(buf, op);
|
||||
if (payload.empty() && buf == save) continue; // need more bytes
|
||||
if (op == rpc::WsOpcode::Text) return json::parse(payload, nullptr, false);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void run() {
|
||||
::unsetenv("VELOX_PAIR_AUTO");
|
||||
|
||||
auto db = store::Db::open(":memory:");
|
||||
CHECK(db.has_value());
|
||||
if (!db) return;
|
||||
CHECK(store::migrate_to_head(*db).has_value());
|
||||
|
||||
char tmpl[] = "/tmp/velox-ws-test-XXXXXX";
|
||||
const char* dir = ::mkdtemp(tmpl);
|
||||
CHECK(dir != nullptr);
|
||||
rpc::RuntimeDir rt{dir ? dir : "/tmp"};
|
||||
|
||||
rpc::EventLoop loop;
|
||||
rpc::VeloxDispatcher dispatcher;
|
||||
rpc::EnvAutoApprover approver;
|
||||
rpc::WsServer server(loop, dispatcher, *db, approver, rt);
|
||||
const auto ec = server.start();
|
||||
CHECK(!ec);
|
||||
if (ec) return;
|
||||
CHECK(server.port() >= rpc::WsServer::kPortLo);
|
||||
CHECK(server.port() <= rpc::WsServer::kPortHi);
|
||||
|
||||
std::thread th([&loop] { loop.run(); });
|
||||
|
||||
const std::string origin = "moz-extension://11111111-2222-3333-4444-555555555555";
|
||||
|
||||
// --- handshake ---------------------------------------------------------------
|
||||
const int fd = dial(server.port());
|
||||
CHECK(fd >= 0);
|
||||
write_all(fd,
|
||||
"GET / HTTP/1.1\r\nHost: 127.0.0.1\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n"
|
||||
"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n"
|
||||
"Origin: " + origin + "\r\n\r\n");
|
||||
std::string hs;
|
||||
while (hs.find("\r\n\r\n") == std::string::npos) hs += read_some(fd);
|
||||
CHECK(hs.find("101 Switching Protocols") != std::string::npos);
|
||||
|
||||
// --- session.hello with no token -> -32002 --------------------------------
|
||||
{
|
||||
const json r = rpc_call(fd, {{"jsonrpc", "2.0"}, {"id", 1}, {"method", "session.hello"},
|
||||
{"params", {{"clientType", "extension"},
|
||||
{"clientName", "Velox for Firefox"},
|
||||
{"protocolVersion",
|
||||
std::string(velox::proto::kProtocolVersion)}}}});
|
||||
CHECK(r.contains("error"));
|
||||
CHECK_EQ(r["error"]["code"].get<int>(), -32002);
|
||||
}
|
||||
|
||||
// --- session.pair with approval off -> not approved ----------------------
|
||||
{
|
||||
const json r = rpc_call(fd, {{"jsonrpc", "2.0"}, {"id", 2}, {"method", "session.pair"},
|
||||
{"params", {{"clientName", "Velox for Firefox"},
|
||||
{"extensionId", "11111111-2222-3333-4444-555555555555"}}}});
|
||||
CHECK(r.contains("error"));
|
||||
}
|
||||
|
||||
// --- approval on -> a token, then hello with it succeeds ----------------
|
||||
::setenv("VELOX_PAIR_AUTO", "1", 1);
|
||||
std::string token;
|
||||
{
|
||||
const json r = rpc_call(fd, {{"jsonrpc", "2.0"}, {"id", 3}, {"method", "session.pair"},
|
||||
{"params", {{"clientName", "Velox for Firefox"},
|
||||
{"extensionId", "11111111-2222-3333-4444-555555555555"}}}});
|
||||
CHECK(r.contains("result"));
|
||||
if (r.contains("result")) {
|
||||
token = r["result"]["token"].get<std::string>();
|
||||
CHECK(token.size() >= 40);
|
||||
}
|
||||
}
|
||||
{
|
||||
const json r = rpc_call(fd, {{"jsonrpc", "2.0"}, {"id", 4}, {"method", "session.hello"},
|
||||
{"params", {{"clientType", "extension"},
|
||||
{"clientName", "Velox for Firefox"},
|
||||
{"protocolVersion",
|
||||
std::string(velox::proto::kProtocolVersion)},
|
||||
{"token", token}}}});
|
||||
CHECK(r.contains("result"));
|
||||
if (r.contains("result"))
|
||||
CHECK_EQ(r["result"]["transport"].get<std::string>(), std::string("ws"));
|
||||
}
|
||||
|
||||
// --- privileged method over WS -> -32003 -------------------------------
|
||||
{
|
||||
const json r = rpc_call(fd, {{"jsonrpc", "2.0"}, {"id", 5}, {"method", "settings.get"},
|
||||
{"params", {{"keys", nullptr}}}});
|
||||
CHECK(r.contains("error"));
|
||||
CHECK_EQ(r["error"]["code"].get<int>(), -32003);
|
||||
}
|
||||
|
||||
// --- a non-privileged method while authed -> a real result -----------
|
||||
{
|
||||
const json r = rpc_call(fd, {{"jsonrpc", "2.0"}, {"id", 6}, {"method", "download.list"},
|
||||
{"params", json::object()}});
|
||||
CHECK(r.contains("result"));
|
||||
if (r.contains("result")) CHECK_EQ(r["result"]["total"].get<int>(), 0);
|
||||
}
|
||||
|
||||
::close(fd);
|
||||
loop.stop();
|
||||
th.join();
|
||||
::unlink(rt.ws_port_path().c_str());
|
||||
}
|
||||
|
||||
TEST_MAIN()
|
||||
Reference in New Issue
Block a user