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;
std::string token;
bool quoted = false;
if (command[index] == '"' || command[index] == '\'') {
quoted = true; // an explicit "" is a real empty argument, not a gap to skip
char quote = command[index++];
while (index < command.size() && command[index] != quote) {
token += command[index++];
@@ -148,7 +150,7 @@ std::vector<std::string> ParseConsoleCommandArgs(const std::string& command)
token += command[index++];
}
}
if (!token.empty()) args.push_back(token);
if (!token.empty() || quoted) args.push_back(token);
}
return args;
}
+15 -6
View File
@@ -29,21 +29,30 @@ static std::string s_status;
// Helper to escape CSV field
static std::string escapeCSV(const std::string& field)
{
if (field.find(',') != std::string::npos ||
field.find('"') != std::string::npos ||
field.find('\n') != std::string::npos) {
// Neutralize spreadsheet formula injection: a field beginning with '=', '+', '-', '@',
// tab, or CR is interpreted as a formula by Excel/LibreOffice, letting an attacker-supplied
// 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
std::string escaped;
escaped.reserve(field.size() + 4);
escaped.reserve(safe.size() + 4);
escaped += '"';
for (char c : field) {
for (char c : safe) {
if (c == '"') escaped += "\"\"";
else escaped += c;
}
escaped += '"';
return escaped;
}
return field;
return safe;
}
void ExportTransactionsDialog::show()
+3 -2
View File
@@ -1110,9 +1110,10 @@ void RenderSendTab(App* app)
// Handle pending payment from URI
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();
strncpy(s_memo, app->getPendingMemo().c_str(), sizeof(s_memo) - 1);
snprintf(s_memo, sizeof(s_memo), "%s", app->getPendingMemo().c_str());
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");
EXPECT_EQ(args.size(), static_cast<size_t>(4));
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");
EXPECT_TRUE(call.valid);
EXPECT_EQ(call.method, std::string("setgenerate"));