diff options
| author | Bitflash <btf01@proton.me> | 2026-07-28 15:25:57 -0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-07-28 15:25:57 -0300 |
| commit | 63764708903cf93476b0fa43580e23ff75ffceba (patch) | |
| tree | 7825001b9037531ee625edd38b9cf177bc0300d0 | |
| parent | 8a613d756c25983588c6ccabf3752aa31a4e7cb2 (diff) | |
rpc: bound and guard the Stratum surface (#19)
Everything an unauthenticated socket can reach in the pool server, in one
pass.
A line that never ends. RecvLine appended to the per-miner buffer and
looked for a newline; a peer that sends bytes and no newline grew that
buffer for as long as it kept writing, before authorizing or subscribing.
Capped at 64 KB, which drops the miner.
No limit on connections. Every accepted socket became a Miner with its
own buffer and nothing bounded the list. Capped at 1000.
Malformed requests crashed the node, not just the pool. ThreadRPCServer
has no exception handler, so anything thrown from line handling reached
the top of the thread and terminated the process. Three ways in, all
valid JSON that parses cleanly:
[1,2,3] -- value() on a non-object throws
{"method": 1} -- value() with the wrong type throws
submit with params [1,2,3] -- get<std::string>() on a number throws
mining.authorize had the subtler shape of the same thing: it tested
p.size() without testing p.is_array(), and indexing a json object with a
number throws while size() happily returns its key count.
Each is now rejected on its own terms, and the call is wrapped so a throw
drops that miner instead of the node.
| -rw-r--r-- | src/rpc.cpp | 80 |
1 files changed, 72 insertions, 8 deletions
diff --git a/src/rpc.cpp b/src/rpc.cpp index 6f81153..9cd0ed6 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp | |||
| @@ -553,6 +553,12 @@ static bool SendLine(btf_socket_t fd, const json& j) | |||
| 553 | return true; | 553 | return true; |
| 554 | } | 554 | } |
| 555 | 555 | ||
| 556 | // Ceilings on what an unauthenticated peer can make the pool operator hold. | ||
| 557 | // Stratum lines are a few hundred bytes and a real pool this size will not see | ||
| 558 | // anything near a thousand miners. | ||
| 559 | static const size_t MAX_STRATUM_LINE = 64 * 1024; | ||
| 560 | static const size_t MAX_POOL_MINERS = 1000; | ||
| 561 | |||
| 556 | // Non-blocking recv: pull whatever bytes are available now into buf, then | 562 | // Non-blocking recv: pull whatever bytes are available now into buf, then |
| 557 | // return one complete line if one is ready. Three outcomes: | 563 | // return one complete line if one is ready. Three outcomes: |
| 558 | // true -- line is filled; buf may still contain more lines | 564 | // true -- line is filled; buf may still contain more lines |
| @@ -585,6 +591,16 @@ static bool RecvLine(btf_socket_t fd, std::string& buf, | |||
| 585 | } | 591 | } |
| 586 | #endif | 592 | #endif |
| 587 | 593 | ||
| 594 | // A peer that never sends a newline would otherwise grow this buffer for | ||
| 595 | // as long as it keeps writing, and it does not have to authorize first -- | ||
| 596 | // the read happens before any of that. Stratum lines are a few hundred | ||
| 597 | // bytes; anything past the cap is not a slow line, it is a flood. | ||
| 598 | if (buf.size() > MAX_STRATUM_LINE) { | ||
| 599 | disconnected = true; | ||
| 600 | buf.clear(); | ||
| 601 | return false; | ||
| 602 | } | ||
| 603 | |||
| 588 | size_t pos = buf.find('\n'); | 604 | size_t pos = buf.find('\n'); |
| 589 | if (pos == std::string::npos) return false; // no complete line yet | 605 | if (pos == std::string::npos) return false; // no complete line yet |
| 590 | 606 | ||
| @@ -668,6 +684,16 @@ static bool HandleLine(Miner* m, const std::string& rawLine, | |||
| 668 | return true; // don't disconnect on bad JSON, just ignore the line | 684 | return true; // don't disconnect on bad JSON, just ignore the line |
| 669 | } | 685 | } |
| 670 | 686 | ||
| 687 | // value() throws when the document is not an object, and again when a key | ||
| 688 | // is present with the wrong type -- "[1,2,3]" and {"method":1} are both | ||
| 689 | // valid JSON that parse cleanly and then blow up on the next two lines. | ||
| 690 | if (!req.is_object() || (req.contains("method") && !req["method"].is_string())) { | ||
| 691 | LogPrint("worker", "[worker] malformed request from %s: %.80s\n", | ||
| 692 | m->address.empty() ? "(unauth)" : m->address.c_str(), | ||
| 693 | rawLine.c_str()); | ||
| 694 | return true; | ||
| 695 | } | ||
| 696 | |||
| 671 | json id = req.value("id", json(nullptr)); | 697 | json id = req.value("id", json(nullptr)); |
| 672 | std::string method = req.value("method", ""); | 698 | std::string method = req.value("method", ""); |
| 673 | 699 | ||
| @@ -691,9 +717,13 @@ static bool HandleLine(Miner* m, const std::string& rawLine, | |||
| 691 | 717 | ||
| 692 | // ------------------------------------------------------------------ | 718 | // ------------------------------------------------------------------ |
| 693 | if (method == "mining.authorize") { | 719 | if (method == "mining.authorize") { |
| 720 | // is_array() matters as much as the element checks: indexing a json | ||
| 721 | // object with a number throws, and size() on an object returns its key | ||
| 722 | // count, so "params":{"a":1} would pass a bare size() test. | ||
| 694 | auto& p = req["params"]; | 723 | auto& p = req["params"]; |
| 695 | m->address = (p.size() > 0 && p[0].is_string()) ? p[0].get<std::string>() : ""; | 724 | bool fArr = p.is_array(); |
| 696 | m->worker = (p.size() > 1 && p[1].is_string()) ? p[1].get<std::string>() : "worker"; | 725 | m->address = (fArr && p.size() > 0 && p[0].is_string()) ? p[0].get<std::string>() : ""; |
| 726 | m->worker = (fArr && p.size() > 1 && p[1].is_string()) ? p[1].get<std::string>() : "worker"; | ||
| 697 | m->lastSeen = GetTime(); | 727 | m->lastSeen = GetTime(); |
| 698 | 728 | ||
| 699 | // Validate address before authorising | 729 | // Validate address before authorising |
| @@ -748,9 +778,13 @@ static bool HandleLine(Miner* m, const std::string& rawLine, | |||
| 748 | } | 778 | } |
| 749 | 779 | ||
| 750 | auto& p = req["params"]; | 780 | auto& p = req["params"]; |
| 751 | if (p.size() < 3) { | 781 | // Check the shape as well as the length: a submit carrying |
| 752 | LogPrint("worker", "[worker->pool] submit bad params from %s (size=%zu)\n", | 782 | // "params":[1,2,3] would otherwise reach get<std::string>() and throw |
| 753 | m->address.c_str(), p.size()); | 783 | // a json::type_error that nothing above catches, taking the pool down |
| 784 | // with it. Authorising costs an attacker nothing but an address. | ||
| 785 | if (!p.is_array() || p.size() < 3 || !p[1].is_string() || !p[2].is_string()) { | ||
| 786 | LogPrint("worker", "[worker->pool] submit bad params from %s\n", | ||
| 787 | m->address.c_str()); | ||
| 754 | reply(false, "bad params"); | 788 | reply(false, "bad params"); |
| 755 | return true; | 789 | return true; |
| 756 | } | 790 | } |
| @@ -1010,6 +1044,16 @@ static void PoolEventLoop() | |||
| 1010 | { | 1044 | { |
| 1011 | std::lock_guard<std::mutex> lk(gReadyMutex); | 1045 | std::lock_guard<std::mutex> lk(gReadyMutex); |
| 1012 | for (btf_socket_t fd : gReadyQueue) { | 1046 | for (btf_socket_t fd : gReadyQueue) { |
| 1047 | // Connecting costs nothing and every accepted socket carries a | ||
| 1048 | // read buffer, so an unbounded miner list is an unbounded | ||
| 1049 | // allocation for whoever runs the pool. | ||
| 1050 | if (gMiners.size() >= MAX_POOL_MINERS) { | ||
| 1051 | LogPrint("worker", "[pool] refusing miner fd=%d --" | ||
| 1052 | " already at %zu connections\n", | ||
| 1053 | (int)fd, gMiners.size()); | ||
| 1054 | sock_close(fd); | ||
| 1055 | continue; | ||
| 1056 | } | ||
| 1013 | gMiners.push_back(new Miner(fd)); | 1057 | gMiners.push_back(new Miner(fd)); |
| 1014 | LogPrint("worker", "[pool] miner fd=%d entered event loop" | 1058 | LogPrint("worker", "[pool] miner fd=%d entered event loop" |
| 1015 | " -- awaiting subscribe/authorize\n", (int)fd); | 1059 | " -- awaiting subscribe/authorize\n", (int)fd); |
| @@ -1154,9 +1198,29 @@ static void PoolEventLoop() | |||
| 1154 | bool disc = false; | 1198 | bool disc = false; |
| 1155 | // Drain all complete lines buffered for this miner. | 1199 | // Drain all complete lines buffered for this miner. |
| 1156 | // HandleLine always returns true; disc signals clean disconnect. | 1200 | // HandleLine always returns true; disc signals clean disconnect. |
| 1157 | while (RecvLine(m->fd, m->readBuf, line, disc)) | 1201 | // ThreadRPCServer has no handler of its own, so anything |
| 1158 | HandleLine(m, line, roundShareCount, | 1202 | // thrown here would reach the top of the thread and |
| 1159 | roundShareTotal, blocksFoundThisSession); | 1203 | // terminate the whole node -- not just the pool. Every |
| 1204 | // line arrives from an unauthenticated socket. | ||
| 1205 | while (RecvLine(m->fd, m->readBuf, line, disc)) { | ||
| 1206 | try { | ||
| 1207 | HandleLine(m, line, roundShareCount, | ||
| 1208 | roundShareTotal, blocksFoundThisSession); | ||
| 1209 | } catch (const std::exception& e) { | ||
| 1210 | LogPrint("worker", "[pool] dropping miner %s --" | ||
| 1211 | " exception handling line: %s\n", | ||
| 1212 | m->address.empty() ? "(unauth)" : m->address.c_str(), | ||
| 1213 | e.what()); | ||
| 1214 | disc = true; | ||
| 1215 | break; | ||
| 1216 | } catch (...) { | ||
| 1217 | LogPrint("worker", "[pool] dropping miner %s --" | ||
| 1218 | " unknown exception handling line\n", | ||
| 1219 | m->address.empty() ? "(unauth)" : m->address.c_str()); | ||
| 1220 | disc = true; | ||
| 1221 | break; | ||
| 1222 | } | ||
| 1223 | } | ||
| 1160 | if (disc) dead.push_back(m); | 1224 | if (disc) dead.push_back(m); |
| 1161 | } | 1225 | } |
| 1162 | for (Miner* m : dead) { | 1226 | for (Miner* m : dead) { |