diff --git a/src/chainstate.cpp b/src/chainstate.cpp index 7926d5fd4..7fef5aba2 100644 --- a/src/chainstate.cpp +++ b/src/chainstate.cpp @@ -625,7 +625,13 @@ bool CChainState::ConnectBlock(const CBlock& block, CValidationState& state, CBl if (!tx.IsCoinBase()) { if (!Consensus::CheckTxInputs(tx, state, view, pindex->nHeight, txfee)) { - return error("%s: Consensus::CheckTxInputs: %s, %s", __func__, tx.GetHashMalFix().ToString(), FormatStateMessage(state)); + // Capture the inner reject code and reason set by CheckTxInputs before + // state.DoS() overwrites them, so the specific reason is preserved. + unsigned int innerCode = state.GetRejectCode(); + std::string innerReason = state.GetRejectReason(); + return state.DoS(100, error("%s: Consensus::CheckTxInputs: %s, %s", __func__, + tx.GetHashMalFix().ToString(), FormatStateMessage(state)), + innerCode, innerReason); } nFees += txfee; if (!MoneyRange(nFees)) { diff --git a/src/coloridentifier.h b/src/coloridentifier.h index 7f8dbd35d..4ac7d60fc 100644 --- a/src/coloridentifier.h +++ b/src/coloridentifier.h @@ -68,13 +68,13 @@ struct ColorIdentifier CSHA256().Write(scriptVector.data(), scriptVector.size()).Finalize(payload); } - ColorIdentifier(const unsigned char* pbegin, const unsigned char* pend):type(UintToToken(*pbegin)) { + ColorIdentifier(const unsigned char* pbegin, const unsigned char* pend):type(UintToToken(*pbegin)), payload{} { CSerActionUnserialize ser_action; CDataStream s((const char*)pbegin, (const char*)pend, SER_NETWORK, INIT_PROTO_VERSION); SerializationOp(s, ser_action); } - ColorIdentifier(const std::vector& in) { + ColorIdentifier(const std::vector& in):type(TokenTypes::NONE), payload{} { CSerActionUnserialize ser_action; CDataStream s(in, SER_NETWORK, INIT_PROTO_VERSION); SerializationOp(s, ser_action); diff --git a/src/httprpc.cpp b/src/httprpc.cpp index e15902db9..d12c997a7 100644 --- a/src/httprpc.cpp +++ b/src/httprpc.cpp @@ -74,6 +74,25 @@ static std::unique_ptr httpRPCTimerInterface; static std::map> g_rpc_whitelist; static bool g_rpc_whitelist_default = false; +/* Per-IP failed-auth backoff. State for one peer address. */ +struct AuthFailState { + int64_t next_allowed_ms{0}; // earliest time (GetTimeMillis) a new attempt is accepted + int consecutive{0}; // consecutive failure count; reset on success +}; + +// Max number of IP entries kept in the backoff table. +// When exceeded, expired entries are pruned; if still over, all are cleared. +static constexpr size_t AUTH_FAIL_CACHE_MAX = 1024; + +static Mutex g_auth_fail_mutex; +static std::map g_failed_auths GUARDED_BY(g_auth_fail_mutex); + +// Backoff for the n-th consecutive failure: 250ms, 500ms, 1s, … capped at 32s. +static int64_t AuthBackoffMs(int n) +{ + return 250LL << std::min(n - 1, 7); +} + static void JSONErrorReply(HTTPRequest* req, const UniValue& objError, const UniValue& id) { // Send error reply from json-rpc error object @@ -175,19 +194,48 @@ static bool HTTPReq_JSONRPC(HTTPRequest* req, const std::string &) } JSONRPCRequest jreq; - jreq.peerAddr = req->GetPeer().ToString(); + CService peer = req->GetPeer(); + jreq.peerAddr = peer.ToString(); if (!RPCAuthorized(authHeader.second, jreq.authUser)) { + const std::string peerIP = peer.ToStringIP(); + const int64_t now = GetTimeMillis(); + LOCK(g_auth_fail_mutex); + // Keep the table bounded: first remove expired entries, then if still full + // evict the single entry whose backoff expires soonest (cheapest to lose). + // Never clear() the entire table — that would let an attacker with 1025 IPs + // reset all throttled peers simultaneously. + if (g_failed_auths.size() >= AUTH_FAIL_CACHE_MAX) { + for (auto it = g_failed_auths.begin(); it != g_failed_auths.end(); ) { + it = (it->second.next_allowed_ms <= now) ? g_failed_auths.erase(it) : ++it; + } + if (g_failed_auths.size() >= AUTH_FAIL_CACHE_MAX) { + auto victim = std::min_element(g_failed_auths.begin(), g_failed_auths.end(), + [](const auto& a, const auto& b) { + return a.second.next_allowed_ms < b.second.next_allowed_ms; + }); + g_failed_auths.erase(victim); + } + } + AuthFailState& state = g_failed_auths[peerIP]; + if (now < state.next_allowed_ms) { + // Within backoff window: reject silently (suppress log spam) + req->WriteHeader("WWW-Authenticate", WWW_AUTH_HEADER_DATA); + req->WriteReply(HTTP_UNAUTHORIZED); + return false; + } + state.consecutive++; + state.next_allowed_ms = now + AuthBackoffMs(state.consecutive); LogPrintf("ThreadRPCServer incorrect password attempt from %s\n", jreq.peerAddr); - - /* Deter brute-forcing - If this results in a DoS the user really - shouldn't have their RPC port exposed. */ - MilliSleep(250); - req->WriteHeader("WWW-Authenticate", WWW_AUTH_HEADER_DATA); req->WriteReply(HTTP_UNAUTHORIZED); return false; } + // Successful auth: clear any backoff for this IP + { + const std::string peerIP = peer.ToStringIP(); + LOCK(g_auth_fail_mutex); + g_failed_auths.erase(peerIP); + } try { // Parse request diff --git a/src/init.cpp b/src/init.cpp index e59a8d78a..e42f6bf8f 100644 --- a/src/init.cpp +++ b/src/init.cpp @@ -225,7 +225,7 @@ void Shutdown() g_connman.reset(); g_txindex.reset(); - if (g_is_mempool_loaded && gArgs.GetArg("-persistmempool", DEFAULT_PERSIST_MEMPOOL)) { + if (g_is_mempool_loaded && gArgs.GetBoolArg("-persistmempool", DEFAULT_PERSIST_MEMPOOL)) { DumpMempool(); } @@ -730,7 +730,7 @@ static void ThreadImport(std::vector vImportFiles, bool fReloadxfield) return; } } // End scope of CImportingNow - if (gArgs.GetArg("-persistmempool", DEFAULT_PERSIST_MEMPOOL)) { + if (gArgs.GetBoolArg("-persistmempool", DEFAULT_PERSIST_MEMPOOL)) { LoadMempool(); } g_is_mempool_loaded = !ShutdownRequested(); @@ -1124,7 +1124,13 @@ bool AppInitParameterInteraction() if ((gArgs.GetChainMode() == TAPYRUS_OP_MODE::PROD) && acceptnonstdtxn) return InitError(strprintf("acceptnonstdtxn is not supported for %s chain", TAPYRUS_MODES::GetChainName(TAPYRUS_OP_MODE::PROD))); #endif - nBytesPerSigOp = gArgs.GetArg("-bytespersigop", nBytesPerSigOp); + { + int64_t bytesPerSigOp = gArgs.GetArg("-bytespersigop", (int64_t)nBytesPerSigOp); + if (bytesPerSigOp < 1 || bytesPerSigOp > (int64_t)std::numeric_limits::max()) { + return InitError(strprintf("-bytespersigop value %d is out of range [1, %u]", bytesPerSigOp, std::numeric_limits::max())); + } + nBytesPerSigOp = (unsigned int)bytesPerSigOp; + } if (!g_wallet_init_interface.ParameterInteraction()) return false; diff --git a/src/net.cpp b/src/net.cpp index 64b2a3126..ab6479d8d 100644 --- a/src/net.cpp +++ b/src/net.cpp @@ -554,7 +554,12 @@ void CConnman::Ban(const CSubNet& subNet, const BanReason &banReason, int64_t ba bantimeoffset = gArgs.GetArg("-bantime", DEFAULT_MISBEHAVING_BANTIME); sinceUnixEpoch = false; } - banEntry.nBanUntil = (sinceUnixEpoch ? 0 : GetTime() )+bantimeoffset; + const int64_t banBase = sinceUnixEpoch ? 0 : GetTime(); + // Clamp to prevent signed int64 overflow, which would silently set a past expiry. + if (bantimeoffset > std::numeric_limits::max() - banBase) { + bantimeoffset = std::numeric_limits::max() - banBase; + } + banEntry.nBanUntil = banBase + bantimeoffset; { LOCK(cs_setBanned); diff --git a/src/net_processing.cpp b/src/net_processing.cpp index ae46cc2f4..15925f207 100644 --- a/src/net_processing.cpp +++ b/src/net_processing.cpp @@ -399,11 +399,11 @@ static bool MarkBlockAsReceived(const uint256& hash, std::optional from // Still erase from_peer's entry below — do not return early and leak it. } bool found = false; - while (rangeInFlight.first != rangeInFlight.second) { - auto itInFlight = rangeInFlight.first->second; + for (auto it = rangeInFlight.first; it != rangeInFlight.second; ) { + auto itInFlight = it->second; auto node_id = itInFlight.first; if (from_peer && *from_peer != node_id) { - rangeInFlight.first++; + ++it; continue; } @@ -421,13 +421,12 @@ static bool MarkBlockAsReceived(const uint256& hash, std::optional from } state->nStallingSince = 0; - rangeInFlight.first = mapBlocksInFlight.erase(rangeInFlight.first); + it = mapBlocksInFlight.erase(it); found = true; - // When a specific peer is given, one entry is expected — return immediately. - // When nullopt, continue to erase all in-flight entries for this hash so - // no other peer's vBlocksInFlight entry is left dangling. - if (from_peer) return true; + if (from_peer) return true; // erase exactly one when targeted + // when from_peer is nullopt, continue the loop to drain all peers } + assert(!found || from_peer || mapBlocksInFlight.count(hash) == 0); return found; } @@ -644,7 +643,7 @@ static void FindNextBlocksToDownload(NodeId nodeid, unsigned int count, std::vec } else if (blockrequested && waitingfor == -1) { // This is the first already-in-flight block. auto iter = mapBlocksInFlight.equal_range(pindex->GetBlockHash()); - waitingfor = iter.second->second.first; + waitingfor = iter.first->second.first; } } } @@ -1994,9 +1993,6 @@ bool static ProcessMessage(CNode* pfrom, const std::string& strCommand, CDataStr std::vector vAddr; vRecv >> vAddr; - // Don't want addr from older versions unless seeding - if (connman->GetAddressCount() > MAX_ADDR_TO_SEND) - return true; if (vAddr.size() > MAX_ADDR_TO_SEND) { LOCK(cs_main); @@ -2633,6 +2629,7 @@ bool static ProcessMessage(CNode* pfrom, const std::string& strCommand, CDataStr } else { // Give up for this peer and wait for other peer(s) MarkBlockAsReceived(pindex->GetBlockHash(), pfrom->GetId()); + return true; } } diff --git a/src/policy/packages.cpp b/src/policy/packages.cpp index 12af00506..91d64f72a 100644 --- a/src/policy/packages.cpp +++ b/src/policy/packages.cpp @@ -129,7 +129,13 @@ bool SubmitPackageToMempool(const Package& package, opt.state.missingInputs = opt.missingInputs.size() > 0; results.emplace(tx->GetHashMalFix(), opt.state); - if (virtualView && opt.state.IsValid()) { + // Only add to the virtual overlay if the tx was TRULY accepted. + // IsValid() returns true even when missingInputs is set (DoAllInputsExist + // deliberately avoids calling state.Invalid() so callers can distinguish + // orphans from consensus failures). Adding a missing-inputs tx to the + // overlay would let its outputs be found by downstream txs, causing them + // to spuriously pass TEST_ONLY validation. + if (virtualView && opt.state.IsValid() && !opt.state.missingInputs) { virtualView->AddVirtualTx(*tx); } } @@ -139,7 +145,7 @@ bool SubmitPackageToMempool(const Package& package, // Relay only after the entire package has been successfully admitted. // Relaying per-tx inside the loop would gossip the admitted prefix of a // partially-failed package, enabling free relay amplification attacks. - if (success && opt.flags != MempoolAcceptanceFlags::TEST_ONLY) { + if (success && opt.flags != MempoolAcceptanceFlags::TEST_ONLY && g_connman) { CConnman& connman = *g_connman; for (auto& tx : package) { RelayTransaction(*tx, &connman); diff --git a/src/primitives/xfield.h b/src/primitives/xfield.h index 9617865ad..0fae99a77 100644 --- a/src/primitives/xfield.h +++ b/src/primitives/xfield.h @@ -87,7 +87,10 @@ class XFieldAggPubKey { } inline bool IsValid() const { - return CPubKey(data.begin(), data.end()).IsFullyValid(); + if (data.size() != CPubKey::COMPRESSED_PUBLIC_KEY_SIZE) return false; + if (data[0] != 0x02 && data[0] != 0x03) return false; + CPubKey k(data.begin(), data.end()); + return k.IsFullyValid() && k.IsCompressed(); } inline std::string ToString() const; @@ -262,7 +265,17 @@ struct CXField { xfieldValue = value; break; } case TAPYRUS_XFIELDTYPES::NONE: + // No payload bytes for NONE. Any bytes that a peer appended + // after the type byte are NOT consumed here — they will be + // read by the next field (proof). CheckBlockHeader catches + // this by requiring proof to be exactly SCHNORR_SIGNATURE_SIZE + // bytes before attempting verification. break; + default: + // Unknown xfield type: the consistency check below will throw + // BadXFieldException, but an explicit default makes the intent + // clear and prevents silent fallthrough for future enum values. + throw BadXFieldException(xfieldType, xfieldValue); } if(GetXFieldTypeFrom(xfieldValue) != xfieldType) { throw BadXFieldException(xfieldType, xfieldValue); diff --git a/src/rest.cpp b/src/rest.cpp index ce5a76246..f881b0403 100644 --- a/src/rest.cpp +++ b/src/rest.cpp @@ -451,6 +451,12 @@ static bool rest_getutxos(HTTPRequest* req, const std::string& strURIPart) switch (rf) { case RetFormat::HEX: { + // Each binary byte is encoded as 2 hex characters; reject before ParseHex + const size_t maxHexBody = 2 * (sizeof(bool) + + GetSizeOfCompactSize(MAX_GETUTXOS_OUTPOINTS) + + MAX_GETUTXOS_OUTPOINTS * sizeof(COutPoint) + 16); + if (strRequestMutable.size() > maxHexBody) + return RESTERR(req, HTTP_BAD_REQUEST, "Request body too large"); // convert hex to bin, continue then with bin part std::vector strRequestV = ParseHex(strRequestMutable); strRequestMutable.assign(strRequestV.begin(), strRequestV.end()); @@ -461,6 +467,12 @@ static bool rest_getutxos(HTTPRequest* req, const std::string& strURIPart) //deserialize only if user sent a request if (strRequestMutable.size() > 0) { + const size_t maxBinBody = sizeof(bool) + + GetSizeOfCompactSize(MAX_GETUTXOS_OUTPOINTS) + + MAX_GETUTXOS_OUTPOINTS * sizeof(COutPoint) + 16; + if (strRequestMutable.size() > maxBinBody) + return RESTERR(req, HTTP_BAD_REQUEST, "Request body too large"); + if (fInputParsed) //don't allow sending input over URI and HTTP RAW DATA return RESTERR(req, HTTP_BAD_REQUEST, "Combination of URI scheme inputs and raw post data is not allowed"); @@ -496,6 +508,10 @@ static bool rest_getutxos(HTTPRequest* req, const std::string& strURIPart) std::string bitmapStringRepresentation; std::vector hits; bitmap.resize((vOutPoints.size() + 7) / 8); + // Snapshot height and tip hash under the same cs_main lock used for coin lookup + // so the returned (height, tipHash, utxos) triple is internally consistent. + int chainHeight = 0; + uint256 chainTip; { auto process_utxos = [&vOutPoints, &outs, &hits](const CCoinsView& view, const CTxMemPool& mempool) { for (const COutPoint& vOutPoint : vOutPoints) { @@ -512,9 +528,13 @@ static bool rest_getutxos(HTTPRequest* req, const std::string& strURIPart) CCoinsViewCache& viewChain = *pcoinsTip; CCoinsViewMemPool viewMempool(&viewChain, mempool); process_utxos(viewMempool, mempool); + chainHeight = chainActive.Height(); + chainTip = chainActive.Tip()->GetBlockHash(); } else { LOCK(cs_main); // no need to lock mempool! process_utxos(*pcoinsTip, CTxMemPool()); + chainHeight = chainActive.Height(); + chainTip = chainActive.Tip()->GetBlockHash(); } for (size_t i = 0; i < hits.size(); ++i) { @@ -529,7 +549,7 @@ static bool rest_getutxos(HTTPRequest* req, const std::string& strURIPart) // serialize data // use exact same output as mentioned in Bip64 CDataStream ssGetUTXOResponse(SER_NETWORK, PROTOCOL_VERSION); - ssGetUTXOResponse << chainActive.Height() << chainActive.Tip()->GetBlockHash() << bitmap << outs; + ssGetUTXOResponse << chainHeight << chainTip << bitmap << outs; std::string ssGetUTXOResponseString = ssGetUTXOResponse.str(); req->WriteHeader("Content-Type", "application/octet-stream"); @@ -539,7 +559,7 @@ static bool rest_getutxos(HTTPRequest* req, const std::string& strURIPart) case RetFormat::HEX: { CDataStream ssGetUTXOResponse(SER_NETWORK, PROTOCOL_VERSION); - ssGetUTXOResponse << chainActive.Height() << chainActive.Tip()->GetBlockHash() << bitmap << outs; + ssGetUTXOResponse << chainHeight << chainTip << bitmap << outs; std::string strHex = HexStr(ssGetUTXOResponse.begin(), ssGetUTXOResponse.end()) + "\n"; req->WriteHeader("Content-Type", "text/plain"); @@ -552,8 +572,8 @@ static bool rest_getutxos(HTTPRequest* req, const std::string& strURIPart) // pack in some essentials // use more or less the same output as mentioned in Bip64 - objGetUTXOResponse.pushKV("chainHeight", chainActive.Height()); - objGetUTXOResponse.pushKV("chaintipHash", chainActive.Tip()->GetBlockHash().GetHex()); + objGetUTXOResponse.pushKV("chainHeight", chainHeight); + objGetUTXOResponse.pushKV("chaintipHash", chainTip.GetHex()); objGetUTXOResponse.pushKV("bitmap", bitmapStringRepresentation); UniValue utxos(UniValue::VARR); diff --git a/src/rpc/blockchain.cpp b/src/rpc/blockchain.cpp index b9275f51b..d17c7a10d 100644 --- a/src/rpc/blockchain.cpp +++ b/src/rpc/blockchain.cpp @@ -179,6 +179,10 @@ void RPCNotifyBlockChange(bool ibd, const CBlockIndex * pindex) cond_blockchange.notify_all(); } +// Maximum wait for the wait* RPCs when caller passes timeout=0. +// Prevents indefinite thread-pool exhaustion via concurrent wait calls. +static constexpr int64_t MAX_WAIT_FOR_BLOCK_MS = 10 * 60 * 1000; // 10 minutes + static UniValue waitfornewblock(const JSONRPCRequest& request) { if (request.fHelp || request.params.size() > 1) @@ -201,14 +205,12 @@ static UniValue waitfornewblock(const JSONRPCRequest& request) if (!request.params[0].isNull()) timeout = request.params[0].get_int(); + const int64_t effective_timeout = timeout > 0 ? timeout : MAX_WAIT_FOR_BLOCK_MS; CUpdatedBlock block; { std::unique_lock lock(cs_blockchange); block = latestblock; - if(timeout) - cond_blockchange.wait_for(lock, std::chrono::milliseconds(timeout), [&block]{return latestblock.height != block.height || latestblock.hash != block.hash || !IsRPCRunning(); }); - else - cond_blockchange.wait(lock, [&block]{return latestblock.height != block.height || latestblock.hash != block.hash || !IsRPCRunning(); }); + cond_blockchange.wait_for(lock, std::chrono::milliseconds(effective_timeout), [&block]{return latestblock.height != block.height || latestblock.hash != block.hash || !IsRPCRunning(); }); block = latestblock; } UniValue ret(UniValue::VOBJ); @@ -243,13 +245,11 @@ static UniValue waitforblock(const JSONRPCRequest& request) if (!request.params[1].isNull()) timeout = request.params[1].get_int(); + const int64_t effective_timeout = timeout > 0 ? timeout : MAX_WAIT_FOR_BLOCK_MS; CUpdatedBlock block; { std::unique_lock lock(cs_blockchange); - if(timeout) - cond_blockchange.wait_for(lock, std::chrono::milliseconds(timeout), [&hash]{return latestblock.hash == hash || !IsRPCRunning();}); - else - cond_blockchange.wait(lock, [&hash]{return latestblock.hash == hash || !IsRPCRunning(); }); + cond_blockchange.wait_for(lock, std::chrono::milliseconds(effective_timeout), [&hash]{return latestblock.hash == hash || !IsRPCRunning();}); block = latestblock; } @@ -286,13 +286,11 @@ static UniValue waitforblockheight(const JSONRPCRequest& request) if (!request.params[1].isNull()) timeout = request.params[1].get_int(); + const int64_t effective_timeout = timeout > 0 ? timeout : MAX_WAIT_FOR_BLOCK_MS; CUpdatedBlock block; { std::unique_lock lock(cs_blockchange); - if(timeout) - cond_blockchange.wait_for(lock, std::chrono::milliseconds(timeout), [&height]{return latestblock.height >= height || !IsRPCRunning();}); - else - cond_blockchange.wait(lock, [&height]{return latestblock.height >= height || !IsRPCRunning(); }); + cond_blockchange.wait_for(lock, std::chrono::milliseconds(effective_timeout), [&height]{return latestblock.height >= height || !IsRPCRunning();}); block = latestblock; } UniValue ret(UniValue::VOBJ); @@ -2251,11 +2249,39 @@ static UniValue dumptxoutset(const JSONRPCRequest& request) "txoutset_hash - the hash of the UTXO set contents\n" "nchaintx - the number of transactions in the chain up to and including the base block\n"); - std::string path_name = request.params[0].get_str(); - const fs::path path = GetDataDir()/ path_name; + const std::string path_name = request.params[0].get_str(); + // Reject absolute paths and any component that would traverse above datadir. + { + const fs::path rel(path_name); + if (rel.is_absolute()) { + throw JSONRPCError(RPC_INVALID_PARAMETER, "Path must be relative"); + } + for (const auto& component : rel) { + if (component == "..") { + throw JSONRPCError(RPC_INVALID_PARAMETER, "Path must not contain '..'"); + } + } + } + const fs::path path = GetDataDir() / path_name; + // Resolve symlinks in the parent directory and verify the result stays + // inside datadir — prevents traversal via a symlink that points elsewhere. + { + const fs::path parent = path.parent_path(); + if (fs::exists(parent)) { + const fs::path canonical_parent = fs::canonical(parent); + const fs::path canonical_datadir = fs::canonical(GetDataDir()); + // fs::relative returns ".." components when canonical_parent is outside + // canonical_datadir, so any result whose first component is ".." means + // the path escapes the data directory. + const fs::path rel = fs::relative(canonical_parent, canonical_datadir); + if (!rel.empty() && *rel.begin() == "..") { + throw JSONRPCError(RPC_INVALID_PARAMETER, "Path resolves outside the data directory"); + } + } + } // Write to a temporary path and then move into `path` on completion // to avoid confusion due to an interruption. - const fs::path temppath = GetDataDir() / path_name.append(".incomplete"); + const fs::path temppath = fs::path(path.string() + ".incomplete"); if (fs::exists(path)) { throw JSONRPCError( diff --git a/src/rpc/rawtransaction.cpp b/src/rpc/rawtransaction.cpp index 4fde300dc..c7fc96c62 100644 --- a/src/rpc/rawtransaction.cpp +++ b/src/rpc/rawtransaction.cpp @@ -566,12 +566,10 @@ static UniValue decoderawtransaction(const JSONRPCRequest& request) + HelpExampleRpc("decoderawtransaction", "\"hexstring\"") ); - LOCK(cs_main); RPCTypeCheck(request.params, {UniValue::VSTR, UniValue::VBOOL}); CMutableTransaction mtx; - if (!DecodeHexTx(mtx, request.params[0].get_str())) { throw JSONRPCError(RPC_DESERIALIZATION_ERROR, "TX decode failed"); } @@ -1472,6 +1470,10 @@ UniValue combinepsbt(const JSONRPCRequest& request) psbtxs.push_back(psbtx); } + if (psbtxs.empty()) { + throw JSONRPCError(RPC_INVALID_PARAMETER, "Parameter 'txs' cannot be empty"); + } + PartiallySignedTransaction merged_psbt(psbtxs[0]); // Copy the first one // Merge @@ -1479,7 +1481,11 @@ UniValue combinepsbt(const JSONRPCRequest& request) if (*it != merged_psbt) { throw JSONRPCError(RPC_INVALID_PARAMETER, "PSBTs do not refer to the same transactions."); } - merged_psbt.Merge(*it); + try { + merged_psbt.Merge(*it); + } catch (const std::invalid_argument& e) { + throw JSONRPCError(RPC_INVALID_PARAMETER, strprintf("Cannot combine PSBTs: %s", e.what())); + } } if (!merged_psbt.IsSane()) { throw JSONRPCError(RPC_INVALID_PARAMETER, "Merged PSBT is inconsistent"); @@ -1649,8 +1655,9 @@ UniValue converttopsbt(const JSONRPCRequest& request) } // Remove all scriptSigs from inputs + bool permitsigdata = !request.params[1].isNull() && request.params[1].get_bool(); for (CTxIn& input : tx.vin) { - if ((!input.scriptSig.empty()) && (request.params[1].isNull() || (!request.params[1].isNull() && request.params[1].get_bool()))) { + if (!input.scriptSig.empty() && !permitsigdata) { throw JSONRPCError(RPC_DESERIALIZATION_ERROR, "Inputs must not have scriptSigs"); } input.scriptSig.clear(); diff --git a/src/script/sign.cpp b/src/script/sign.cpp index 8a23d59ad..c3a8fb722 100644 --- a/src/script/sign.cpp +++ b/src/script/sign.cpp @@ -460,8 +460,12 @@ void PartiallySignedTransaction::Merge(const PartiallySignedTransaction& psbt) bool PartiallySignedTransaction::IsSane() const { - for (PSBTInput input : inputs) { - if (!input.IsSane()) return false; + for (unsigned int i = 0; i < inputs.size(); ++i) { + if (!inputs[i].IsSane()) return false; + if (inputs[i].non_witness_utxo && tx && i < tx->vin.size() && + tx->vin[i].prevout.n >= inputs[i].non_witness_utxo->vout.size()) { + return false; + } } return true; } @@ -519,6 +523,13 @@ void PSBTInput::Merge(const PSBTInput& input) if (redeem_script.empty() && !input.redeem_script.empty()) redeem_script = input.redeem_script; if (final_script_sig.empty() && !input.final_script_sig.empty()) final_script_sig = input.final_script_sig; + + // Both PSBTs have sighash_type set: they must agree. Silent discard would + // allow a combined PSBT to be signed with the wrong sighash type. + if (sighash_type > 0 && input.sighash_type > 0 && sighash_type != input.sighash_type) { + throw std::invalid_argument("PSBT sighash_type mismatch"); + } + if (sighash_type == 0 && input.sighash_type > 0) sighash_type = input.sighash_type; } bool PSBTInput::IsSane() const diff --git a/src/script/sign.h b/src/script/sign.h index c5ac10b2d..6e6d33b97 100644 --- a/src/script/sign.h +++ b/src/script/sign.h @@ -267,6 +267,7 @@ struct PSBTInput template inline void Unserialize(Stream& s) { // Read loop + bool has_sighash_type = false; while(!s.empty()) { // Read std::vector key; @@ -317,12 +318,13 @@ struct PSBTInput break; } case PSBT_IN_SIGHASH: - if (sighash_type > 0) { + if (has_sighash_type) { throw std::ios_base::failure("Duplicate Key, input sighash type already provided"); } else if (key.size() != 1) { throw std::ios_base::failure("Sighash type key is more than one byte type"); } UnserializeFromVector(s, sighash_type); + has_sighash_type = true; break; case PSBT_IN_REDEEMSCRIPT: { @@ -587,6 +589,9 @@ struct PartiallySignedTransaction if (input.non_witness_utxo && input.non_witness_utxo->GetHashMalFix() != tx->vin[i].prevout.hashMalFix) { throw std::ios_base::failure("Non-witness UTXO does not match outpoint hash"); } + if (input.non_witness_utxo && tx->vin[i].prevout.n >= input.non_witness_utxo->vout.size()) { + throw std::ios_base::failure("Non-witness UTXO vout index out of range"); + } ++i; } // Make sure that the number of inputs matches the number of inputs in the transaction diff --git a/src/test/CMakeLists.txt b/src/test/CMakeLists.txt index f812c71dd..e02e83545 100644 --- a/src/test/CMakeLists.txt +++ b/src/test/CMakeLists.txt @@ -118,6 +118,7 @@ add_executable(test_tapyrus validation_block_tests.cpp xfieldhistory_tests.cpp xfield_tests.cpp + zmq_tests.cpp # Tests generated from JSON ${JSON_HEADERS} ) diff --git a/src/test/txpackage_tests.cpp b/src/test/txpackage_tests.cpp index bbc6063a1..2be1029ec 100644 --- a/src/test/txpackage_tests.cpp +++ b/src/test/txpackage_tests.cpp @@ -740,4 +740,47 @@ BOOST_FIXTURE_TEST_CASE(package_submission_tests, PackageTestSetup) } +// Verify that a chained tx whose parent has missing-inputs does NOT get its +// outputs added to the virtual overlay. Before the fix, IsValid() returned +// true even for missing-inputs rejections (DoAllInputsExist avoids calling +// state.Invalid()), so the parent's outputs leaked into the overlay and the +// child was spuriously reported as "allowed". +BOOST_FIXTURE_TEST_CASE(testmempoolaccept_missing_inputs_no_overlay_leak, PackageTestSetup) +{ + PackageValidationState validationState; + CValidationState state; + CTxMempoolAcceptanceOptions opt; + opt.context = ValidationContext::PACKAGE; + opt.flags = MempoolAcceptanceFlags::TEST_ONLY; + + // tx_orphan: spends a non-existent outpoint — will get missing-inputs. + COutPoint nonexistent(InsecureRand256(), 0); + CScript op_true_eq = CScript() << OP_TRUE << OP_EQUAL; + auto mtx_orphan = CreateValidTransaction(nonexistent, CAmount(49 * COIN), op_true_eq); + + // tx_child: spends tx_orphan's output 0. + COutPoint spend_orphan(mtx_orphan.GetHashMalFix(), 0); + CScript op_true_eq2 = CScript() << OP_TRUE << OP_EQUAL; + auto mtx_child = CreateValidTransaction(spend_orphan, CAmount(44 * COIN), op_true_eq2); + mtx_child.vin[0].scriptSig = CScript() << OP_TRUE; + for (int i = 0; i < 4; ++i) + mtx_child.vout.push_back(CTxOut(CAmount(1 * COIN), op_true_eq2)); + + Package package{MakeTransactionRef(mtx_orphan), MakeTransactionRef(mtx_child)}; + auto result = SubmitPackageToMempool(package, state, validationState, opt); + + // The package must be rejected. + BOOST_CHECK(!result); + + // tx_orphan: missing-inputs (state valid, but missingInputs flag set). + BOOST_CHECK(validationState[mtx_orphan.GetHashMalFix()].IsValid()); + BOOST_CHECK(validationState[mtx_orphan.GetHashMalFix()].missingInputs); + + // tx_child: must also get missing-inputs, NOT "allowed". + // Before the fix, tx_orphan's outputs leaked into the virtual overlay and + // tx_child would pass validation and be reported as allowed. + BOOST_CHECK(validationState[mtx_child.GetHashMalFix()].IsValid()); + BOOST_CHECK(validationState[mtx_child.GetHashMalFix()].missingInputs); +} + BOOST_AUTO_TEST_SUITE_END() diff --git a/src/test/xfield_tests.cpp b/src/test/xfield_tests.cpp index 93ec3c9bb..22a1bac81 100644 --- a/src/test/xfield_tests.cpp +++ b/src/test/xfield_tests.cpp @@ -119,5 +119,71 @@ BOOST_AUTO_TEST_CASE(CXField_unserialize) } +// XFieldAggPubKey::IsValid() must accept only 33-byte compressed keys (0x02/0x03 prefix). +// Uncompressed (0x04, 65 bytes) and hybrid (0x06/0x07, 65 bytes) encodings represent the +// same secp256k1 points but must be rejected to prevent xfieldHistory serialization aliasing +// and block-production halts caused by CPubKey::operator== being bytewise. +BOOST_AUTO_TEST_CASE(XFieldAggPubKey_IsValid_compressed_only) +{ + // Valid: compressed 0x02 prefix + BOOST_CHECK(XFieldAggPubKey(ParseHex(ValidPubKeyStrings[1])).IsValid()); // 02ce7e... + // Valid: compressed 0x03 prefix + BOOST_CHECK(XFieldAggPubKey(ParseHex(ValidPubKeyStrings[0])).IsValid()); // 03af80... + + // Invalid: uncompressed encoding (65 bytes, 0x04 prefix) — same EC point, different bytes + std::vector uncompressed = ParseHex(UncompressedPubKeyString); + BOOST_CHECK_EQUAL(uncompressed.size(), 65U); + BOOST_CHECK(!XFieldAggPubKey(uncompressed).IsValid()); + + // Invalid: hybrid-even encoding (65 bytes, 0x06 prefix) + std::vector hybridEven = uncompressed; + hybridEven[0] = 0x06; + BOOST_CHECK(!XFieldAggPubKey(hybridEven).IsValid()); + + // Invalid: hybrid-odd encoding (65 bytes, 0x07 prefix) + std::vector hybridOdd = uncompressed; + hybridOdd[0] = 0x07; + BOOST_CHECK(!XFieldAggPubKey(hybridOdd).IsValid()); + + // Invalid: empty data + BOOST_CHECK(!XFieldAggPubKey().IsValid()); + + // Invalid: 32 bytes (one byte short — raw scalar, no prefix) + std::vector key0 = ParseHex(ValidPubKeyStrings[0]); + std::vector tooShort(key0.begin() + 1, key0.end()); + BOOST_CHECK_EQUAL(tooShort.size(), 32U); + BOOST_CHECK(!XFieldAggPubKey(tooShort).IsValid()); + + // Invalid: 34 bytes (one byte too long) + std::vector tooLong = key0; + tooLong.push_back(0x00); + BOOST_CHECK_EQUAL(tooLong.size(), 34U); + BOOST_CHECK(!XFieldAggPubKey(tooLong).IsValid()); + + // Invalid: 33 bytes but 0x04 prefix (not a legal 65-byte uncompressed form) + std::vector badPrefix = ParseHex(ValidPubKeyStrings[0]); + badPrefix[0] = 0x04; + BOOST_CHECK(!XFieldAggPubKey(badPrefix).IsValid()); + + // Invalid: 33 bytes but 0x00 prefix + std::vector zeroPrefix(33, 0x00); + BOOST_CHECK(!XFieldAggPubKey(zeroPrefix).IsValid()); + + // Invalid: 33 bytes with 0x02 prefix but payload is all-zeros (not on curve) + std::vector badPoint(33, 0x00); + badPoint[0] = 0x02; + BOOST_CHECK(!XFieldAggPubKey(badPoint).IsValid()); + + // Full chain: CXField::IsValid() → XFieldValidityVisitor → XFieldAggPubKey::IsValid() + // Uncompressed key in a block's xfield must be rejected at CheckBlockHeader. + XFieldAggPubKey uncompressedXField{uncompressed}; + CXField xfieldUncompressed{XFieldData(uncompressedXField)}; + BOOST_CHECK(!xfieldUncompressed.IsValid()); + + XFieldAggPubKey compressedXField{ParseHex(ValidPubKeyStrings[0])}; + CXField xfieldCompressed{XFieldData(compressedXField)}; + BOOST_CHECK(xfieldCompressed.IsValid()); +} + BOOST_AUTO_TEST_SUITE_END() diff --git a/src/test/zmq_tests.cpp b/src/test/zmq_tests.cpp new file mode 100644 index 000000000..14a702182 --- /dev/null +++ b/src/test/zmq_tests.cpp @@ -0,0 +1,108 @@ +// Copyright (c) 2019 Chaintope Inc. +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include +#include + +#if ENABLE_ZMQ + +#include + +BOOST_AUTO_TEST_SUITE(zmq_tests) + +// Shutdown() must be a no-op when psocket is nullptr (notifier never initialized). +// Previously the assert(psocket) would fire → SIGABRT in Release builds. +BOOST_AUTO_TEST_CASE(shutdown_without_initialize_noop) +{ + CZMQPublishHashBlockNotifier notifier; + BOOST_CHECK_NO_THROW(notifier.Shutdown()); + // Idempotent: second call also safe after psocket stays nullptr + BOOST_CHECK_NO_THROW(notifier.Shutdown()); +} + +// SetType/SetAddress/GetType/GetAddress must round-trip correctly. +BOOST_AUTO_TEST_CASE(notifier_accessors) +{ + CZMQPublishHashBlockNotifier notifier; + notifier.SetType("pubhashblock"); + notifier.SetAddress("tcp://127.0.0.1:28332"); + BOOST_CHECK_EQUAL(notifier.GetType(), "pubhashblock"); + BOOST_CHECK_EQUAL(notifier.GetAddress(), "tcp://127.0.0.1:28332"); +} + +// Initialize with a null context returns false and leaves psocket null, +// so a subsequent Shutdown() is still safe. +BOOST_AUTO_TEST_CASE(initialize_null_context_fails_gracefully) +{ + CZMQPublishHashBlockNotifier notifier; + notifier.SetType("pubhashblock"); + notifier.SetAddress("inproc://tapyrus_zmq_test_null_ctx"); + BOOST_CHECK(!notifier.Initialize(nullptr)); + BOOST_CHECK_NO_THROW(notifier.Shutdown()); +} + +// Happy path: Initialize with a valid context and inproc address, then Shutdown. +BOOST_AUTO_TEST_CASE(initialize_and_shutdown) +{ + void* ctx = zmq_ctx_new(); + BOOST_REQUIRE(ctx != nullptr); + + CZMQPublishHashBlockNotifier notifier; + notifier.SetType("pubhashblock"); + notifier.SetAddress("inproc://tapyrus_zmq_test_init"); + + BOOST_CHECK(notifier.Initialize(ctx)); + BOOST_CHECK_NO_THROW(notifier.Shutdown()); + + zmq_ctx_destroy(ctx); +} + +// After Initialize + Shutdown, a second Shutdown must be a no-op +// (psocket is nullptr and the guard prevents double-close). +BOOST_AUTO_TEST_CASE(shutdown_after_initialize_idempotent) +{ + void* ctx = zmq_ctx_new(); + BOOST_REQUIRE(ctx != nullptr); + + CZMQPublishHashBlockNotifier notifier; + notifier.SetType("pubhashblock"); + notifier.SetAddress("inproc://tapyrus_zmq_test_idem"); + + BOOST_CHECK(notifier.Initialize(ctx)); + BOOST_CHECK_NO_THROW(notifier.Shutdown()); + BOOST_CHECK_NO_THROW(notifier.Shutdown()); // second call must not abort + + zmq_ctx_destroy(ctx); +} + +// Two notifiers bound to the same address reuse the underlying socket. +// Both Initialize() calls succeed; both Shutdown() calls complete without error, +// and the socket is only closed when the last user shuts down. +BOOST_AUTO_TEST_CASE(shared_socket_reuse) +{ + void* ctx = zmq_ctx_new(); + BOOST_REQUIRE(ctx != nullptr); + + const std::string address = "inproc://tapyrus_zmq_test_shared"; + + CZMQPublishHashBlockNotifier first; + first.SetType("pubhashblock"); + first.SetAddress(address); + + CZMQPublishHashTransactionNotifier second; + second.SetType("pubhashtx"); + second.SetAddress(address); + + BOOST_CHECK(first.Initialize(ctx)); + BOOST_CHECK(second.Initialize(ctx)); // reuses socket opened by first + + BOOST_CHECK_NO_THROW(first.Shutdown()); // ref-count drops to 1; socket kept open + BOOST_CHECK_NO_THROW(second.Shutdown()); // ref-count drops to 0; socket closed + + zmq_ctx_destroy(ctx); +} + +BOOST_AUTO_TEST_SUITE_END() + +#endif // ENABLE_ZMQ diff --git a/src/validation.cpp b/src/validation.cpp index c9d446cc6..a6d63d29a 100644 --- a/src/validation.cpp +++ b/src/validation.cpp @@ -585,7 +585,7 @@ bool VerifyTokenBalances(const CTransaction& tx, CValidationState& state, const tpcout = iter->second; if(tpcin <= 0) - return state.Invalid(false, REJECT_INSUFFICIENTFEE, "bad-txns-token-without-fee"); + return state.DoS(100, false, REJECT_INVALID, "bad-txns-token-without-fee"); for(auto& out:outColoredCoinBalances) { @@ -1323,11 +1323,15 @@ bool CheckBlockHeader(const CBlockHeader& block, CValidationState& state, CXFiel if(!fCheckPOW) return true; - //Check proof of Signed Blocks in a block header - const unsigned int proofSize = block.proof.size(); - - if(!proofSize) - return state.Invalid(false, REJECT_INVALID, "bad-proof", "No Proof in block"); + // Schnorr proofs are always exactly 64 bytes. Reject early — before the + // expensive GetHashForSign() + Verify_Schnorr call — if the proof is the + // wrong size. A NONE xfield with trailing bytes appended by a peer causes + // those bytes to be consumed as proof data, so wrong-size proofs are the + // primary symptom of that malformation. + if (block.proof.size() != CPubKey::SCHNORR_SIGNATURE_SIZE) + return state.Invalid(false, REJECT_INVALID, "bad-proof-size", + strprintf("Proof must be %u bytes, got %u", + CPubKey::SCHNORR_SIGNATURE_SIZE, block.proof.size())); // Aggpubkey to verify blocks is read from the xfield history at the block's // height, not at the chain tip. Both temp and non-temp paths now call Get(height) uniformly. diff --git a/src/verifydb.cpp b/src/verifydb.cpp index 31f50ac9b..f6b81e32b 100644 --- a/src/verifydb.cpp +++ b/src/verifydb.cpp @@ -117,6 +117,8 @@ bool CVerifyDB::VerifyDB(CCoinsView *coinsview, int nCheckLevel, int nCheckDepth // check level 4: try reconnecting blocks if (nCheckLevel >= 4) { while (pindex != chainActive.Tip()) { + if (ShutdownRequested()) + return true; uiInterface.ShowProgress(_("Verifying blocks..."), std::max(1, std::min(99, 100 - (int)(((double)(chainActive.Height() - pindex->nHeight)) / (double)nCheckDepth * 50))), false); pindex = chainActive.Next(pindex); CBlock block; diff --git a/src/wallet/feebumper.cpp b/src/wallet/feebumper.cpp index 2ab3bc424..44f54c9ce 100644 --- a/src/wallet/feebumper.cpp +++ b/src/wallet/feebumper.cpp @@ -94,7 +94,7 @@ Result CreateTransaction(const CWallet* wallet, const uint256& txid, const CCoin // figure out which output was change // if there was no change output or multiple change outputs, fail - // Colored outputs are skipped: their nValue is a token amount, not satoshis, + // Colored outputs are skipped: their nValue is a token amount, not tapyrus, // so subtracting a TPC fee delta from them silently destroys tokens. int nOutput = -1; for (size_t i = 0; i < wtx.tx->vout.size(); ++i) { @@ -120,7 +120,10 @@ Result CreateTransaction(const CWallet* wallet, const uint256& txid, const CCoin return Result::WALLET_ERROR; } std::vector vAvailableCoins; - wallet->AvailableCoins(vAvailableCoins, true, &coin_control); + // Require at least 1 confirmation: adding a 0-conf input to a fee-bump + // creates an unconfirmed chain and defeats the purpose of RBF. + wallet->AvailableCoins(vAvailableCoins, true, &coin_control, + 1, MAX_MONEY, MAX_MONEY, 0, /*nMinDepth=*/1); for (const auto& out : vAvailableCoins) { if (!out.fSpendable) continue; const CTxOut& txout = out.tx->tx->vout[out.i]; diff --git a/src/wallet/rpcwallet.cpp b/src/wallet/rpcwallet.cpp index 81734259f..1d65f8d3f 100644 --- a/src/wallet/rpcwallet.cpp +++ b/src/wallet/rpcwallet.cpp @@ -4294,7 +4294,15 @@ UniValue IssueReissuableToken(CWallet* const pwallet, const std::string& script, } } - if (!foundExisting) { + if (foundExisting) { + // mapAddressBook can hold watch-only or externally-imported addresses. + // Verify the wallet actually holds the signing key before committing. + const CColorKeyID* ck = std::get_if(&colorDest); + if (!ck || !pwallet->HaveKey(ck->getKeyID())) { + throw JSONRPCError(RPC_WALLET_ERROR, + "Cannot reuse colored address: wallet does not have the private key for the existing address"); + } + } else { // Pre-allocate the key for tx2's colored output before tx1 is broadcast. CPubKey newKey; if (!pwallet->GetKeyFromPool(newKey)) diff --git a/src/wallet/test/psbt_wallet_tests.cpp b/src/wallet/test/psbt_wallet_tests.cpp index 4af61f0ac..8fa9baf42 100644 --- a/src/wallet/test/psbt_wallet_tests.cpp +++ b/src/wallet/test/psbt_wallet_tests.cpp @@ -160,4 +160,73 @@ BOOST_AUTO_TEST_CASE(parse_hd_keypath) BOOST_CHECK(!ParseHDKeypath("m/4294967296", keypath)); // 4294967296 == 0xFFFFFFFF (uint32_t max) + 1 } +// Regression test for out-of-bounds access when non_witness_utxo->vout.size() +// <= prevout.n. The Unserialize path must throw before any vout[] indexing. +BOOST_AUTO_TEST_CASE(psbt_non_witness_utxo_vout_oob) +{ + // Build a prev_tx with exactly 1 output (valid index = 0 only) + CMutableTransaction mtx_prev; + mtx_prev.vout.emplace_back(CAmount(1000), CScript()); + uint256 prevHashMalFix = mtx_prev.GetHashMalFix(); + + // ── Case A: prevout.n = 1 (OOB) ───────────────────────────────────────── + CMutableTransaction mtx_oob; + mtx_oob.vin.emplace_back(COutPoint(prevHashMalFix, 1)); // index 1, only 0 exists + mtx_oob.vout.emplace_back(CAmount(900), CScript()); + + PartiallySignedTransaction psbt_oob; + psbt_oob.tx = mtx_oob; + PSBTInput inp_oob; + inp_oob.non_witness_utxo = MakeTransactionRef(CTransaction(mtx_prev)); + psbt_oob.inputs.push_back(inp_oob); + psbt_oob.outputs.emplace_back(); + + CDataStream ss_oob(SER_NETWORK, PROTOCOL_VERSION); + ss_oob << psbt_oob; + + PartiallySignedTransaction psbt_oob2; + BOOST_CHECK_EXCEPTION(ss_oob >> psbt_oob2, std::ios_base::failure, [](const std::ios_base::failure& e) { + return std::string(e.what()).find("vout index out of range") != std::string::npos; + }); + + // ── Case B: prevout.n = 0xFFFFFFFF (max uint32 OOB) ───────────────────── + CMutableTransaction mtx_max; + mtx_max.vin.emplace_back(COutPoint(prevHashMalFix, 0xFFFFFFFF)); + mtx_max.vout.emplace_back(CAmount(900), CScript()); + + PartiallySignedTransaction psbt_max; + psbt_max.tx = mtx_max; + PSBTInput inp_max; + inp_max.non_witness_utxo = MakeTransactionRef(CTransaction(mtx_prev)); + psbt_max.inputs.push_back(inp_max); + psbt_max.outputs.emplace_back(); + + CDataStream ss_max(SER_NETWORK, PROTOCOL_VERSION); + ss_max << psbt_max; + + PartiallySignedTransaction psbt_max2; + BOOST_CHECK_EXCEPTION(ss_max >> psbt_max2, std::ios_base::failure, [](const std::ios_base::failure& e) { + return std::string(e.what()).find("vout index out of range") != std::string::npos; + }); + + // ── Case C: prevout.n = 0 (in-range) must deserialize cleanly ─────────── + CMutableTransaction mtx_ok; + mtx_ok.vin.emplace_back(COutPoint(prevHashMalFix, 0)); // valid + mtx_ok.vout.emplace_back(CAmount(900), CScript()); + + PartiallySignedTransaction psbt_ok; + psbt_ok.tx = mtx_ok; + PSBTInput inp_ok; + inp_ok.non_witness_utxo = MakeTransactionRef(CTransaction(mtx_prev)); + psbt_ok.inputs.push_back(inp_ok); + psbt_ok.outputs.emplace_back(); + + CDataStream ss_ok(SER_NETWORK, PROTOCOL_VERSION); + ss_ok << psbt_ok; + + PartiallySignedTransaction psbt_ok2; + BOOST_CHECK_NO_THROW(ss_ok >> psbt_ok2); + BOOST_CHECK(psbt_ok2.inputs[0].non_witness_utxo); +} + BOOST_AUTO_TEST_SUITE_END() diff --git a/src/wallet/wallet.cpp b/src/wallet/wallet.cpp index 63b78bbfa..d5f8023c2 100644 --- a/src/wallet/wallet.cpp +++ b/src/wallet/wallet.cpp @@ -2271,7 +2271,11 @@ void CWallet::AvailableCoins(std::vector &vCoins, bool fOnlySafe, const for (unsigned int i = 0; i < pcoin->tx->vout.size(); i++) { ColorIdentifier colorId = GetColorIdFromScript(pcoin->tx->vout[i].scriptPubKey); - if (pcoin->tx->vout[i].nValue < nMinimumAmount || pcoin->tx->vout[i].nValue > nMaximumAmount) + // minimumAmount/maximumAmount are in TPC. Token output nValue is a + // token count with no unit relationship to TPC, so applying this + // filter to colored outputs would silently misfilter them. + if (colorId.type == TokenTypes::NONE && + (pcoin->tx->vout[i].nValue < nMinimumAmount || pcoin->tx->vout[i].nValue > nMaximumAmount)) continue; if (coinControl && coinControl->HasSelected() && !coinControl->fAllowOtherInputs && !coinControl->IsSelected(COutPoint(entry.first, i))) diff --git a/src/zmq/zmqpublishnotifier.cpp b/src/zmq/zmqpublishnotifier.cpp index 70107bb57..dac102b29 100644 --- a/src/zmq/zmqpublishnotifier.cpp +++ b/src/zmq/zmqpublishnotifier.cpp @@ -83,6 +83,7 @@ bool CZMQAbstractPublishNotifier::Initialize(void *pcontext) { zmqError("Failed to bind address"); zmq_close(psocket); + psocket = nullptr; return false; } @@ -103,7 +104,7 @@ bool CZMQAbstractPublishNotifier::Initialize(void *pcontext) void CZMQAbstractPublishNotifier::Shutdown() { - assert(psocket); + if (!psocket) return; // Initialize never ran or already shut down int count = mapPublishNotifiers.count(address); diff --git a/test/functional/feature_cp2sh_softfork.py b/test/functional/feature_cp2sh_softfork.py index c450cbaeb..2ac07d852 100755 --- a/test/functional/feature_cp2sh_softfork.py +++ b/test/functional/feature_cp2sh_softfork.py @@ -83,7 +83,7 @@ def _wrap_submit(self, *txs, expect_reject=None): def _issue_cp2sh(self, utxo, redeem_bytes): """ Build and wallet-sign a NON_REISSUABLE issuance that creates a single - CP2SH colored output (100 satoshis / 100 tokens). + CP2SH colored output (100 TPC / 100 tokens). Returns (CTransaction, colorid_bytes). The wallet signs only the TPC input; the colored output needs no signature at creation time. diff --git a/test/functional/interface_rest.py b/test/functional/interface_rest.py index 1e7f920a1..84512cc98 100755 --- a/test/functional/interface_rest.py +++ b/test/functional/interface_rest.py @@ -228,6 +228,15 @@ def run_test(self): long_uri = '/'.join(['{}-{}'.format(txid, n_) for n_ in range(15)]) self.test_rest_request("/getutxos/checkmempool/{}".format(long_uri), http_method='POST', status=200) + self.log.info("Test /getutxos binary/hex body size limits") + # maxBinBody = sizeof(bool) + CompactSize(15) + 15*sizeof(COutPoint) + 16 = 558 + MAX_GETUTXOS_BODY = 1 + 1 + 15 * 36 + 16 # 558 + oversized_bin = b'\x00' * (MAX_GETUTXOS_BODY + 1) + self.test_rest_request("/getutxos", http_method='POST', req_type=ReqType.BIN, body=oversized_bin, status=400, ret_type=RetType.OBJ) + # HEX path: each binary byte is 2 hex chars; > 2*558 = 1116 chars should be rejected + oversized_hex = '00' * (MAX_GETUTXOS_BODY + 1) + self.test_rest_request("/getutxos", http_method='POST', req_type=ReqType.HEX, body=oversized_hex, status=400, ret_type=RetType.OBJ) + self.log.info("Test the /block and /headers URIs") bb_hash = self.nodes[0].getbestblockhash() diff --git a/test/functional/mempool_limit.py b/test/functional/mempool_limit.py index ce2dac9d0..7e307a0a0 100755 --- a/test/functional/mempool_limit.py +++ b/test/functional/mempool_limit.py @@ -170,7 +170,7 @@ def create_tx_to_be_evicted(self, node, unspent): mempool_evicted_tx.deserialize(BytesIO(hex_str_to_bytes(signresult['hex']))) tx_size_bytes = len(hex_str_to_bytes(signresult['hex'])) - # Calculate precise fee: feerate is in BTC/kB, convert to satoshis + # Calculate precise fee: feerate is in TPC/kB, convert to tapyrus fee = int(feerate * tx_size_bytes / 1000 * COIN) # Recreate transaction with precise fee diff --git a/test/functional/p2p_compactblocks.py b/test/functional/p2p_compactblocks.py index f735a4587..c38b62341 100755 --- a/test/functional/p2p_compactblocks.py +++ b/test/functional/p2p_compactblocks.py @@ -780,6 +780,55 @@ def announce_cmpct_block(node, peer): stalling_peer.send_and_ping(msg) assert_equal(int(node.getbestblockhash(), 16), block.sha256) + def test_cmpctblock_failed_not_first_in_flight(self, node, stalling_peer, attacker_peer): + """Regression: no UAF when a secondary peer sends a CMPCTBLOCK with duplicate short IDs. + + net_processing.cpp:2633-2636 (the non-first_in_flight READ_STATUS_FAILED branch) + previously fell through to partialBlock.IsTxAvailable() after calling + MarkBlockAsReceived(), which destroys the QueuedBlock and its + unique_ptr. The fix adds 'return true' to prevent + the fall-through. + """ + utxo = self.utxos.pop(0) + block = self.build_block_with_transactions(node, utxo, 3) + + # Peer 1 (stalling_peer) sends a valid CMPCTBLOCK. Because the transactions + # aren't in the mempool the node sends getblocktxn, leaving the block in-flight + # from peer 1 (first_in_flight=True for peer 1). + cmpct_block = HeaderAndShortIDs() + cmpct_block.initialize_from_block(block) + with mininode_lock: + stalling_peer.last_message.pop("getblocktxn", None) + stalling_peer.send_and_ping(msg_cmpctblock(cmpct_block.to_p2p())) + with mininode_lock: + assert "getblocktxn" in stalling_peer.last_message, \ + "node should request missing txns from first peer" + + # Peer 2 (attacker_peer) sends the same block with duplicate short IDs so that + # InitData() returns READ_STATUS_FAILED. For peer 2, first_in_flight=False + # because peer 1 already holds the in-flight slot. + # Before the fix, MarkBlockAsReceived() freed partialBlock then execution + # fell through to partialBlock.IsTxAvailable() — UAF. + p2p_cmpct = cmpct_block.to_p2p() + if len(p2p_cmpct.shortids) >= 2: + p2p_cmpct.shortids[1] = p2p_cmpct.shortids[0] # duplicate → READ_STATUS_FAILED + else: + p2p_cmpct.shortids = p2p_cmpct.shortids * 2 # duplicate all + p2p_cmpct.shortids_length = len(p2p_cmpct.shortids) + with mininode_lock: + attacker_peer.last_message.pop("getblocktxn", None) + attacker_peer.send_and_ping(msg_cmpctblock(p2p_cmpct)) + + # With the fix the node returns early after MarkBlockAsReceived(): no getblocktxn + # is sent to attacker_peer and the node remains alive. + with mininode_lock: + assert "getblocktxn" not in attacker_peer.last_message, \ + "node must not send getblocktxn after READ_STATUS_FAILED on non-first-in-flight peer" + assert_equal(int(node.getbestblockhash(), 16), block.hashPrevBlock) + + # Restore utxo chain for later tests + self.utxos.append([block.vtx[-1].malfixsha256, 0, block.vtx[-1].vout[0].nValue]) + def run_test(self): # Setup the p2p connections self.test_node = self.nodes[0].add_p2p_connection(TestP2PConn(self.nodes[0].time_to_connect)) @@ -848,6 +897,10 @@ def run_test(self): self.log.info("Testing blocktxn messages from multiple peers for the same block") self.test_number_of_blocktxn_requests(self.nodes[0], self.test_node) + self.log.info("Testing no UAF when secondary peer sends CMPCTBLOCK with duplicate short IDs...") + self.test_cmpctblock_failed_not_first_in_flight(self.nodes[1], self.extra_node, self.old_node) + sync_blocks(self.nodes) + if __name__ == '__main__': CompactBlocksTest().main() diff --git a/test/functional/rpc_dumptxoutset.py b/test/functional/rpc_dumptxoutset.py index 06763c4b5..3c29f5779 100755 --- a/test/functional/rpc_dumptxoutset.py +++ b/test/functional/rpc_dumptxoutset.py @@ -21,7 +21,9 @@ ) from test_framework.messages import CSnapshotMetadata from io import BytesIO +import os import os.path +import tempfile FILENAME = "dumptxoutset.dat" TIME_GENESIS_BLOCK = 1296688602 @@ -84,10 +86,27 @@ def run_test(self): assert_equal(snapshot.base_blockhash, out['base_hash']) assert_equal(snapshot.coins_count, out['coins_written']) - # Specifying an invalid file will fail. - invalid_path = os.path.join(get_datadir_path(self.options.tmpdir, 0), "invalid", "path") - assert_raises_rpc_error( - -8, "Couldn't open file temp file for writing", node.dumptxoutset, invalid_path) + # Specifying a path whose parent directory does not exist will fail. + invalid_path = "nonexistent_dir/path" + assert_raises_rpc_error(-8, "Path must be relative", node.dumptxoutset, "/tmp/foo/snap") + assert_raises_rpc_error(-8, "Path must not contain '..'", node.dumptxoutset, "../snap") + assert_raises_rpc_error(-8, "Couldn't open file temp file for writing", + node.dumptxoutset, invalid_path) # relative, write fails + + # A symlink inside the data directory that escapes to an outside directory + # must be rejected even though the path itself looks relative and benign. + outside_dir = tempfile.mkdtemp() + network_datadir = os.path.join(get_datadir_path(self.options.tmpdir, 0), NetworkDirName()) + symlink_path = os.path.join(network_datadir, "escape_symlink") + try: + os.symlink(outside_dir, symlink_path) + assert_raises_rpc_error( + -8, "Path resolves outside the data directory", + node.dumptxoutset, "escape_symlink/output.dat") + finally: + if os.path.islink(symlink_path): + os.remove(symlink_path) + os.rmdir(outside_dir) if __name__ == '__main__': diff --git a/test/functional/rpc_users.py b/test/functional/rpc_users.py index b890a87f0..3f3d48fd8 100755 --- a/test/functional/rpc_users.py +++ b/test/functional/rpc_users.py @@ -16,6 +16,8 @@ import http.client import urllib.parse import subprocess +import time +import threading from random import SystemRandom import string import configparser @@ -202,6 +204,59 @@ def run_test(self): assert_equal(resp.status, 401) conn.close() + ############################################################### + # Auth-failure per-IP backoff: worker threads must not block # + ############################################################### + self.log.info("Test per-IP backoff: worker threads are not held during auth failures") + url0 = urllib.parse.urlparse(self.nodes[0].url) + correct_headers = {"Authorization": "Basic " + str_to_b64str(url0.username + ':' + url0.password)} + wrong_headers = {"Authorization": "Basic " + str_to_b64str("wrong:wrong")} + + # Send DEFAULT_HTTP_THREADS (4) wrong-auth requests in parallel and measure. + # With the old MilliSleep(250) each thread would block 250 ms, so 4 parallel + # requests would take >= 250 ms. With the backoff map the threads return + # immediately; even 4 parallel bad requests must complete well under 250 ms. + errors = [] + def send_wrong_auth(): + try: + c = http.client.HTTPConnection(url0.hostname, url0.port) + c.request('POST', '/', '{"method": "getbestblockhash"}', wrong_headers) + r = c.getresponse() + r.read() + assert_equal(r.status, 401) + c.close() + except Exception as e: + errors.append(str(e)) + + threads = [threading.Thread(target=send_wrong_auth) for _ in range(4)] + t0 = time.monotonic() + for t in threads: + t.start() + for t in threads: + t.join(timeout=10.0) + elapsed = time.monotonic() - t0 + assert not errors, f"Parallel wrong-auth threads raised: {errors}" + # 4 parallel bad requests must finish well under 4 × 250 ms = 1 s + assert elapsed < 1.0, f"4 parallel wrong-auth requests took {elapsed:.2f}s; worker threads may be blocking" + + # After failures, a valid request from the same IP must still succeed + # (backoff window does not block legitimate credentials) + conn = http.client.HTTPConnection(url0.hostname, url0.port) + conn.connect() + conn.request('POST', '/', '{"method": "getbestblockhash"}', correct_headers) + resp = conn.getresponse() + assert_equal(resp.status, 200) + conn.close() + + # Successful auth clears the backoff; a subsequent wrong-auth should log again + # (i.e., not be silently dropped as within-backoff) — just verify it returns 401 + conn = http.client.HTTPConnection(url0.hostname, url0.port) + conn.connect() + conn.request('POST', '/', '{"method": "getbestblockhash"}', wrong_headers) + resp = conn.getresponse() + assert_equal(resp.status, 401) + conn.close() + if __name__ == '__main__': HTTPBasicsTest ().main ()