Files
samiandClaude Sonnet 5 dab071c41a 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
2026-09-10 15:44:28 +04:00

151 lines
5.6 KiB
C++

#include "rpc/ws_frame.hpp"
#include "rpc/ws_handshake.hpp"
#include <string>
#include <vector>
#include "check.hpp"
using namespace velox::daemon::rpc;
namespace {
// Build a *client* frame: FIN/opcode, mask bit set, a fixed 4-byte mask, masked payload.
std::string client_frame(WsOpcode op, std::string_view payload, bool fin = true) {
std::string f;
f.push_back(static_cast<char>((fin ? 0x80 : 0x00) | static_cast<std::uint8_t>(op)));
const std::size_t n = payload.size();
if (n < 126) {
f.push_back(static_cast<char>(0x80 | n));
} else if (n <= 0xFFFF) {
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));
} else {
f.push_back(static_cast<char>(0x80 | 127));
for (int i = 7; i >= 0; --i)
f.push_back(static_cast<char>((static_cast<std::uint64_t>(n) >> (i * 8)) & 0xFF));
}
const char key[4] = {0x12, 0x34, 0x56, 0x78};
f.append(key, 4);
for (std::size_t i = 0; i < n; ++i) f.push_back(static_cast<char>(payload[i] ^ key[i & 3]));
return f;
}
} // namespace
void run() {
// --- one text frame ------------------------------------------------------------
{
WsFrameReader r;
std::vector<WsMessage> m;
CHECK(r.feed(client_frame(WsOpcode::Text, "{\"a\":1}"), m) == WsFrameReader::Status::Ok);
CHECK_EQ(m.size(), 1u);
CHECK(m[0].opcode == WsOpcode::Text);
CHECK_EQ(m[0].payload, std::string("{\"a\":1}"));
}
// --- fragmented: text (fin=0) + continuation (fin=1) --------------------------
{
WsFrameReader r;
std::vector<WsMessage> m;
r.feed(client_frame(WsOpcode::Text, "hel", /*fin=*/false), m);
CHECK_EQ(m.size(), 0u);
r.feed(client_frame(WsOpcode::Continuation, "lo", /*fin=*/true), m);
CHECK_EQ(m.size(), 1u);
CHECK_EQ(m[0].payload, std::string("hello"));
}
// --- byte-at-a-time delivery still reassembles -------------------------------
{
WsFrameReader r;
std::vector<WsMessage> m;
const std::string frame = client_frame(WsOpcode::Text, "streamed");
for (char ch : frame) r.feed(std::string_view(&ch, 1), m);
CHECK_EQ(m.size(), 1u);
CHECK_EQ(m[0].payload, std::string("streamed"));
}
// --- a 200-byte payload exercises the 16-bit length path --------------------
{
WsFrameReader r;
std::vector<WsMessage> m;
const std::string big(200, 'x');
r.feed(client_frame(WsOpcode::Text, big), m);
CHECK_EQ(m.size(), 1u);
CHECK_EQ(m[0].payload.size(), 200u);
}
// --- ping is surfaced so the server can pong -------------------------------
{
WsFrameReader r;
std::vector<WsMessage> m;
r.feed(client_frame(WsOpcode::Ping, "hi"), m);
CHECK_EQ(m.size(), 1u);
CHECK(m[0].opcode == WsOpcode::Ping);
}
// --- an unmasked client frame is a protocol error (RFC 6455 §5.1) ----------
{
WsFrameReader r;
std::vector<WsMessage> m;
std::string bad;
bad.push_back(static_cast<char>(0x81)); // FIN + text
bad.push_back(static_cast<char>(0x03)); // len 3, mask bit clear
bad.append("abc");
CHECK(r.feed(bad, m) == WsFrameReader::Status::ProtocolError);
}
// --- a declared length past the cap is rejected before allocating ----------
{
WsFrameReader r;
std::vector<WsMessage> m;
std::string hdr;
hdr.push_back(static_cast<char>(0x82)); // FIN + binary
hdr.push_back(static_cast<char>(0x80 | 127));
for (int i = 7; i >= 0; --i)
hdr.push_back(static_cast<char>((0x0000000001000000ull >> (i * 8)) & 0xFF)); // 16 MiB
CHECK(r.feed(hdr, m) == WsFrameReader::Status::MessageTooBig);
}
// --- ws_encode: server frames are unmasked, correct length byte ------------
{
const std::string f = ws_encode(WsOpcode::Text, "abc");
CHECK_EQ(static_cast<std::uint8_t>(f[0]), 0x81u);
CHECK_EQ(static_cast<std::uint8_t>(f[1]), 0x03u); // len 3, no mask bit
CHECK_EQ(f.substr(2), std::string("abc"));
}
// --- RFC 6455 §1.3 sample accept value ------------------------------------
CHECK_EQ(ws_accept_key("dGhlIHNhbXBsZSBub25jZQ=="),
std::string("s3pPLMBiTxaQ9kYGzzhZRbK+xOo="));
// --- handshake: a page origin is refused, an extension origin upgrades -----
{
const std::string req_page =
"GET / HTTP/1.1\r\nHost: 127.0.0.1:52000\r\nUpgrade: websocket\r\n"
"Connection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n"
"Sec-WebSocket-Version: 13\r\nOrigin: https://evil.example\r\n\r\n";
const auto r = ws_try_handshake(req_page);
CHECK(r.complete);
CHECK(!r.ok);
CHECK(r.response.find("403") != std::string::npos);
}
{
const std::string req_ext =
"GET / HTTP/1.1\r\nHost: 127.0.0.1:52000\r\nUpgrade: websocket\r\n"
"Connection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n"
"Sec-WebSocket-Version: 13\r\n"
"Origin: moz-extension://11111111-2222-3333-4444-555555555555\r\n\r\n";
const auto r = ws_try_handshake(req_ext);
CHECK(r.complete);
CHECK(r.ok);
CHECK(r.response.find("101") != std::string::npos);
CHECK(r.response.find("Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=") !=
std::string::npos);
CHECK_EQ(r.origin, std::string("moz-extension://11111111-2222-3333-4444-555555555555"));
}
}
TEST_MAIN()