fix(security): harden untrusted-input parsing (Tier 4 audit items)

- export: neutralize CSV/spreadsheet formula injection in transaction export —
  escapeCSV() now prefixes fields beginning with = + - @ / tab / CR with a
  single quote so a hostile memo/address cannot execute on open (F13-1).
- console: preserve explicitly-quoted empty arguments in ParseConsoleCommandArgs
  so a "" no longer collapses and shifts later positional RPC params; adds a
  regression assertion to testConsoleInputModel (F14-1).
- send: copy pending payment-URI address/memo with snprintf("%s") instead of
  strncpy(…, sizeof-1), which does not NUL-terminate on truncation (F8-2).

All build clean (full-node + tests) and the suite passes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 01:56:13 -05:00
co-authored by Claude Fable 5
parent 5554016029
commit 13788b5dd0
4 changed files with 27 additions and 9 deletions
+3 -1
View File
@@ -127,7 +127,9 @@ std::vector<std::string> ParseConsoleCommandArgs(const std::string& command)
if (index >= command.size()) break; if (index >= command.size()) break;
std::string token; std::string token;
bool quoted = false;
if (command[index] == '"' || command[index] == '\'') { if (command[index] == '"' || command[index] == '\'') {
quoted = true; // an explicit "" is a real empty argument, not a gap to skip
char quote = command[index++]; char quote = command[index++];
while (index < command.size() && command[index] != quote) { while (index < command.size() && command[index] != quote) {
token += command[index++]; token += command[index++];
@@ -148,7 +150,7 @@ std::vector<std::string> ParseConsoleCommandArgs(const std::string& command)
token += command[index++]; token += command[index++];
} }
} }
if (!token.empty()) args.push_back(token); if (!token.empty() || quoted) args.push_back(token);
} }
return args; return args;
} }
+15 -6
View File
@@ -29,21 +29,30 @@ static std::string s_status;
// Helper to escape CSV field // Helper to escape CSV field
static std::string escapeCSV(const std::string& field) static std::string escapeCSV(const std::string& field)
{ {
if (field.find(',') != std::string::npos || // Neutralize spreadsheet formula injection: a field beginning with '=', '+', '-', '@',
field.find('"') != std::string::npos || // tab, or CR is interpreted as a formula by Excel/LibreOffice, letting an attacker-supplied
field.find('\n') != std::string::npos) { // memo/address execute on open. Prefix such fields with a single quote so they render as text.
std::string safe = field;
if (!safe.empty()) {
const char c0 = safe.front();
if (c0 == '=' || c0 == '+' || c0 == '-' || c0 == '@' || c0 == '\t' || c0 == '\r')
safe.insert(safe.begin(), '\'');
}
if (safe.find(',') != std::string::npos ||
safe.find('"') != std::string::npos ||
safe.find('\n') != std::string::npos) {
// Escape quotes and wrap in quotes // Escape quotes and wrap in quotes
std::string escaped; std::string escaped;
escaped.reserve(field.size() + 4); escaped.reserve(safe.size() + 4);
escaped += '"'; escaped += '"';
for (char c : field) { for (char c : safe) {
if (c == '"') escaped += "\"\""; if (c == '"') escaped += "\"\"";
else escaped += c; else escaped += c;
} }
escaped += '"'; escaped += '"';
return escaped; return escaped;
} }
return field; return safe;
} }
void ExportTransactionsDialog::show() void ExportTransactionsDialog::show()
+3 -2
View File
@@ -1110,9 +1110,10 @@ void RenderSendTab(App* app)
// Handle pending payment from URI // Handle pending payment from URI
if (app->hasPendingPayment()) { if (app->hasPendingPayment()) {
strncpy(s_to_address, app->getPendingToAddress().c_str(), sizeof(s_to_address) - 1); // snprintf always NUL-terminates; strncpy(…, sizeof-1) does not on truncation.
snprintf(s_to_address, sizeof(s_to_address), "%s", app->getPendingToAddress().c_str());
s_amount = app->getPendingAmount(); s_amount = app->getPendingAmount();
strncpy(s_memo, app->getPendingMemo().c_str(), sizeof(s_memo) - 1); snprintf(s_memo, sizeof(s_memo), "%s", app->getPendingMemo().c_str());
app->clearPendingPayment(); app->clearPendingPayment();
} }
+6
View File
@@ -2378,6 +2378,12 @@ void testConsoleInputModel()
auto args = dragonx::ui::ParseConsoleCommandArgs("z_sendmany \"from addr\" [{\"address\":\"zs\",\"amount\":1.25}] true"); auto args = dragonx::ui::ParseConsoleCommandArgs("z_sendmany \"from addr\" [{\"address\":\"zs\",\"amount\":1.25}] true");
EXPECT_EQ(args.size(), static_cast<size_t>(4)); EXPECT_EQ(args.size(), static_cast<size_t>(4));
EXPECT_EQ(args[1], std::string("from addr")); EXPECT_EQ(args[1], std::string("from addr"));
// An explicitly-quoted empty argument must be preserved (not dropped), otherwise
// later positional RPC params silently shift by one (F14-1).
auto emptyArgs = dragonx::ui::ParseConsoleCommandArgs("walletpassphrase \"\" 60");
EXPECT_EQ(emptyArgs.size(), static_cast<size_t>(3));
EXPECT_EQ(emptyArgs[1], std::string(""));
EXPECT_EQ(emptyArgs[2], std::string("60"));
auto call = dragonx::ui::BuildConsoleRpcCall("setgenerate true 4"); auto call = dragonx::ui::BuildConsoleRpcCall("setgenerate true 4");
EXPECT_TRUE(call.valid); EXPECT_TRUE(call.valid);
EXPECT_EQ(call.method, std::string("setgenerate")); EXPECT_EQ(call.method, std::string("setgenerate"));