security: payment-URI amount validation, export filename sanitize, chat crash-guard, HTTPS-only, conf 0600, pool auto-lock
Six bounded defensive fixes: - Payment-URI amount is now strictly validated (reject NaN/inf/sign/garbage, <=8 decimals, 0..21,000,000) at parse time, so a crafted QR/URI can't inject a bogus amount into the Send form. The z_sendmany call the user confirms is unchanged; this is input validation only. - export-all-keys filename is sanitized (basename-only, no traversal) via a shared util::sanitizeExportFilename (factored out of the CSV export dialog). - Chat append()/upsert() wrap the strict serialize in try/catch so a malformed (invalid-UTF-8, attacker-influenceable) body is skipped, not crashed on — matching appendBatch's existing drop behavior. - http_download (used by the updaters + pool stats) restricts transfers AND redirects to HTTPS and bounds the download path's redirects, so a redirect can't downgrade to http:// or another scheme. - The default DRGX.conf (rpcuser/rpcpassword) is written 0600 from the start via writeFileAtomically, closing the brief world-readable ofstream+chmod window. - Auto-lock is suppressed only for SOLO mining (needs the wallet), not pool mining (pays an external address), which was holding the wallet open forever. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
|
||||
namespace dragonx {
|
||||
namespace util {
|
||||
|
||||
// Make a user-typed filename safe to write into a trusted config/export directory: basename only
|
||||
// (no path traversal), illegal / control chars -> '_', no leading dots or trailing dots-or-spaces,
|
||||
// Windows reserved device stems prefixed with '_', length-capped, and always ending in requiredExt
|
||||
// (e.g. ".csv"). Returns `fallback` if nothing usable remains. Header-only so every export dialog
|
||||
// shares one implementation.
|
||||
inline std::string sanitizeExportFilename(std::string name, const char* requiredExt, const char* fallback) {
|
||||
const std::size_t slash = name.find_last_of("/\\");
|
||||
if (slash != std::string::npos) name = name.substr(slash + 1);
|
||||
auto notSpace = [](unsigned char c) { return !std::isspace(c); };
|
||||
name.erase(name.begin(), std::find_if(name.begin(), name.end(), notSpace));
|
||||
name.erase(std::find_if(name.rbegin(), name.rend(), notSpace).base(), name.end());
|
||||
for (char& c : name) {
|
||||
const unsigned char u = static_cast<unsigned char>(c);
|
||||
if (u < 0x20 || c == '<' || c == '>' || c == ':' || c == '"' || c == '|' ||
|
||||
c == '?' || c == '*' || c == '/' || c == '\\')
|
||||
c = '_';
|
||||
}
|
||||
const std::size_t firstGood = name.find_first_not_of('.');
|
||||
if (firstGood == std::string::npos) name.clear(); else name.erase(0, firstGood);
|
||||
while (!name.empty() && (name.back() == '.' || name.back() == ' ')) name.pop_back();
|
||||
if (name.size() > 120) name.resize(120);
|
||||
// Windows reserved device names (case-insensitive, checked on the stem before the first dot).
|
||||
{
|
||||
std::string stem = name;
|
||||
const std::size_t dot = stem.find('.');
|
||||
if (dot != std::string::npos) stem = stem.substr(0, dot);
|
||||
std::transform(stem.begin(), stem.end(), stem.begin(),
|
||||
[](unsigned char c) { return static_cast<char>(std::toupper(c)); });
|
||||
static const char* const kReserved[] = {
|
||||
"CON", "PRN", "AUX", "NUL",
|
||||
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
|
||||
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
|
||||
};
|
||||
for (const char* r : kReserved) if (stem == r) { name = "_" + name; break; }
|
||||
}
|
||||
std::string lower = name;
|
||||
std::transform(lower.begin(), lower.end(), lower.begin(),
|
||||
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
||||
std::string lext = requiredExt ? requiredExt : "";
|
||||
std::transform(lext.begin(), lext.end(), lext.begin(),
|
||||
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
||||
if (!lext.empty() &&
|
||||
(lower.size() < lext.size() || lower.compare(lower.size() - lext.size(), lext.size(), lext) != 0))
|
||||
name += requiredExt;
|
||||
if (name.empty() || (requiredExt && name == requiredExt)) name = fallback ? fallback : "export";
|
||||
return name;
|
||||
}
|
||||
|
||||
} // namespace util
|
||||
} // namespace dragonx
|
||||
@@ -60,6 +60,20 @@ int cancelXferCb(void* clientp, curl_off_t, curl_off_t, curl_off_t, curl_off_t)
|
||||
return static_cast<const std::atomic<bool>*>(clientp)->load(std::memory_order_relaxed) ? 1 : 0;
|
||||
}
|
||||
|
||||
// Restrict the transfer AND any redirect to HTTPS so a redirect can't silently downgrade to http://,
|
||||
// file://, ftp:// or an unexpected scheme. Every caller of these shared helpers (daemon/xmrig updaters,
|
||||
// pool stats) uses https URLs. (Verify-peer/host stay on at the call sites.)
|
||||
void restrictToHttps(CURL* curl)
|
||||
{
|
||||
#if LIBCURL_VERSION_NUM >= 0x075500 // 7.85.0: the string-form protocol setopts
|
||||
curl_easy_setopt(curl, CURLOPT_PROTOCOLS_STR, "https");
|
||||
curl_easy_setopt(curl, CURLOPT_REDIR_PROTOCOLS_STR, "https");
|
||||
#else
|
||||
curl_easy_setopt(curl, CURLOPT_PROTOCOLS, CURLPROTO_HTTPS);
|
||||
curl_easy_setopt(curl, CURLOPT_REDIR_PROTOCOLS, CURLPROTO_HTTPS);
|
||||
#endif
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
HttpGetResult httpGet(const std::string& url, const char* logTag, const HttpGetOptions& opt)
|
||||
@@ -78,6 +92,7 @@ HttpGetResult httpGet(const std::string& url, const char* logTag, const HttpGetO
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &sink);
|
||||
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
|
||||
curl_easy_setopt(curl, CURLOPT_MAXREDIRS, opt.maxRedirects);
|
||||
restrictToHttps(curl);
|
||||
curl_easy_setopt(curl, CURLOPT_USERAGENT, "ObsidianDragon/1.0");
|
||||
curl_easy_setopt(curl, CURLOPT_TIMEOUT, opt.timeoutSec);
|
||||
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, opt.connectTimeoutSec);
|
||||
@@ -131,6 +146,8 @@ bool httpDownloadToFile(const std::string& url, const std::string& destPath,
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writeFileCb);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEDATA, fp);
|
||||
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
|
||||
curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 5L); // bound redirects (this path set none before)
|
||||
restrictToHttps(curl);
|
||||
curl_easy_setopt(curl, CURLOPT_USERAGENT, "ObsidianDragon/1.0");
|
||||
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 0L);
|
||||
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 30L);
|
||||
|
||||
@@ -10,6 +10,29 @@
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cstdio>
|
||||
#include <cmath>
|
||||
|
||||
namespace {
|
||||
// Strictly parse a decimal DRGX amount from an (attacker-influenceable) payment URI / QR: digits with at
|
||||
// most one '.', at most 8 fractional digits, in [0, 21000000]. Rejects empty, NaN/inf, signs, whitespace
|
||||
// and trailing garbage that std::stod would otherwise accept — so a crafted URI can't inject a bogus
|
||||
// amount into the Send form.
|
||||
bool parseStrictAmount(const std::string& s, double& out) {
|
||||
if (s.empty() || s.size() > 32) return false;
|
||||
int dots = 0, digits = 0, frac = 0; bool afterDot = false;
|
||||
for (char c : s) {
|
||||
if (c == '.') { if (++dots > 1) return false; afterDot = true; }
|
||||
else if (c >= '0' && c <= '9') { ++digits; if (afterDot && ++frac > 8) return false; }
|
||||
else return false;
|
||||
}
|
||||
if (digits == 0) return false;
|
||||
double v = 0.0;
|
||||
try { v = std::stod(s); } catch (...) { return false; }
|
||||
if (!std::isfinite(v) || v < 0.0 || v > 21000000.0) return false;
|
||||
out = v;
|
||||
return true;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
namespace dragonx {
|
||||
namespace util {
|
||||
@@ -141,11 +164,13 @@ PaymentURI parsePaymentURI(const std::string& uri)
|
||||
std::transform(key.begin(), key.end(), key.begin(), ::tolower);
|
||||
|
||||
if (key == "amount" || key == "amt") {
|
||||
try {
|
||||
result.amount = std::stod(value);
|
||||
} catch (...) {
|
||||
// Invalid amount, keep as 0
|
||||
double a = 0.0;
|
||||
if (!parseStrictAmount(value, a)) {
|
||||
result.error = "Invalid amount";
|
||||
result.valid = false;
|
||||
return result; // reject the whole URI rather than silently treating it as 0
|
||||
}
|
||||
result.amount = a;
|
||||
} else if (key == "label") {
|
||||
result.label = value;
|
||||
} else if (key == "memo") {
|
||||
@@ -174,7 +199,7 @@ PaymentURI parsePaymentURI(const std::string& uri)
|
||||
}
|
||||
|
||||
// Validate amount if present
|
||||
if (result.amount < 0) {
|
||||
if (!std::isfinite(result.amount) || result.amount < 0 || result.amount > 21000000.0) {
|
||||
result.error = "Invalid negative amount";
|
||||
return result;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user